← posts/b.log()

blog92@web:~$ cat posts/docker-deep-dive.md

DEVOPS12 min read

도커의 본질 — 컨테이너를 맨손으로 만들며 이해하기

리눅스 namespace·cgroups·pivot_root만으로 컨테이너를 직접 만들어 보며, 도커가 자동화한 것이 무엇인지 본질부터 이해한다.

docker run nginx 한 줄을 칠 때, 그 안에서 무슨 일이 벌어지는지 설명할 수 있을까. 명령어를 외워서 쓰는 것과, 그 명령어가 커널 수준에서 무엇을 하는지 아는 것은 완전히 다른 이야기다.

이 글은 "컨테이너란 사실 무엇인가"라는 본질에서 출발한다. 그리고 도커를 단 한 번도 쓰지 않고, 리눅스 기본 명령어만으로 컨테이너를 직접 손으로 만들어 본다. 그 경험 위에서 도커의 구조·이미지·네트워크가 왜 그렇게 설계됐는지를 이해하게 된다.

이 글의 가장 중요한 한 줄을 미리 말하면 이렇다.

리눅스 커널에는 "컨테이너"라는 객체가 존재하지 않는다. 컨테이너는 여러 커널 기능을 조합해 만들어낸 환상이다.

1. 도커가 풀려던 문제: "내 컴퓨터에서는 되는데요"

소프트웨어는 결코 혼자 동작하지 않는다. 특정 OS 버전, 시스템 라이브러리, 언어 런타임, 환경 변수, 설정 파일 — 이 모든 환경(environment)에 의존한다. 문제는 개발자의 노트북, 테스트 서버, 운영 서버의 환경이 미묘하게 다르다는 것이고, 그 결과가 악명 높은 "works on my machine"이다.

도커 이전의 해법들은 저마다 한계가 있었다.

  • 설치 문서: 사람이 손으로 따라 하니 단계가 빠지고 버전이 어긋난다. 재현 불가능.
  • 구성 관리 도구(Ansible, Chef, Puppet): 머신을 "원하는 상태"로 수렴시키지만 느리고, 시간이 지나면 실제 상태가 선언과 어긋나는 드리프트가 발생한다.
  • 가상 머신(VM): 격리는 완벽하지만 너무 무겁다.

도커의 아이디어는 명료하다. 애플리케이션과 그것이 의존하는 모든 것을 하나의 불변(immutable) 패키지로 묶어 어디서든 똑같이 실행한다. 그런데 이걸 VM보다 압도적으로 가볍게 해냈다는 게 혁신이었다. 그 "가볍게"의 비밀이 곧 컨테이너의 본질이다.

2. VM vs 컨테이너: 무엇을 가상화하는가

이 둘의 차이를 "VM은 무겁고 컨테이너는 가볍다"로만 외우면 핵심을 놓친다. 무엇을 가상화하는가가 근본적으로 다르다.

가상 머신 (VM)컨테이너
가상화 대상하드웨어OS (커널 공유)
커널게스트마다 별도 커널 부팅호스트 커널을 모두 공유
크기수 GB수 MB
시작 시간수십 초 ~ 수 분밀리초
격리 강도강함상대적으로 약함

VM은 하이퍼바이저가 가짜 하드웨어를 만들어 그 위에 완전한 게스트 OS(커널 포함)를 올린다. 그래서 무겁다.

컨테이너는 호스트 커널을 공유한다. 별도 커널을 부팅하지 않고, 그저 호스트 위에서 도는 하나의 프로세스인데, 커널 기능으로 "자기만의 시스템 안에 있는 것처럼" 격리되어 있을 뿐이다. 그래서 가볍고 빠르다.

ps로 보면 컨테이너는 그냥 프로세스다. 도커가 마법을 부리는 게 아니라, 리눅스가 원래 가진 기능들을 영리하게 엮는 것뿐이다.

3. 컨테이너를 만드는 리눅스 3대 원시 기능

컨테이너는 세 축으로 만들어진다. 무엇을 볼 수 있는가(namespaces), 무엇을 얼마나 쓸 수 있는가(cgroups), 어떤 파일시스템을 보는가(union filesystem). 여기에 보안 계층이 더해진다.

네임스페이스(Namespaces) — 격리, 즉 "시야"의 제한

한 프로세스가 시스템의 어떤 부분을 볼 수 있는지를 격리한다.

  • PID: 프로세스 번호 공간 분리. 컨테이너 안 첫 프로세스가 PID 1이 된다.
  • NET: 독립된 네트워크 스택(인터페이스, 라우팅, iptables, 포트). 컨테이너마다 80번 포트를 열어도 부딪히지 않는다.
  • MNT: 독립된 마운트 테이블. 자기만의 /를 본다.
  • UTS: 독립된 호스트명.
  • IPC: 독립된 프로세스 간 통신 자원.
  • USER: UID/GID 매핑. 컨테이너 안 root를 호스트의 일반 사용자로 매핑 → rootless의 핵심.

cgroups — 자원의 제한과 계측

네임스페이스가 "볼 수 있는 것"이라면, cgroups는 "쓸 수 있는 것"이다. CPU·메모리·I/O·PID 개수를 그룹 단위로 제한한다. docker run --memory=512m이 정확히 이걸 설정한다. 한도를 넘으면 커널 OOM killer가 프로세스를 죽인다.

유니온 파일시스템(OverlayFS) — 레이어와 Copy-on-Write

여러 디렉터리를 겹쳐 하나의 뷰로 보여준다. 읽기 전용 lower 계층(이미지 레이어)들 위에 읽기-쓰기 upper 계층을 얹는다. 파일을 수정하려는 순간에만 그 파일을 upper로 복사해 수정하는 Copy-on-Write 방식이다.

이 설계 덕분에 (1) 이미지 레이어를 여러 컨테이너가 공유하고, (2) 컨테이너 생성이 거의 공짜가 된다. 같은 이미지로 100개 컨테이너를 띄워도 읽기 레이어는 디스크에 한 벌뿐이다.

보안 계층

  • Capabilities: root 권한을 약 40개로 쪼갠 것. 도커는 위험한 것 대부분을 떨군다.
  • seccomp: 호출 가능한 시스템 콜을 필터링한다.
  • LSM(AppArmor/SELinux): 강제 접근 제어.

4. [실습] 도커 없이 컨테이너를 맨손으로 만들기

이론은 충분하다. 이제 도커를 한 번도 쓰지 않고 위 기능들만으로 컨테이너를 직접 만들어, "컨테이너는 그냥 프로세스"라는 명제를 눈으로 확인한다. (리눅스 환경에서 root 권한으로 따라 할 수 있다.)

실습 1: UTS 네임스페이스 — 호스트명 격리

bash
unshare --uts sh -c '
  hostname container-by-hand
  echo "안쪽 hostname: $(hostname)"
'
echo "바깥 hostname: $(hostname)"
text
안쪽 hostname: container-by-hand
바깥 hostname: vm        # 안에서 바꿨지만 바깥은 그대로

같은 커널, 같은 머신인데 "호스트명"이라는 자원을 서로 다르게 본다. 이게 격리의 정체다.

실습 2: PID 네임스페이스 — "나는 PID 1이다"

bash
unshare --pid --fork --mount-proc sh -c '
  echo "내 PID: $$"
  ps -e -o pid,comm
'
text
내 PID: 1               # 새 네임스페이스에서 나는 1번
    PID COMMAND
      1 sh
      2 ps

바깥엔 수십 개의 프로세스가 있지만, 안에서는 딱 2개만 보인다. 도커 컨테이너에서 보던 바로 그 모습이다.

왜 옵션이 이렇게 많을까?

  • --fork: unshare 자신은 이미 PID가 정해졌으므로, 자식을 fork해 그 자식이 새 네임스페이스의 PID 1이 되게 한다.
  • --mount-proc: ps는 /proc를 읽는다. /proc를 새로 마운트하지 않으면 바깥 /proc가 그대로 보여 격리가 깨진다. 그래서 마운트 네임스페이스를 함께 만들어야 비로소 안쪽 세계가 완성된다. 격리는 단일 기능이 아니라 여러 네임스페이스의 조합이다.

실습 3: rootfs + pivot_root — 자기만의 루트 파일시스템

가장 극적인 부분이다. 먼저 셸과 필수 명령어, 그리고 그들이 의존하는 공유 라이브러리만 모아 최소 루트 파일시스템(rootfs)을 만든다. 사실상 FROM scratch 이미지를 손으로 빚는 작업이다.

bash
ROOT=/tmp/rootfs
for d in bin lib lib64 proc sys dev etc; do mkdir -p "$ROOT/$d"; done
 
copy_with_libs() {
  local bin; bin="$(command -v "$1")"
  cp -L "$bin" "$ROOT/bin/"
  ldd "$bin" 2>/dev/null | grep -oE '/[^ ]+\.so[^ ]*' | while read -r lib; do
    mkdir -p "$ROOT$(dirname "$lib")"; cp -L "$lib" "$ROOT$lib" 2>/dev/null || true
  done
}
for cmd in bash ls cat hostname ps mount sleep id grep; do copy_with_libs "$cmd"; done
ln -sf bash "$ROOT/bin/sh"

이제 9.5MB짜리 우리만의 rootfs가 생겼다. pivot_root로 루트(/)를 이 rootfs로 바꿔치기한다. runc가 컨테이너를 띄울 때 하는 바로 그 동작이다.

bash
unshare --mount --uts --pid --fork --propagation private bash -c '
  ROOT=/tmp/rootfs
  mount --bind "$ROOT" "$ROOT"          # pivot_root는 새 루트가 마운트 지점이어야 함
  mkdir -p "$ROOT/old_root"
  mount -t proc proc "$ROOT/proc"
  cd "$ROOT"
  pivot_root . old_root                 # 현재 루트를 old_root로 밀어내고 . 을 새 루트로
  cd /
  umount -l /old_root                   # 옛 호스트 루트 분리 → 호스트 파일시스템 사라짐
  hostname pivoted-container
 
  echo "[루트(/)에 보이는 것]"; ls /
  echo "[hostname] $(hostname)"
'
text
[루트(/)에 보이는 것]
bin  dev  etc  lib  lib64  old_root  proc  sys
[hostname] pivoted-container

/를 보면 우리가 만든 rootfs만 있고, 호스트의 방대한 파일시스템은 사라졌다. 도커 없이 순수 리눅스 도구만으로 컨테이너를 만든 것이다.

실습 4: cgroups — 자원에 한도를 걸다

격리(네임스페이스)를 했으니 제한(cgroups) 차례다.

bash
CG=/sys/fs/cgroup/demo_box
mkdir -p "$CG"
echo "+memory" > /sys/fs/cgroup/cgroup.subtree_control
echo $((50*1024*1024)) > "$CG/memory.max"   # 메모리 상한 50MB
echo 0 > "$CG/memory.swap.max"               # 스왑도 차단해야 한도가 강제됨

함정 하나: memory.max만 걸면 스왑으로 빠져나가 OOM이 안 날 수 있다. 그래서 memory.swap.max도 0으로 막아야 한다. 도커가 --memory와 --memory-swap을 함께 다루는 이유다.

일반 호스트라면, 이 cgroup에 등록된 프로세스가 50MB를 넘기는 순간 종료 코드 137(=128+9, SIGKILL)과 함께 OOM kill된다.

우리가 만든 것 정리

컨테이너의 속성사용한 도구
독립된 호스트명unshare --uts
독립된 프로세스 공간unshare --pid --fork --mount-proc
독립된 파일시스템mount --bind + pivot_root
자원 한도cgroups (memory.max)

"컨테이너"라는 마법의 객체는 없었다. unshare, mount, pivot_root, cgroups 파일에 값 쓰기 — 평범한 조각들을 엮었더니 "독립된 시스템 같은" 환상이 만들어졌을 뿐이다.

우리가 생략한 것 (도커가 더 해주는 일)

정직하게, 우리 컨테이너는 진짜 도커 컨테이너보다 허술하다. 그 차이가 곧 도커의 가치다.

  • USER 네임스페이스 미적용 → 컨테이너 root가 호스트 진짜 root (위험). 도커 rootless가 해결한다.
  • NET 네임스페이스 + veth 미적용 → 격리된 네트워크 없음.
  • capability 드롭 / seccomp 미적용 → 탈출이 쉽다.
  • 쓰기 레이어(overlay upper) 미사용 → 변경이 원본에 그대로 간다.

5. 도커의 아키텍처와 진화

도커는 처음엔 하나의 거대한 데몬이었지만, 이후 의도적으로 분해되어 왔다. 이 분해를 알면 containerd, runc 같은 이름이 왜 등장하는지 이해된다.

text
docker CLI → dockerd → containerd → containerd-shim → runc → [컨테이너 프로세스]
  • Docker CLI: 우리가 치는 명령어. 사실은 클라이언트이며 REST API로 데몬과 통신한다(클라이언트-서버 구조).
  • dockerd: 고수준 기능(API, 이미지 빌드, 네트워크·볼륨).
  • containerd: 컨테이너 수명주기 관리(이미지 풀, 스토리지, 감독). CNCF 표준 런타임.
  • containerd-shim: 컨테이너의 입출력·종료코드를 붙들어, containerd가 재시작돼도 컨테이너가 안 죽게 한다.
  • runc: 최하위 OCI 런타임. OCI 번들(rootfs + config.json)을 받아 4장의 그 syscall들을 실제로 호출한다.

OCI 표준 — 도커 말고도 대안이 존재하는 이유

2015년 OCI(Open Container Initiative)가 런타임·이미지·배포 스펙을 표준화했다. 덕분에 도커가 만든 이미지를 Podman, CRI-O 등도 똑같이 실행할 수 있다. 도커는 이제 "유일한 길"이 아니라 "한 구현체"다.

4장 실습과의 연결: runc가 받는 config.json은 우리가 손으로 친 모든 결정("어떤 네임스페이스를, cgroup 한도는 얼마, 엔트리포인트는 무엇")을 선언적으로 적은 명세서다. runc는 우리가 손으로 한 절차를 자동·표준화하는 도구일 뿐이다.

6. 이미지의 구조: 레이어와 콘텐츠 주소 지정

이미지를 "앱이 든 zip" 정도로 생각하면 빌드 캐시나 멀티스테이지를 영원히 이해하지 못한다. 정확히는 읽기 전용 레이어들의 스택 + 메타데이터다.

각 레이어는 파일시스템 변경분의 tarball이고, 그 내용의 SHA256 다이제스트로 식별된다(콘텐츠 주소 지정). 내용이 같으면 다이제스트가 같고, 같으면 저장소에 한 벌만 저장·공유된다.

이 한 가지 설계가 큰 효율을 만든다.

  • 서로 다른 이미지가 같은 베이스를 쓰면 그 레이어는 한 번만 저장된다.
  • 풀할 때 없는 레이어만 내려받는다.
  • 다이제스트로 무결성을 검증한다.

이미지 vs 컨테이너

  • 이미지: 읽기 전용 레이어 + 설정. 정적·불변.
  • 컨테이너: 그 위에 얇은 쓰기 레이어를 얹은 것 + 실행 중인 프로세스.

실행 중 변경은 쓰기 레이어로 가고, 컨테이너를 지우면 사라진다. 그래서 중요한 데이터는 쓰기 레이어가 아닌 볼륨에 둬야 한다.

7. Dockerfile: 레이어와 빌드 캐시

대원칙: 파일시스템을 바꾸는 명령어(RUN, COPY, ADD)는 새 레이어를 만들고, 메타데이터만 바꾸는 명령어(ENV, WORKDIR, CMD 등)는 설정만 수정한다.

빌드 캐시 — 순서가 전부다

도커는 각 명령어를 해싱해 변경이 없으면 캐시를 재사용한다. 핵심은 캐시 무효화가 연쇄된다는 것 — 어떤 레이어의 캐시가 깨지면 그 아래 모든 레이어가 다시 빌드된다.

그래서 자주 안 바뀌는 것을 위에, 자주 바뀌는 것을 아래에 둬야 한다.

dockerfile
COPY package.json package-lock.json ./
RUN npm ci          # 의존성: 자주 안 바뀜 → 캐시 잘 됨
COPY . .            # 소스코드: 자주 바뀜 → 여기서 캐시 깨져도 위는 보존

COPY . .를 위로 올리면 소스 한 줄만 고쳐도 매번 의존성을 다시 설치하게 된다. 이 순서 하나로 빌드 시간이 분 단위로 달라진다.

멀티스테이지 빌드 — 최종 이미지를 날씬하게

dockerfile
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp
 
FROM alpine:3.20
COPY --from=builder /app/myapp /usr/local/bin/myapp
ENTRYPOINT ["myapp"]

최종 이미지엔 컴파일러도 소스도 없이 실행 바이너리만 남아, 크기와 공격 표면이 모두 줄어든다.

8. 네트워킹: veth 페어와 브리지의 실체

컨테이너 네트워킹도 마법이 아니라 NET 네임스페이스 + 리눅스 네트워크 기능의 조합이다.

기본은 브리지 네트워크다. 도커는 docker0라는 가상 스위치를 만들고, 컨테이너가 뜰 때 veth 페어(가상 케이블의 양 끝)를 만들어 한쪽은 컨테이너 안에서 eth0로, 다른 쪽은 docker0 브리지에 꽂는다.

  • 같은 브리지의 컨테이너끼리 통신할 수 있다.
  • 외부로 나갈 땐 iptables NAT(마스커레이딩).
  • -p 8080:80은 호스트 8080을 컨테이너 80으로 넘기는 DNAT 규칙을 설치하는 것이다.
드라이버특징
bridge기본. 단일 호스트 격리 네트워크
host호스트 네트워크 공유. 격리 없음, NAT 오버헤드 없음
none네트워크 없음
overlay여러 호스트에 걸침(VXLAN). 클러스터용

사용자 정의 브리지를 만들면 컨테이너 이름으로 DNS가 된다(기본 docker0는 안 됨). Docker Compose가 이걸 자동으로 해준다.

9. 스토리지: 데이터는 쓰기 레이어에 두지 말 것

컨테이너의 쓰기 레이어는 휘발성이다. 지우면 데이터도 사라진다. 그래서 지속 데이터는 별도로 관리한다.

  • 볼륨(Volume): 도커가 관리(/var/lib/docker/volumes). 컨테이너 수명주기와 분리. 영속 데이터에 권장.
  • 바인드 마운트: 호스트 경로를 직접 마운트. 개발 시 소스코드 마운트에 유용.
  • tmpfs: 메모리상에만 존재.

원칙: 불변 코드는 이미지에, 변하는 상태는 볼륨에.

10. docker run nginx 한 줄의 완전 분해

이제 이 한 줄이 내부에서 펼쳐지는 전체 그림이 보일 것이다.

  1. 레지스트리에서 nginx 이미지의 없는 레이어만 내려받는다(콘텐츠 주소 기반).
  2. 읽기 전용 레이어들을 OverlayFS로 합치고, 위에 쓰기 레이어를 얹어 rootfs를 만든다.
  3. 이 rootfs와 실행 설정으로 OCI 번들(config.json)을 구성한다.
  4. containerd가 shim을 통해 runc를 호출한다.
  5. runc가 (4장에서 우리가 손으로 한 그대로) 네임스페이스 생성 → cgroups 한도 → veth 네트워크 → pivot_root → old_root 언마운트 → capability 드롭·seccomp → 엔트리포인트(nginx)를 PID 1로 exec 한다.
  6. shim이 그 프로세스의 입출력·종료코드를 붙들고 살아남는다.

우리가 4장에서 5번의 알맹이를 직접 손으로 체험한 셈이다.

마치며

이 글의 핵심을 한 줄로 압축하면 이렇다.

도커의 본질은, 우리가 손으로 친 unshare + pivot_root + cgroups 절차를 — 이미지 관리·네트워킹·보안 설정과 함께 — 표준화하고 자동화한 것이다.

"컨테이너는 그냥 프로세스"라는 명제를 이제는 직접 만들어 본 경험으로 갖게 됐다. 앞으로 도커의 어떤 동작을 봐도 "이건 내부적으로 어떤 네임스페이스/cgroup/마운트를 건드리는 거구나"로 환원해서 볼 수 있을 것이다.

핵심 요약

개념핵심
컨테이너네임스페이스(격리) + cgroups(제한) + 유니온 파일시스템(레이어)이 적용된 평범한 프로세스
도커 아키텍처CLI → dockerd → containerd → runc 계층. OCI 표준 덕분에 Podman·CRI-O도 같은 이미지 실행
이미지 / 컨테이너이미지 = 콘텐츠 주소 기반 읽기 전용 레이어 스택, 컨테이너 = 그 위 쓰기 레이어 + 프로세스
Dockerfile순서가 곧 캐시 효율. 멀티스테이지로 최종 이미지를 날씬하게
네트워킹 / 스토리지veth + 브리지 + iptables, 영속 데이터는 볼륨에
COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..