Skip to main content

Command Palette

Search for a command to run...

Docker 컨테이너의 내부 — Namespaces, Cgroups, OverlayFS, 그리고 runc

Updated
14 min read

docker run을 입력하면 어떤 마법이 일어날까요. 사실 마법은 없습니다. 리눅스 커널이 이미 가지고 있던 세 가지 부품(namespaces, cgroups, OverlayFS) 위에 OCI 규격의 얇은 런타임을 얹은 것이 컨테이너입니다. 이 글은 컨테이너의 가벼움이 어디서 오는지, "격리"가 정확히 무엇을 격리하는지, 그리고 이미지가 디스크 위에서 어떻게 살아 있는지를 같은 그림 위에서 풀어냅니다. 컨테이너를 매일 쓰지만 안쪽을 한 번도 들춰 보지 못한 백엔드 개발자를 대상으로 합니다.

컨테이너는 VM이 아니다

가상머신과 컨테이너를 같은 이름으로 묶어 부르는 경우가 많지만, 둘은 추상화 레벨이 다릅니다. VM은 하이퍼바이저가 가상 하드웨어를 만들고 그 위에 게스트 OS 커널이 따로 동작합니다. 컨테이너는 호스트 커널을 그대로 공유한 채로, 같은 커널이 가진 격리 기능을 빌려 "다른 시스템인 것처럼" 보이게 만들 뿐입니다.

flowchart TB
  subgraph VM["Virtual Machines"]
    Host1[Host OS Kernel] --> Hypervisor[Hypervisor]
    Hypervisor --> GuestA[Guest Kernel A]
    Hypervisor --> GuestB[Guest Kernel B]
    GuestA --> AppA[App A]
    GuestB --> AppB[App B]
  end
  subgraph CT["Containers"]
    Host2[Host OS Kernel] --> NS1[ns + cgroup A]
    Host2 --> NS2[ns + cgroup B]
    NS1 --> AppC[App C]
    NS2 --> AppD[App D]
  end

이 차이는 시작 시간, 메모리 풋프린트, 그리고 보안 경계의 강도에 직결됩니다. 컨테이너는 프로세스 하나를 띄우는 것에 가깝기 때문에 수십 밀리초 안에 시작합니다. 대신 호스트 커널이 같다는 사실은, 커널 익스플로잇이 격리를 통째로 뚫을 수 있다는 의미이기도 합니다.

이 글에서 따라가 볼 부품은 네 개입니다.

  • Namespaces: 시스템 자원의 "보이는 범위"를 분할합니다.
  • Cgroups: 시스템 자원의 "쓸 수 있는 양"을 분할합니다.
  • OverlayFS: 이미지 레이어를 합쳐 하나의 파일시스템처럼 보이게 만듭니다.
  • runc: 위 세 가지를 OCI 규격대로 묶어 컨테이너 프로세스를 띄웁니다.

1. Namespaces — "보이는 범위"의 격리

리눅스 namespace는 커널이 관리하는 자원의 식별자 집합을 프로세스 단위로 분리하는 메커니즘입니다. 같은 호스트의 두 프로세스가 서로 다른 PID 1을 본다거나, 같은 포트 8080을 동시에 LISTEN할 수 있는 까닭이 여기에 있습니다.

현재 리눅스 커널이 제공하는 namespace는 여덟 종류입니다.

Namespace 격리 대상 도입 커널
mnt mount point 목록 2.4.19 (2002)
uts hostname, NIS domain 2.6.19 (2006)
ipc System V IPC, POSIX message queue 2.6.19 (2006)
pid PID 번호 공간 2.6.24 (2008)
net 네트워크 스택 전체 (인터페이스, 라우팅, iptables) 2.6.29 (2009)
user UID/GID 매핑 3.8 (2013)
cgroup cgroup 계층의 루트 4.6 (2016)
time CLOCK_MONOTONIC, CLOCK_BOOTTIME 5.6 (2020)

namespace를 만들고 들어가는 시스템 콜은 세 가지입니다.

  • clone(2): 새 프로세스를 만들면서 동시에 새 namespace를 생성합니다 (CLONE_NEW* 플래그).
  • unshare(2): 현재 프로세스를 기존 namespace에서 떼어내 새 namespace로 옮깁니다.
  • setns(2): 이미 존재하는 namespace에 합류합니다.

PID namespace — PID 1이 두 개일 수 있는 이유

PID namespace를 만들면 새로 만들어진 namespace 안에서 처음 실행되는 프로세스가 PID 1을 받습니다. 이 PID 1은 init 프로세스의 책임(자식의 SIGCHLD 처리, 시그널 기본 처리)을 떠안기 때문에, 컨테이너 안의 PID 1을 무엇으로 둘지가 운영에서 자주 문제가 됩니다. 일반 서버 프로세스를 그대로 PID 1로 두면 좀비 프로세스 회수가 안 되어 서서히 PID가 새는 일이 있어, tinidumb-init 같은 미니 init이 자주 PID 1로 쓰입니다.

PID namespace는 중첩됩니다. 컨테이너 안의 PID 1은 호스트에서 보면 그냥 평범한 PID(예: 24531)일 뿐입니다. 호스트는 자신의 모든 자손 namespace의 프로세스를 볼 수 있지만, 안쪽 namespace는 바깥을 볼 수 없습니다.

# 컨테이너 안
$ ps -ef
UID   PID  PPID  CMD
root    1     0  /sbin/tini -- node server.js
root    7     1  node server.js

# 호스트
$ ps -ef | grep node
root 24531 24510  node server.js

Mount namespace — /가 격리되는 메커니즘

mount namespace는 mount point 목록을 namespace 단위로 가집니다. 컨테이너 안에서 mount 명령을 쳤을 때 보이는 결과는 호스트의 /proc/mounts와 완전히 다른 집합입니다. 컨테이너 시작 시 runc는 pivot_root(2)로 루트 디렉토리를 이미지 레이어가 합쳐진 OverlayFS 마운트 포인트로 바꿉니다. 옛날의 chroot(2)와 비슷하지만, pivot_root는 옛 루트 디렉토리에 대한 모든 참조를 끊는다는 점에서 더 강합니다.

Network namespace — 컨테이너 네트워킹의 출발점

network namespace는 네트워크 인터페이스, 라우팅 테이블, iptables/nftables 규칙, 소켓 목록을 모두 격리합니다. 새로 만들어진 network namespace에는 loopback 인터페이스조차 없습니다. Docker가 컨테이너 두 개를 같은 사용자 정의 브리지 네트워크에 붙이면, 각 컨테이너는 자기만의 eth0(실은 veth pair의 한쪽)을 가지고, 다른 한쪽은 호스트의 docker0 브리지에 연결됩니다.

flowchart LR
  subgraph Host
    docker0[docker0 bridge]
    veth1[veth-host-1]
    veth2[veth-host-2]
  end
  subgraph netnsA[netns A]
    eth0A[eth0]
  end
  subgraph netnsB[netns B]
    eth0B[eth0]
  end
  docker0 --- veth1
  docker0 --- veth2
  veth1 -. veth pair .- eth0A
  veth2 -. veth pair .- eth0B

이 구조 덕분에 컨테이너끼리는 브리지를 통해 서로 통신하지만, 호스트의 다른 네트워크 인터페이스나 라우팅 정책에는 격리됩니다.

User namespace — UID 0이 root가 아닐 수 있는 이유

user namespace는 가장 늦게 들어왔고, 가장 강력한 격리를 제공합니다. 컨테이너 안의 UID 0(root)을 호스트의 UID 100000(평범한 일반 사용자)에 매핑할 수 있기 때문입니다. /proc/self/uid_map을 보면 매핑 규칙이 보입니다.

0    100000    65536

왼쪽부터 "컨테이너 안의 UID 0부터 시작해, 호스트 UID 100000부터 시작하는 65536개를 매핑한다"는 뜻입니다. 결과적으로 컨테이너 안에서는 root지만, 호스트에서는 일반 사용자이기 때문에 호스트 파일시스템을 함부로 건드릴 수 없습니다. Docker의 rootless 모드와 Podman이 활용하는 핵심 기능입니다.

namespace 진단

lsns(1), nsenter(1), /proc/<pid>/ns/ 디렉토리가 진단의 출발점입니다.

# 시스템 전체의 namespace
$ lsns
        NS TYPE   NPROCS   PID USER  COMMAND
4026531835 cgroup    198     1 root  /sbin/init
4026531837 user      198     1 root  /sbin/init
4026532567 mnt         1 24531 root  /sbin/tini -- ...
4026532568 uts         1 24531 root  ...
4026532569 ipc         1 24531 root  ...
4026532570 pid         1 24531 root  ...
4026532572 net         1 24531 root  ...

# 컨테이너의 network namespace에 들어가서 ip 확인
$ sudo nsenter -t 24531 -n ip addr

/proc/<pid>/ns/의 각 항목은 namespace를 참조하는 파일이고, 같은 inode 번호를 공유하면 같은 namespace입니다. 두 컨테이너가 같은 network namespace를 공유하는지 확인할 때 자주 씁니다.

2. Cgroups — "쓸 수 있는 양"의 격리

namespace가 보이는 범위를 나눈다면, cgroup(control group)은 쓸 수 있는 양을 나눕니다. CPU 시간, 메모리, 블록 I/O 대역폭, 만들 수 있는 프로세스 수까지 제어할 수 있습니다.

v1과 v2

cgroup은 v1과 v2 두 버전이 있고, 현대 리눅스 배포판(systemd 256 이상)은 v2(unified hierarchy)를 기본으로 사용합니다. v1은 controller마다 별도의 hierarchy를 가져 같은 프로세스를 여러 트리에 동시에 묶었지만, v2는 단일 hierarchy로 합쳐졌습니다.

flowchart TB
  subgraph v1
    cpu1[/sys/fs/cgroup/cpu/]
    mem1[/sys/fs/cgroup/memory/]
    io1[/sys/fs/cgroup/blkio/]
  end
  subgraph v2
    root[/sys/fs/cgroup/]
    root --> docker[/sys/fs/cgroup/system.slice/docker-XXX.scope/]
    docker --> cpu_max[cpu.max]
    docker --> memory_max[memory.max]
    docker --> io_max[io.max]
    docker --> pids_max[pids.max]
  end

v2의 핵심 파일들은 다음과 같습니다.

  • cgroup.procs: 이 cgroup에 속한 PID 목록. 여기에 PID를 echo하면 프로세스가 이동합니다.
  • cgroup.controllers: 이 cgroup에서 사용 가능한 controller 목록.
  • cgroup.subtree_control: 자식 cgroup에서 활성화할 controller. +cpu +memory -io처럼 prefix로 켜고 끕니다.
  • cpu.max: CPU 한도. <quota> <period> 형식. 예를 들어 50000 100000은 100ms 주기당 50ms 사용, 즉 0.5 CPU.
  • memory.max: 메모리 한도(바이트). 초과 시 OOM Kill.
  • memory.high: 소프트 한도. 초과 시 회수 압력을 받지만 즉시 죽지는 않습니다.
  • io.max: 블록 디바이스별 IOPS/대역폭 한도.
  • pids.max: fork 한도.

Docker가 cgroup을 쓰는 법

docker run --memory=512m --cpus=0.5 한 줄은 위 파일들에 값을 쓰는 작업으로 풀립니다. systemd 환경에서 cgroup 경로는 다음과 같이 생깁니다.

/sys/fs/cgroup/system.slice/docker-<container-id>.scope/

이 디렉토리의 memory.max536870912(512Mi)가, cpu.max50000 100000(0.5 CPU)이 들어 있습니다. 컨테이너 안의 모든 프로세스의 PID가 cgroup.procs에 등록되어 있고, 컨테이너가 죽으면 scope가 통째로 사라집니다.

$ docker run -d --name web --memory=512m --cpus=0.5 nginx
\( cat /sys/fs/cgroup/system.slice/docker-\)(docker inspect -f '{{.Id}}' web).scope/memory.max
536870912
\( cat /sys/fs/cgroup/system.slice/docker-\)(docker inspect -f '{{.Id}}' web).scope/cpu.max
50000 100000

OOM Kill의 진실

memory.max를 넘긴 컨테이너는 OOM Killer가 처리합니다. 호스트 전체의 OOM이 아니라, 그 cgroup 안에서 가장 점수가 높은 프로세스를 죽이는 cgroup OOM입니다. 컨테이너 안의 다른 프로세스는 살아 있을 수 있고, 메인 프로세스가 죽으면 컨테이너가 종료됩니다.

JVM 컨테이너에서 OOM Kill이 자주 발생하는 까닭은 단순합니다. JVM은 -Xmx로 힙 상한만 잡지만, 실제 RSS는 힙 + Metaspace + Direct Buffer + 스레드 스택 + 코드 캐시 + Native 라이브러리 합입니다. memory.max가 힙만 보고 잡히면 부족합니다. JDK 11+는 -XX:+UseContainerSupport(기본 활성)로 cgroup의 메모리 한도를 읽어 MaxRAMPercentage 기반으로 힙을 잡지만, 이 비율의 기본값은 25%로 보수적입니다. 운영에서는 MaxRAMPercentage=75.0 정도로 명시하거나, -Xmx를 직접 잡으면서 비힙 사용량을 별도로 측정하는 편이 안정적입니다.

3. OverlayFS — 이미지가 디스크 위에 사는 모양

컨테이너 이미지는 압축된 tarball의 목록입니다. 하나하나가 "레이어"이고, 각 레이어는 이전 레이어 위에 더해지는 파일 변경 집합(추가/수정/삭제)을 담습니다. 같은 베이스 이미지를 쓰는 컨테이너 백 개가 베이스 레이어를 공유하기 때문에, 컨테이너 한 개를 더 띄울 때 추가되는 디스크 비용은 변경분만큼만입니다.

이 레이어들을 런타임에 합쳐 하나의 파일시스템처럼 보이게 만드는 부품이 OverlayFS입니다. 리눅스 커널 3.18(2014)에서 들어왔고, Docker의 기본 storage driver(overlay2)가 이걸 씁니다.

네 가지 디렉토리

OverlayFS는 네 종류의 디렉토리를 다룹니다.

  • lowerdir: 읽기 전용 레이어. 여러 개 쌓을 수 있고, OverlayFS는 이를 :로 구분합니다. overlay2는 보통 약 128개 내외의 lowerdir를 지원합니다 — 고정 상수가 아니라 mount 시스템 콜 인자의 페이지 크기 한계(보통 4KB)에 의존하기 때문에, 경로 길이가 짧을수록 더 많이 쌓을 수 있고 길수록 줄어듭니다(Docker 공식 문서도 "약 128개" 표현을 씁니다).
  • upperdir: 읽기/쓰기 레이어. 변경분이 여기에 기록됩니다.
  • workdir: OverlayFS 내부 작업용 디렉토리. 같은 파일시스템 위에 있어야 하고, 직접 건드리면 안 됩니다.
  • merged: 위 세 가지가 합쳐져 보이는 통합 뷰.
mount -t overlay overlay \
  -o lowerdir=L4:L3:L2:L1,upperdir=U,workdir=W \
  /merged

lowerdir의 순서는 왼쪽이 위, 오른쪽이 아래입니다. 같은 경로가 여러 lower에 있으면 가장 왼쪽(가장 위) 것이 보입니다.

Copy-up — 쓰기를 가로채는 방식

읽기는 단순합니다. 위에서 아래로 lowerdir를 훑어 첫 번째로 찾은 파일을 반환합니다. 문제는 쓰기입니다. lowerdir는 읽기 전용이기 때문에 컨테이너가 lower의 파일을 수정하려 들면 OverlayFS는 copy_up 동작을 수행합니다.

flowchart TB
  Read[read /etc/nginx.conf] --> Search[search top-down: U, L4, L3, L2, L1]
  Search --> Found[found in L2]
  Found --> Return[return L2 contents]

  Write[write /etc/nginx.conf] --> CopyUp[copy_up: copy from L2 to U]
  CopyUp --> Modify[modify U/etc/nginx.conf]

이 첫 쓰기에서만 비용이 발생하고, 이후 쓰기는 그냥 upperdir에서 직접 처리됩니다. 큰 파일에 작은 수정만 가하는 경우라도 일단 전체 파일이 upperdir로 복사된다는 점이 함정입니다. 데이터베이스 컨테이너 같이 큰 파일을 자주 쓰는 워크로드는 OverlayFS 위에서 돌리기보다 볼륨 마운트로 호스트 파일시스템에 직접 쓰는 편이 안정적입니다.

삭제는 더 흥미롭습니다. lower에만 존재하는 파일을 컨테이너가 지우면, OverlayFS는 upperdir에 whiteout이라는 특수 파일을 만듭니다. character device(major 0, minor 0)로 표현된 이 마커가, 이후 조회 시 해당 경로를 "없는 것"으로 보이게 만듭니다.

/var/lib/docker/overlay2/

Docker는 다운로드한 각 레이어를 /var/lib/docker/overlay2/<layer-id>/ 디렉토리에 풉니다. 각 디렉토리에 들어가는 파일은 다음과 같습니다.

  • diff/: 레이어의 실제 내용(파일 트리).
  • link: 짧은 ID(28자 base32)가 한 줄 들어 있는 텍스트. 이걸 통해 l/ 심볼릭 링크와 연결됩니다.
  • lower: (최하위 레이어가 아니면 존재) 부모 레이어 체인을 :로 연결한 문자열. 단, 절대경로가 아니라 l/XXXX... 형식의 짧은 경로로 들어갑니다.
  • work/: 이 레이어가 컨테이너 마운트의 upperdir로 쓰일 때 OverlayFS 작업 디렉토리로 사용됩니다.
  • merged/: 컨테이너가 실행 중일 때만 생기는 마운트 포인트.

l/ 디렉토리에는 짧은 ID → 실제 레이어 디렉토리로 향하는 심볼릭 링크가 잔뜩 들어 있습니다. 왜 이런 우회를 하느냐면, mount 시스템 콜 인자의 페이지 크기 한계(보통 4KB) 때문입니다. 레이어가 수십 개 쌓이면 절대경로의 lowerdir=... 인자가 4KB를 넘어 마운트가 실패합니다. 짧은 28자 ID로 줄여서 그 한계를 피하는 트릭입니다.

$ ls /var/lib/docker/overlay2/
0a1b2c3d.../   4e5f6g7h.../   l/   ...

$ ls /var/lib/docker/overlay2/0a1b2c3d.../
diff  link  lower  work

$ cat /var/lib/docker/overlay2/0a1b2c3d.../link
ABCDEFGH12345678ABCDEFGH1234

$ cat /var/lib/docker/overlay2/0a1b2c3d.../lower
l/IJKL...:l/MNOP...:l/QRST...

$ ls /var/lib/docker/overlay2/l/
ABCDEFGH12345678ABCDEFGH1234 -> ../0a1b2c3d.../diff

페이지 캐시도 OverlayFS의 이점입니다. 같은 lowerdir에서 같은 파일을 읽는 컨테이너 여러 개는 페이지 캐시 항목을 공유합니다. 베이스 이미지가 같은 컨테이너 백 개가 메모리 압박을 의외로 덜 받는 까닭이 이것입니다.

4. OCI 스펙과 runc — 표준화된 두 개의 약속

여기까지가 리눅스 커널 기능입니다. 이 부품들을 어떻게 조립해 컨테이너를 띄울지를 표준화한 것이 OCI(Open Container Initiative)입니다. OCI는 세 개의 스펙을 관리합니다.

  • Image Spec: 이미지가 어떻게 생긴 tarball 모음인지.
  • Runtime Spec: 풀어 놓은 디렉토리 + JSON 설정을 받아 컨테이너를 어떻게 띄울지.
  • Distribution Spec: 레지스트리가 이미지를 어떻게 주고받을지.

OCI Runtime Spec은 2025년 11월에 v1.3.0이 릴리스되었습니다.

Image Spec — 이미지의 구조

OCI 이미지 한 개는 다음 세 종류의 객체로 구성됩니다.

  • manifest: 이 이미지를 구성하는 config와 layer의 목록을 담은 JSON.
  • config: 환경변수, 엔트리포인트, 작성 일자, 그리고 레이어들의 압축 해제 후 해시(DiffID)를 담은 JSON.
  • layer: tar 아카이브(또는 gzip 압축된 tar). 이전 레이어 위에 덧붙일 파일 변경분.

모든 객체는 sha256으로 해싱되어 content-addressable로 참조됩니다. 같은 내용은 같은 digest를 가지므로 중복 저장을 피할 수 있고, 변조 검출도 자동입니다.

flowchart LR
  Manifest[manifest.json] --> Config[config.json digest]
  Manifest --> L1[layer 1.tar.gz digest]
  Manifest --> L2[layer 2.tar.gz digest]
  Manifest --> L3[layer 3.tar.gz digest]
  Config --> Env[env, cmd, entrypoint]
  Config --> DiffIDs[diff_ids: uncompressed hashes]

여기서 digest와 DiffID의 차이를 짚어야 합니다. manifest에 적힌 각 레이어의 digest는 압축된 tar.gz의 해시고, config 안의 DiffID는 압축 해제된 tar의 해시입니다. 압축 방식이 바뀌어도 DiffID는 그대로이기 때문에, DiffID 체인이 같은 이미지는 같은 root filesystem을 만든다는 보장이 됩니다.

Runtime Spec — config.json과 bundle

OCI Runtime Spec은 "bundle"이라는 형태로 컨테이너를 정의합니다. bundle은 디렉토리 하나로, 안에 두 가지가 있어야 합니다.

  • config.json: OCI Runtime Spec이 정의하는 모든 필드(namespace, cgroup, mounts, env, args, capabilities, seccomp 등)를 담은 JSON.
  • rootfs/: 컨테이너의 루트 파일시스템으로 쓰일 디렉토리. 이것이 OverlayFS의 merged가 됩니다.

런타임은 이 bundle을 받아 컨테이너 프로세스를 띄웁니다.

runc — 표준 구현

runc는 OCI Runtime Spec의 참조 구현입니다. Docker가 Linux Foundation에 기증해 OCI의 출발점이 된 프로젝트로, 지금도 Docker, containerd, CRI-O, Podman의 기본 런타임입니다.

runc가 하는 일을 줄여 적으면 이렇습니다.

  1. config.json을 읽는다.
  2. clone(2)을 호출해 새 프로세스를 만든다. 이때 CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | ... 플래그로 namespace들을 분리한다.
  3. cgroup을 만들고 새 PID를 cgroup.procs에 쓴다.
  4. pivot_root로 루트를 rootfs/로 옮긴다.
  5. capabilities, seccomp 필터, AppArmor/SELinux 라벨을 적용한다.
  6. execve(2)config.jsonprocess.args 명령을 실행한다.

이 모든 단계 후에 컨테이너 안의 PID 1로 실제 사용자 프로세스가 떠 있게 됩니다. runc 자신은 사라집니다(또는 runc init 보조 프로세스만 남아 있다가 빠집니다).

# bundle 만들기
$ mkdir -p mybundle/rootfs
\( docker export \)(docker create alpine) | tar -C mybundle/rootfs -xf -
$ cd mybundle && runc spec    # 기본 config.json 생성
$ runc run mycontainer        # 띄우기

Docker, containerd, runc의 분리

오늘날 Docker 엔진은 사실상 여러 컴포넌트의 모음입니다.

flowchart LR
  CLI[docker CLI] -->|REST API| Daemon[dockerd]
  Daemon -->|gRPC| Containerd[containerd]
  Containerd -->|fork exec| Shim[containerd-shim-runc-v2]
  Shim -->|fork exec| Runc[runc]
  Runc -->|clone setns pivot_root execve| Process[container PID 1]

각자의 책임은 분명합니다.

  • docker CLI: 사용자 명령을 REST API 호출로 변환.
  • dockerd: 이미지 빌드, 네트워크 관리, 볼륨 관리. 사용자 친화적인 상위 추상화.
  • containerd: 이미지 pull/push, 이미지 저장소 관리, 컨테이너 라이프사이클. CNCF 졸업 프로젝트.
  • containerd-shim-runc-v2: 컨테이너 하나당 하나씩 떠 있는 작은 프로세스. containerd가 재시작되어도 컨테이너는 살아 있게 만드는 장치.
  • runc: 한 번 호출되어 컨테이너를 띄운 뒤 사라지는 단발성 도구.

이 구조 덕분에 같은 컨테이너 런타임 위에 Docker, Kubernetes(CRI를 거쳐 containerd), Podman 등 여러 상위 도구가 공존할 수 있습니다. CRI(Container Runtime Interface)도 결국은 containerd에 말을 거는 gRPC 인터페이스이고, 그 아래는 runc가 똑같이 namespace + cgroup + OverlayFS를 쓰는 그림입니다.

운영에서 자주 부딪히는 함정 다섯 가지

1. JVM 컨테이너의 메모리 한도

cgroup의 memory.max는 JVM 힙이 아니라 RSS 전체에 적용됩니다. JDK 11+ -XX:+UseContainerSupport(기본 켜짐)가 cgroup 한도를 읽지만, MaxRAMPercentage의 기본값은 25%로 보수적입니다. 컨테이너의 메모리가 1Gi이면 힙은 256Mi뿐이고, 나머지는 Metaspace, Direct Buffer, 코드 캐시, 네이티브 메모리, 스레드 스택의 몫입니다. 운영에서는 -XX:MaxRAMPercentage=75.0처럼 명시하고, 비힙 사용량을 별도로 측정합니다.

2. PID 1로 평범한 서버 프로세스를 두기

docker run myapp 같이 애플리케이션을 PID 1로 띄우면 자식 프로세스의 좀비 회수와 시그널 기본 처리가 정상적으로 동작하지 않습니다. --init 플래그(Docker가 자동으로 tini를 PID 1로 끼워 줌)나 명시적인 tini, dumb-init을 ENTRYPOINT로 두는 편이 안전합니다.

3. OverlayFS 위에서 큰 파일을 자주 수정

데이터베이스 데이터 파일, 큰 로그 파일을 컨테이너 레이어 위에 두면 첫 쓰기마다 전체 파일이 upperdir로 copy_up됩니다. 데이터 디렉토리는 volume(-v 또는 named volume)으로 빼서 호스트 파일시스템에 직접 쓰도록 해야 I/O와 디스크 사용량 모두 안정적입니다.

4. --privileged의 과한 권한

--privileged는 capabilities 전부 부여, cgroup 격리 해제, 디바이스 노드 전부 접근, AppArmor/SELinux 비활성화를 한꺼번에 켭니다. 사실상 호스트 권한입니다. 특정 capability(--cap-add SYS_ADMIN 등)만 추가하거나, 특정 디바이스만 --device 옵션으로 노출하는 것이 안전합니다. Docker-in-Docker 같은 흔한 동기는 대부분 --cap-add 조합으로 풀 수 있습니다.

5. 호스트의 PID/network namespace 공유

--pid=host는 컨테이너가 호스트의 모든 프로세스를 보고 시그널을 보낼 수 있게 만들고, --network=host는 호스트의 네트워크 인터페이스를 그대로 씁니다. 둘 다 격리의 핵심 부분을 풀어버리는 옵션이고, 디버깅 도구(예: nicolaka/netshoot)나 모니터링 에이전트에만 한정해서 써야 합니다.

참고자료

Linux Kernel

Docker

OCI

Containerd

JVM in Containers

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ