Linux Containers & Docker 커널 내부
Linux 컨테이너(Container) 기술 스택을 커널 인터페이스와 런타임 계층 연결 관점에서 종합적으로 심층 분석합니다. OCI 규격과 runc/containerd/Podman 실행 경로, Docker 데몬 아키텍처(dockerd→containerd→shim→runc)와 libcontainer 내부 구조, namespace+cgroup 조합을 통한 격리(Isolation)·자원 제어, seccomp/capabilities/LSM 보안 경계, OverlayFS 기반 이미지·레이어 관리, Docker 이미지 레이어와 BuildKit, CNI 네트워킹과 veth/bridge/iptables 경로, rootless 컨테이너의 user namespace 제약, CRIU 체크포인트(Checkpoint)/복원, Kata Containers 같은 경량 VM 모델 비교, 관측·디버깅(Debugging)·장애 복구까지 플랫폼 운영에 필요한 핵심을 다룹니다.
핵심 요약
- 격리 모델 — 관측 범위와 자원 한계를 분리해 이해합니다.
- 자원 회계 — cgroups 제한과 스케줄링 효과를 함께 봅니다.
- 가상화(Virtualization) 경계 — 호스트/게스트 전환 비용을 파악합니다.
- 시간/상태 일관성 — 체크포인트/복원 시 기준값을 점검합니다.
- 운영 정책 — 격리 강도와 성능 비용의 균형을 맞춥니다.
단계별 이해
- 경계 정의
무엇을 공유하고 무엇을 분리할지 먼저 정합니다. - 제한 적용
CPU/메모리/IO 제한을 단계적으로 설정합니다. - 관측 검증
네임스페이스/가상화 경계에서 보이는 값을 확인합니다. - 장애 복구 점검
마이그레이션/재시작(Reboot) 시 일관성을 검증합니다.
컨테이너 개요
컨테이너는 운영체제 수준 가상화(OS-level virtualization) 기술로, 하나의 커널 위에서 프로세스를 격리된 환경에서 실행합니다. VM이 하이퍼바이저(Hypervisor) 위에 게스트 커널을 통째로 올리는 것과 달리, 컨테이너는 호스트 커널을 공유하면서 네임스페이스와 cgroups로 격리와 리소스 제한을 구현합니다.
핵심 커널 프리미티브
컨테이너 기술은 다음 4가지 커널 프리미티브의 조합으로 구현됩니다:
| 프리미티브 | 역할 | 예시 |
|---|---|---|
| Namespaces | 리소스 가시성 격리 | PID, NET, MNT, USER, UTS, IPC, cgroup, time |
| cgroups | 리소스 사용량 제한 | CPU, memory, I/O, PID 수 제한 |
| seccomp-bpf | 시스템 콜(System Call) 필터링 | 허용/차단 syscall 목록 적용 |
| Capabilities | 권한 세분화 | CAP_NET_BIND_SERVICE, CAP_SYS_ADMIN 등 |
VM vs Container 아키텍처
컨테이너 기술의 역사
| 연도 | 기술 | 의미 |
|---|---|---|
| 1979 | chroot | 파일시스템(Filesystem) 루트 격리의 시작 |
| 2001 | Linux namespaces | mount namespace — CLONE_NEWNS (Linux 2.4.11) |
| 2006 | cgroups (Google) | 프로세스 그룹 리소스 제한 |
| 2008 | LXC | 최초의 완전한 Linux 컨테이너 구현 |
| 2013 | Docker | 이미지 레이어 + 사용자 경험 혁신 |
| 2015 | OCI 설립 | 런타임/이미지 스펙 표준화 |
| 2016 | runc 1.0 | OCI 참조 런타임 구현 |
| 2017 | containerd 1.0 | 산업 표준 고수준 런타임 |
| 2019 | cgroup v2 | 통합 계층 구조, PSI 지원 |
| 2021 | Podman 3.0+ | 데몬리스, rootless 네이티브 |
커널 프리미티브 조합
컨테이너 런타임은 단일 프리미티브가 아닌, 여러 커널 기능을 특정 순서로 조합하여 격리 환경을 구성합니다. 아래 다이어그램은 컨테이너 생성 시 커널 프리미티브가 적용되는 흐름을 보여줍니다.
seccomp-bpf: 시스템 콜 필터링
seccomp(Secure Computing Mode)은 프로세스가 호출할 수 있는 시스템 콜을 제한하는 커널 기능입니다. 컨테이너 런타임은 seccomp-bpf 필터를 사용하여 위험한 시스템 콜을 차단합니다.
/* 커널의 seccomp 필터 데이터 구조 (include/uapi/linux/seccomp.h) */
struct seccomp_data {
int nr; /* 시스템 콜 번호 */
__u32 arch; /* AUDIT_ARCH_* 값 */
__u64 instruction_pointer; /* 호출 위치 */
__u64 args[6]; /* 시스템 콜 인자 */
};
/* seccomp 필터 설치 */
struct sock_fprog prog = {
.len = ARRAY_SIZE(filter),
.filter = filter, /* BPF 필터 배열 */
};
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
/* 또는 seccomp() 시스템 콜 사용 (Linux 3.17+) */
seccomp(SECCOMP_SET_MODE_FILTER, 0, &prog);
kexec_load, mount, reboot, init_module, ptrace, keyctl 등 호스트에 영향을 줄 수 있는 시스템 콜이 차단 대상입니다. --security-opt seccomp=unconfined로 비활성화 가능하나 보안상 권장하지 않습니다. seccomp 커널 내부 구현과 필터 체인 메커니즘에 대한 상세 내용은 LSM 프레임워크 — Seccomp 문서를 참고하세요.Linux Capabilities
전통적 Unix는 root(UID 0)와 non-root만 구분했습니다. Linux capabilities는 root 권한을 약 40개 이상의 세분화된 권한으로 분리하여, 컨테이너에 필요한 최소 권한만 부여할 수 있게 합니다.
/* 컨테이너 런타임의 기본 capability 목록 (Docker/runc 기준) */
/* 유지되는 capabilities */
CAP_CHOWN /* 파일 소유자 변경 */
CAP_DAC_OVERRIDE /* 파일 접근 권한 우회 */
CAP_FSETID /* setuid/setgid 비트 보존 */
CAP_FOWNER /* 파일 소유자 검사 우회 */
CAP_MKNOD /* 특수 파일 생성 */
CAP_NET_RAW /* RAW/PACKET 소켓 */
CAP_SETGID /* GID 조작 */
CAP_SETUID /* UID 조작 */
CAP_SETFCAP /* 파일 capability 설정 */
CAP_SETPCAP /* 프로세스 capability 전달 */
CAP_NET_BIND_SERVICE /* 1024 미만 포트 바인딩 */
CAP_SYS_CHROOT /* chroot() 호출 */
CAP_KILL /* 프로세스에 시그널 전송 */
CAP_AUDIT_WRITE /* 감사 로그 작성 */
/* 주요 DROP 대상 capabilities */
CAP_SYS_ADMIN /* mount, namespace 생성 등 — 가장 위험 */
CAP_SYS_PTRACE /* ptrace, /proc 접근 */
CAP_SYS_MODULE /* 커널 모듈 로드/언로드 */
CAP_SYS_RAWIO /* iopl, ioperm */
CAP_SYS_BOOT /* reboot() */
CAP_NET_ADMIN /* 네트워크 구성 변경 */
# capability 확인 도구
$ capsh --print # 현재 프로세스의 capabilities 출력
$ getpcaps <PID> # 특정 프로세스의 capabilities 확인
$ cat /proc/<PID>/status | grep Cap # 커널이 보고하는 capability 비트마스크
# 출력 예시 (Docker 컨테이너 내부)
CapInh: 0000000000000000
CapPrm: 00000000a80425fb # 허용된 capability 비트마스크
CapEff: 00000000a80425fb # 유효 capability 비트마스크
CapBnd: 00000000a80425fb # bounding set
CapAmb: 0000000000000000 # ambient capabilities
clone()/unshare()/setns() 시스템콜
Docker가 컨테이너를 생성할 때 핵심적으로 사용하는 3개의 namespace 관련 syscall입니다.
clone() — 새 프로세스 + 새 namespace 동시 생성
/* kernel/fork.c */
long do_fork(unsigned long clone_flags, ...)
{
return copy_process(clone_flags, ...);
}
static struct task_struct *copy_process(unsigned long clone_flags, ...)
{
p = dup_task_struct(current, node); /* 태스크 구조체 복사 */
copy_namespaces(clone_flags, p); /* nsproxy.c: 네임스페이스 처리 */
copy_mm(clone_flags, p);
copy_files(clone_flags, p);
copy_sighand(clone_flags, p);
return p;
}
/* kernel/nsproxy.c */
int copy_namespaces(unsigned long flags, struct task_struct *tsk)
{
struct nsproxy *old_ns = tsk->nsproxy;
struct nsproxy *new_ns;
if (!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWUSER |
CLONE_NEWCGROUP | CLONE_NEWTIME)))
return 0; /* 플래그 없으면 기존 nsproxy 공유 */
new_ns = create_new_namespaces(flags, tsk, ...);
tsk->nsproxy = new_ns;
return 0;
}
| clone() 플래그 | 생성되는 Namespace | 격리 대상 |
|---|---|---|
CLONE_NEWPID | PID namespace | 프로세스 ID — 컨테이너 내 init는 PID 1 |
CLONE_NEWNET | Network namespace | 네트워크 인터페이스, 라우팅 테이블(Routing Table), iptables |
CLONE_NEWNS | Mount namespace | 마운트(Mount) 포인트, pivot_root로 rootfs 교체 |
CLONE_NEWUTS | UTS namespace | hostname, domainname |
CLONE_NEWIPC | IPC namespace | System V IPC, POSIX 메시지 큐 |
CLONE_NEWUSER | User namespace | UID/GID 매핑 (Rootless Docker 핵심) |
CLONE_NEWCGROUP | cgroup namespace | cgroup 루트 뷰 |
unshare() — 기존 프로세스에서 새 namespace로 분리
/* kernel/fork.c */
int ksys_unshare(unsigned long unshare_flags)
{
/* 새 namespace 객체 생성 */
unshare_nsproxy_namespaces(unshare_flags, &new_nsproxy, ...);
/* 현재 태스크에 새 nsproxy 적용 */
task_lock(current);
current->nsproxy = new_nsproxy;
task_unlock(current);
return 0;
}
# unshare 사용 예: 루트 없이 user+net+pid namespace 생성
$ unshare --user --net --pid --mount-proc --fork bash
$ id
uid=0(root) gid=0(root) groups=0(root) # 컨테이너 내에서 root처럼 보임
$ ip link show
1: lo: <LOOPBACK> mtu 65536 ... # 격리된 네트워크 — loopback만 존재
struct nsproxy — 6개 네임스페이스 포인터
/* kernel/nsproxy.c — 태스크가 가진 모든 네임스페이스 참조 */
struct nsproxy {
atomic_t count; /* 참조 카운트 */
struct uts_namespace *uts_ns; /* hostname, domainname */
struct ipc_namespace *ipc_ns; /* SysV IPC, POSIX 메시지큐 */
struct mnt_namespace *mnt_ns; /* 마운트 트리 */
struct pid_namespace *pid_ns_for_children; /* 자식 PID NS */
struct net *net_ns; /* 네트워크 스택 */
struct time_namespace *time_ns; /* 클럭 오프셋 (Linux 5.6+) */
struct time_namespace *time_ns_for_children;
struct cgroup_namespace *cgroup_ns; /* cgroup 루트 뷰 */
};
/* init_nsproxy: 초기 프로세스(PID 1)가 공유하는 기본 nsproxy */
struct nsproxy init_nsproxy = {
.count = ATOMIC_INIT(1),
.uts_ns = &init_uts_ns,
.ipc_ns = &init_ipc_ns,
.mnt_ns = &init_mnt_ns,
.pid_ns_for_children = &init_pid_ns,
.net_ns = &init_net,
.time_ns = &init_time_ns,
.cgroup_ns = &init_cgroup_ns,
};
clone3() — 확장 플래그 + cgroup 직접 설정 (Linux 5.3+)
/* clone3(): clone()의 확장 버전 — 구조체로 플래그 전달 */
struct clone_args {
uint64_t flags; /* CLONE_* 플래그 */
uint64_t pidfd; /* pidfd 반환용 포인터 */
uint64_t child_tid; /* CLONE_CHILD_SETTID */
uint64_t parent_tid; /* CLONE_PARENT_SETTID */
uint64_t exit_signal; /* 부모에게 보낼 시그널 */
uint64_t stack;
uint64_t stack_size;
uint64_t tls;
uint64_t set_tid; /* 특정 PID 지정 */
uint64_t set_tid_size;
uint64_t cgroup; /* CLONE_INTO_CGROUP: 특정 cgroup에서 시작 (Linux 5.7) */
};
/* CLONE_INTO_CGROUP: 생성 즉시 지정 cgroup에 진입 (cgroup_fd 필요) */
int cgroup_fd = open("/sys/fs/cgroup/docker/mycontainer", O_RDONLY);
struct clone_args args = {
.flags = CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | CLONE_INTO_CGROUP,
.cgroup = cgroup_fd,
};
pid_t child = (pid_t)syscall(__NR_clone3, &args, sizeof(args));
/* 자식은 clone() 즉시 지정 cgroup 소속 — 기존 clone() + write(cgroup.procs) 2단계를 1단계로 */
setns() — 기존 namespace에 합류
/* kernel/nsproxy.c */
int ksys_setns(int fd, int nstype)
{
struct ns_common *ns;
struct file *file = fget(fd);
ns = get_proc_ns(file_inode(file)); /* /proc/PID/ns/net 등 파일에서 추출 */
ns->ops->install(nstype_info, ns); /* 해당 namespace에 현재 태스크 합류 */
return 0;
}
/* nsenter 유사 구현 예 */
int main(void) {
int fd = open("/proc/12345/ns/net", O_RDONLY);
setns(fd, CLONE_NEWNET); /* 컨테이너 net namespace에 진입 */
close(fd);
execv("/bin/ip", args); /* 컨테이너 네트워크에서 실행 */
}
namespace 파일 디스크립터(File Descriptor)와 pidfd
/* Linux 5.3+: pidfd — PID 재사용 문제 없는 프로세스 참조 */
/* clone3()로 pidfd 동시 획득 */
int pidfd = -1;
struct clone_args args = {
.flags = CLONE_PIDFD | CLONE_NEWPID | CLONE_NEWNET,
.pidfd = (uint64_t)&pidfd,
};
pid_t child = (pid_t)syscall(__NR_clone3, &args, sizeof(args));
/* pidfd_send_signal(): PID 재사용 걱정 없이 안전하게 시그널 전송 */
syscall(__NR_pidfd_send_signal, pidfd, SIGTERM, NULL, 0);
/* pidfd_getfd(): 다른 프로세스의 fd를 현재 프로세스로 복사 */
/* docker exec 구현에서 컨테이너 namespace fd를 안전하게 가져올 때 활용 */
int ns_fd = syscall(__NR_pidfd_getfd, pidfd, ns_fd_in_container, 0);
/* /proc/PID/ns/ 파일 기반 namespace 조작 (전통적 방법) */
struct {
const char *name;
int flag;
} namespaces[] = {
{ "mnt", CLONE_NEWNS }, /* /proc/PID/ns/mnt */
{ "uts", CLONE_NEWUTS }, /* /proc/PID/ns/uts */
{ "ipc", CLONE_NEWIPC }, /* /proc/PID/ns/ipc */
{ "net", CLONE_NEWNET }, /* /proc/PID/ns/net */
{ "pid", CLONE_NEWPID }, /* /proc/PID/ns/pid */
{ "user", CLONE_NEWUSER }, /* /proc/PID/ns/user */
{ "cgroup", CLONE_NEWCGROUP }, /* /proc/PID/ns/cgroup */
{ "time", CLONE_NEWTIME }, /* /proc/PID/ns/time (Linux 5.6+) */
};
# namespace inode 번호로 컨테이너 식별
$ ls -lai /proc/$(docker inspect -f '{{.State.Pid}}' mycontainer)/ns/
4026532xxx net # net NS inode 번호 — 같은 번호면 같은 NS
4026532yyy pid
4026532zzz mnt
# 두 컨테이너가 같은 network NS를 공유하는지 확인
$ stat -L /proc/$PID1/ns/net /proc/$PID2/ns/net | grep Inode
# Inode가 같으면 --network=container:<id> 로 NS 공유 중
Docker cgroup v2 리소스 제한 실전
docker run 플래그와 cgroup v2 파일 간의 완전한 매핑을 정리합니다. containerd는 systemd cgroup 드라이버를 사용하여 컨테이너를 /sys/fs/cgroup/system.slice/docker-<id>.scope에 배치합니다.
cgroup v2 계층 구조
# Docker의 cgroup v2 계층 구조 확인
$ systemctl status docker
$ systemd-cgls | grep -A5 docker
/sys/fs/cgroup/
└── system.slice/
├── docker.service/ # dockerd 프로세스
└── docker-<container-id>.scope/ # 각 컨테이너
├── cpu.max # CPU 쿼터
├── memory.max # 메모리 한도
├── pids.max # 프로세스 수 제한
└── io.max # I/O 대역폭 제한
# cgroup v2 드라이버 확인 (containerd 설정)
$ cat /etc/containerd/config.toml | grep systemd_cgroup
SystemdCgroup = true # cgroup v2 + systemd 드라이버 필수
# cgroup namespace로 컨테이너가 자신의 cgroup 루트만 보이는지 확인
$ docker exec mycontainer cat /proc/self/cgroup
0::/ # 컨테이너 내에서는 자신의 cgroup이 루트로 보임
| docker run 플래그 | cgroup v2 파일 | 값 형식 | 예시 |
|---|---|---|---|
--memory=1g | memory.max | bytes 또는 "max" | 1073741824 |
--memory-swap=2g | memory.swap.max | bytes | 1073741824 (swap only) |
--memory-reservation=512m | memory.low | bytes | 536870912 |
--cpus=0.5 | cpu.max | "quota period" | 50000 100000 |
--cpu-shares=512 | cpu.weight | 1-10000 (기본 100) | 50 (512/1024*100) |
--cpuset-cpus=0,1 | cpuset.cpus | CPU 목록 | 0-1 |
--cpuset-mems=0 | cpuset.mems | NUMA 노드 | 0 |
--pids-limit=100 | pids.max | 정수 또는 "max" | 100 |
--blkio-weight=300 | io.weight | 1-10000 | 300 |
--device-read-bps | io.max | "major:minor rbps=N" | 8:0 rbps=10485760 |
# docker run 실행 후 cgroup v2 확인
$ docker run -d --name=myapp --memory=512m --cpus=0.5 --pids-limit=50 nginx
# 컨테이너의 cgroup 경로 찾기
$ CGPATH=$(cat /proc/$(docker inspect -f '{{.State.Pid}}' myapp)/cgroup | grep "^0::" | cut -d: -f3)
$ echo "/sys/fs/cgroup${CGPATH}"
/sys/fs/cgroup/system.slice/docker-abc123.scope
# 리소스 제한 확인
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.max
536870912
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.max
50000 100000
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/pids.max
50
# PSI (Pressure Stall Information) 메모리 압박 모니터링
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.pressure
some avg10=0.00 avg60=0.00 avg300=0.00 total=0
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
memory.events — OOM 이벤트 모니터링
# memory.events: OOM 및 throttling 이벤트 카운터
$ cat $CGPATH/memory.events
low 0 # memory.low 아래로 떨어진 횟수
high 15 # memory.high 초과 횟수 (throttling 발생)
max 3 # memory.max 도달 횟수 (할당 실패 발생)
oom 1 # OOM killer 호출 횟수
oom_kill 1 # OOM으로 프로세스 킬 횟수
oom_group_kill 0 # 그룹 전체 OOM kill 횟수
# PSI (Pressure Stall Information) 임계값 기반 이벤트 감시
# 형식: "some/full <stall_us> <window_us>"
$ echo "some 5000000 1000000" > $CGPATH/memory.pressure # 1초 중 5ms 이상 stall 시 이벤트
# inotify로 memory.events 변경 감지 (Docker daemon이 사용하는 방식)
$ inotifywait -e modify $CGPATH/memory.events &
$ docker run --memory=64m stress --vm 1 --vm-bytes 100m &
# → memory.events 변경 → OOM kill 발생
cpu.stat / io.stat — 상세 사용량 분석
# cpu.stat: CPU 사용량 전체 필드
$ cat $CGPATH/cpu.stat
usage_usec 5432100 # 총 CPU 사용 시간 (μs)
user_usec 3210000 # 사용자 모드 시간
system_usec 2222100 # 커널 모드 시간
nr_periods 1000 # CFS 스케줄링 주기 수
nr_throttled 45 # 쿼터 초과로 throttle된 주기 수
throttled_usec 450000 # 총 throttle 시간 (μs)
nr_bursts 0 # burst credit 사용 횟수 (cpu.max.burst)
burst_usec 0
# io.stat: 블록 I/O 통계
$ cat $CGPATH/io.stat
8:0 rbytes=1073741824 wbytes=524288000 rios=12345 wios=6789 dbytes=0 dios=0
# 8:0 = 디바이스 major:minor
# rbytes/wbytes: 읽기/쓰기 바이트, rios/wios: I/O 작업 수
# dbytes/dios: discard (TRIM) 바이트/작업 수
# 실시간 모니터링: docker stats와 cgroup 직접 비교
$ watch -n1 'cat '$CGPATH'/cpu.stat | grep usage_usec'
OCI (Open Container Initiative)
OCI는 컨테이너 포맷과 런타임의 개방 표준을 정의하는 Linux Foundation 프로젝트입니다. Docker가 2015년에 기증한 libcontainer를 기반으로, 모든 컨테이너 런타임이 따르는 표준 인터페이스를 제공합니다.
OCI Runtime Specification
런타임 스펙은 컨테이너의 설정, 실행 환경, 라이프사이클을 정의합니다. config.json이 핵심 설정 파일입니다.
// OCI config.json 핵심 구조 (간략화)
{
"ociVersion": "1.0.2",
"process": {
"terminal": true,
"user": { "uid": 0, "gid": 0 },
"args": ["/bin/sh"],
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"cwd": "/",
"capabilities": {
"bounding": ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"],
"effective": ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"]
},
"rlimits": [{ "type": "RLIMIT_NOFILE", "hard": 1024, "soft": 1024 }]
},
"root": { "path": "rootfs", "readonly": true },
"mounts": [
{ "destination": "/proc", "type": "proc", "source": "proc" },
{ "destination": "/dev", "type": "tmpfs", "source": "tmpfs" }
],
"linux": {
"namespaces": [
{ "type": "pid" }, { "type": "network" },
{ "type": "ipc" }, { "type": "uts" },
{ "type": "mount" }, { "type": "cgroup" }
],
"seccomp": { "defaultAction": "SCMP_ACT_ERRNO", "architectures": ["SCMP_ARCH_X86_64"] },
"resources": {
"memory": { "limit": 536870912 },
"cpu": { "shares": 1024, "quota": 100000, "period": 100000 }
}
}
}
OCI 컨테이너 라이프사이클
| 상태 | 설명 | 전이 |
|---|---|---|
| creating | 런타임이 config.json을 파싱하고 환경 준비 중 | create 명령 |
| created | 환경 준비 완료, 프로세스 미실행 | prestart 훅 실행됨 |
| running | 컨테이너 프로세스 실행 중 | start 명령 |
| stopped | 프로세스 종료됨, 리소스 미해제 | 프로세스 exit |
| (삭제됨) | 모든 리소스 해제 | delete 명령 |
OCI Image Specification
OCI 이미지는 content-addressable 스토리지 모델을 사용합니다. 각 레이어는 SHA256 다이제스트로 식별됩니다.
- Image Manifest — 이미지의 레이어 목록과 config를 가리키는 디스크립터
- Image Config — 실행 파라미터, 환경변수, 레이어 히스토리
- Filesystem Layers — tar+gzip으로 압축된 파일시스템 변경분 (diff)
- Image Index — 멀티 아키텍처(amd64, arm64) 이미지의 매니페스트 목록
OCI Runtime Spec ↔ 커널 인터페이스 매핑
OCI Runtime Spec의 config.json 각 필드가 어떤 커널 syscall로 변환되는지 1:1로 정리합니다.
| config.json 필드 | syscall / 커널 인터페이스 | 세부 내용 |
|---|---|---|
linux.namespaces[].type | clone(CLONE_NEW*) | pid/net/mnt/uts/ipc/user/cgroup namespace 생성 |
linux.resources.memory.limit | cgroup v2: memory.max | 단위: bytes. -1이면 unlimited |
linux.resources.memory.swap | cgroup v2: memory.swap.max | swap 포함 총 메모리 한도 |
linux.resources.cpu.quota | cgroup v2: cpu.max 첫 번째 값 | 기본 period=100000µs |
linux.resources.pids.limit | cgroup v2: pids.max | 컨테이너 내 최대 프로세스 수 |
linux.seccomp | prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER) | BPF 프로그램으로 syscall 필터링 |
linux.capabilities | capset(CAP_NET_ADMIN, ...) | 허용할 Linux capabilities 목록 |
process.user.uid | setuid() / setreuid() | 컨테이너 프로세스의 UID |
process.user.gid | setgid() / setregid() | 컨테이너 프로세스의 GID |
process.user.additionalGids | setgroups() | 보조 그룹 ID 목록 |
mounts[].source/target | mount(source, target, ...) | bind mount 또는 tmpfs/proc/sys 마운트 |
root.path | pivot_root(new_root, put_old) | rootfs 교체. chroot보다 안전 |
linux.maskedPaths | mount("", path, "tmpfs", MS_BIND|MS_RDONLY) | /proc/kcore 등 민감 경로 숨김 |
linux.readonlyPaths | mount(path, path, "", MS_BIND|MS_RDONLY|MS_REMOUNT) | /proc/sys 등 읽기 전용(Read-Only) 재마운트 |
linux.rlimits | prlimit64(RLIMIT_NOFILE, ...) | 프로세스별 리소스 제한 |
linux.sysctl | write("/proc/sys/...", value) | 네트워크 NS 스코프 sysctl만 허용 |
// OCI config.json 예시 (일부)
{
"ociVersion": "1.0.2",
"process": {
"user": { "uid": 1000, "gid": 1000 },
"capabilities": {
"bounding": ["CAP_NET_BIND_SERVICE", "CAP_KILL", "CAP_AUDIT_WRITE"]
}
},
"linux": {
"namespaces": [
{ "type": "pid" }, // → clone(CLONE_NEWPID)
{ "type": "network", "path": "/var/run/netns/existing" } // → setns()
],
"resources": {
"memory": { "limit": 536870912 }, // → memory.max = 512MB
"cpu": { "quota": 50000, "period": 100000 } // → cpu.max "50000 100000"
},
"seccomp": { "defaultAction": "SCMP_ACT_ERRNO", "syscalls": [...] }
}
}
runc OCI 스펙 처리 커널 실행 순서
runc가 config.json을 파싱하여 실제 커널 syscall을 호출하는 순서를 정리합니다:
| 단계 | runc 동작 | 커널 syscall | 비고 |
|---|---|---|---|
| 1 | namespace 생성 | clone(CLONE_NEWPID|CLONE_NEWNET|...) | nsexec.c에서 Go 런타임 전에 처리 |
| 2 | uid/gid 매핑 | write("/proc/PID/uid_map") | Rootless 시 newuidmap 사용 |
| 3 | cgroup 설정 | cgroup v2 파일 쓰기 | CLONE_INTO_CGROUP으로 대체 가능 |
| 4 | capabilities 설정 | prctl(PR_CAPBSET_DROP, ...) | bounding set에서 불필요 cap 제거 |
| 5 | seccomp 필터 설치 | prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER) | exec 전에 반드시 설치 |
| 6 | OverlayFS 마운트 | mount("overlay", merged, "overlay", ...) | lowerdir/upperdir/workdir 설정 |
| 7 | 바인드 마운트(Bind Mount) | mount(src, dst, MS_BIND) | 볼륨, /proc, /sys, /dev 마운트 |
| 8 | rootfs 교체 | pivot_root(new_root, put_old) | chroot가 아닌 pivot_root 사용 |
| 9 | AppArmor 프로필 | write("/proc/self/attr/exec", profile) | exec 후 자동 적용 |
| 10 | 컨테이너 init exec | execve(entrypoint, args, env) | runc 종료, init PID 1이 됨 |
컨테이너 런타임
컨테이너 런타임은 3계층으로 분류됩니다:
| 계층 | 역할 | 예시 |
|---|---|---|
| CRI (인터페이스) | 오케스트레이터와 런타임 간 gRPC API | Kubernetes CRI |
| High-level | 이미지 관리, 스냅샷, 네트워킹 | containerd, CRI-O |
| Low-level (OCI) | 실제 컨테이너 생성/실행 | runc, crun, youki |
runc (OCI 참조 구현)
runc은 Docker가 기증한 libcontainer를 기반으로 한 Go 언어 구현의 OCI 참조 런타임입니다. 컨테이너 생성의 핵심 코드 흐름:
/* runc의 컨테이너 생성 흐름 (Go → 커널 호출) */
// 1. config.json 파싱 → Container 구조체 생성
container := libcontainer.Create(id, config)
// 2. 부모 프로세스에서 clone() + CLONE_NEW* 플래그로 자식 생성
// (실제로는 /proc/self/exe를 재실행하는 "init" 방식)
cmd := &exec.Cmd{
Path: "/proc/self/exe", // runc 바이너리 자체를 다시 실행
Args: []string{"runc", "init"},
}
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS | syscall.CLONE_NEWNET |
syscall.CLONE_NEWIPC | syscall.CLONE_NEWUSER,
}
// 3. 자식 프로세스(runc init)에서:
pivot_root(rootfs, put_old) // rootfs 전환
mount("proc", "/proc", "proc", 0, "") // /proc 마운트
prctl(PR_SET_NO_NEW_PRIVS, 1) // 권한 상승 차단
seccomp(SET_MODE_FILTER, &prog) // seccomp 필터 적용
execve(entrypoint, args, envp) // 컨테이너 프로세스 실행
# runc 직접 사용 예시
$ mkdir -p bundle/rootfs
$ docker export $(docker create alpine) | tar -C bundle/rootfs -xf -
$ cd bundle
$ runc spec # 기본 config.json 생성
$ runc create mycontainer # 컨테이너 생성 (created 상태)
$ runc start mycontainer # 프로세스 시작 (running 상태)
$ runc list # 컨테이너 목록 확인
$ runc state mycontainer # 상태 JSON 출력
$ runc delete mycontainer # 정리
crun (C 구현)
crun은 runc의 C 언어 대안 구현으로, Red Hat이 개발합니다.
| 비교 항목 | runc (Go) | crun (C) |
|---|---|---|
| 메모리 사용 | ~10-15 MB (Go 런타임) | ~1-2 MB |
| 시작 시간 | 기준 | ~40-50% 빠름 |
| cgroup v2 | 지원 | 네이티브 우선 지원 |
| WASM | 미지원 | wasmedge/wasmtime 지원 |
| 채택 | Docker 기본 | Podman/Fedora 기본 |
containerd
containerd는 CNCF 졸업 프로젝트로, Docker와 Kubernetes의 핵심 컨테이너 런타임입니다.
- gRPC API — 클라이언트(Docker/kubelet)가 API로 컨테이너 라이프사이클 관리
- Snapshotter — 이미지 레이어를 파일시스템 스냅샷으로 관리 (overlayfs, native, devmapper 등)
- Content Store — content-addressable 스토리지로 이미지 블롭 저장
- Shim 프로세스 —
containerd-shim-runc-v2가 각 컨테이너와 1:1로 존재하여, containerd 재시작과 컨테이너를 분리 - CRI 플러그인 — Kubernetes CRI(Container Runtime Interface) 네이티브 구현
# containerd 디버깅 도구
$ ctr images pull docker.io/library/alpine:latest # 이미지 pull
$ ctr run --rm docker.io/library/alpine:latest test /bin/sh # 실행
$ ctr containers list # 컨테이너 목록
$ ctr tasks list # 실행 중인 태스크
# nerdctl (containerd용 Docker-호환 CLI)
$ nerdctl run --rm -it alpine sh # Docker와 동일한 UX
Podman
Podman은 Red Hat이 개발한 데몬리스(daemonless) 컨테이너 엔진입니다. Docker와 달리 중앙 데몬이 없어 각 컨테이너가 독립적인 프로세스 트리를 가집니다.
- Fork/exec 모델 — podman 명령이 직접 conmon(container monitor)을 생성하고, conmon이 runc/crun을 호출
- Rootless 네이티브 — User namespace 기반으로 일반 사용자가 컨테이너 실행 가능
- systemd 통합 —
podman generate systemd로 컨테이너를 systemd 서비스로 관리 - Pod 지원 — Kubernetes Pod 개념을 로컬에서 구현 (infra 컨테이너로 네임스페이스 공유)
# Podman rootless 사용 예
$ podman run --rm -it alpine sh # root 권한 불필요
$ podman pod create --name mypod -p 8080:80 # Pod 생성
$ podman run --pod mypod nginx # Pod에 컨테이너 추가
# systemd 서비스로 관리
$ podman generate systemd --name mycontainer --new > mycontainer.service
$ systemctl --user enable --now mycontainer.service
Docker 데몬 아키텍처
Docker는 단일 프로세스가 아닌 4계층 분리 아키텍처로 동작합니다. 각 계층은 별도 프로세스로 실행되며 gRPC 또는 fork/exec로 통신합니다.
containerd → shim IPC 초기화 상세
# containerd가 새 컨테이너 생성 시 shim 프로세스 시작 순서
# 1. containerd가 shim 바이너리 실행
$ /usr/bin/containerd-shim-runc-v2 \
-namespace moby \
-id <container-id> \
-address /run/containerd/containerd.sock \
start # "start" 명령: shim 초기화
# 2. shim이 ttrpc 소켓 바인드 후 자신의 주소 출력 (containerd가 읽음)
# 출력: unix:///run/containerd/s/abc123def456
# 3. containerd가 shim 소켓에 연결하여 Task.Create 요청 (ttrpc)
# 4. shim이 runc create 실행 → namespace 생성 → 컨테이너 init 대기
# 5. containerd가 Task.Start 요청 → shim이 runc start → exec(init)
# shim v2 프로토콜: containerd/runtime/v2/task/shim.proto
# 주요 RPC: Create / Start / Delete / Exec / Kill / Wait / State / Pause / Resume
# shim이 containerd 없이도 동작함을 확인
$ kill -9 $(pgrep containerd) # containerd 강제 종료
$ ps aux | grep containerd-shim # shim은 계속 실행 중
$ docker ps # containerd 재시작 후 컨테이너 상태 보존 확인
각 계층의 역할
| 컴포넌트 | 역할 | IPC 방법 | 프로세스 생존 |
|---|---|---|---|
| docker CLI | 사용자 명령 처리, REST 요청 생성 | HTTP REST / Unix socket | 명령 완료 후 종료 |
| dockerd | API 서버, 볼륨/네트워크/이미지 관리 | gRPC → containerd | 데몬으로 항상 실행 |
| containerd | 이미지 pull/push, 스냅샷, 태스크(Task) 관리 | ttrpc → shim | 데몬으로 항상 실행 |
| containerd-shim | 컨테이너 생명주기 관리, stdio 릴레이, exit 코드 수집 | fork/exec → runc | 컨테이너 종료까지 생존 |
| runc | OCI 스펙 실행: namespace 생성, cgroup 설정, pivot_root, exec | syscall → kernel | 컨테이너 init 실행 후 종료 |
Docker 1.11(2016)부터 이 분리 아키텍처가 도입되었습니다. 핵심 이유는 containerd 재시작 시에도 실행 중인 컨테이너를 보존하기 위함입니다. shim이 컨테이너 PID 1의 부모 역할을 하므로, containerd가 재시작되어도 컨테이너는 계속 실행됩니다.
ttrpc — containerd ↔ shim 프로토콜
ttrpc는 gRPC의 경량 버전으로, containerd와 shim 사이에서 Unix 도메인 소켓(Socket)을 통해 통신합니다. 메모리 사용량이 gRPC보다 훨씬 적고 지연(Latency) 시간이 낮아 수천 개의 컨테이너가 있는 환경에서도 효율적입니다.
# ttrpc 소켓 구조 확인
$ ls -la /run/containerd/s/
# 각 shim 인스턴스마다 고유한 소켓 파일 존재
srwxrwxrwx 1 root root 0 ... /run/containerd/s/abc123def456
# strace로 ttrpc 통신 프레임 확인
$ strace -f -p $(pgrep containerd-shim) -e sendmsg,recvmsg 2>&1 | head -30
# 프레임 헤더: [length:4B][stream_id:4B][flags:1B] + protobuf 페이로드
# gRPC 대비 HTTP/2 헤더 없음 → 저지연, 저메모리
containerd snapshotter — 이미지 pull 커널 경로
containerd가 이미지를 pull할 때 snapshotter API를 통해 OverlayFS 레이어를 준비합니다:
// containerd/snapshots/snapshots.go
type Snapshotter interface {
// 새 읽기-쓰기 스냅샷 준비 (컨테이너 upperdir)
Prepare(ctx context.Context, key, parent string, ...) ([]mount.Mount, error)
// 읽기 전용 뷰 생성 (이미지 레이어)
View(ctx context.Context, key, parent string, ...) ([]mount.Mount, error)
// 쓰기 레이어를 읽기 전용으로 commit (새 이미지 레이어)
Commit(ctx context.Context, name, key string, ...) error
// 마운트 포인트 목록 반환
Mounts(ctx context.Context, key string) ([]mount.Mount, error)
}
// 이미지 pull → unpack 경로
// client.Pull() → fetch.Fetch() → unpack → snapshotter.Prepare() → mount
func unpack(ctx, desc, snapshotter) error {
key := fmt.Sprintf("extract-%s", desc.Digest)
mounts, _ := snapshotter.Prepare(ctx, key, parent)
// tar 레이어를 OverlayFS upperdir에 풀기
archive.Apply(ctx, mounts[0].Source, tarStream)
snapshotter.Commit(ctx, chainID, key)
}
# 실제 프로세스 트리 확인
$ pstree -p | grep -E 'dockerd|containerd|shim|runc'
systemd(1)─┬─dockerd(1234)
└─containerd(1235)─┬─containerd-shim(5678)─┬─nginx(5679)
│ └─nginx(5680)
└─containerd-shim(5681)───redis(5682)
# shim의 ttrpc 소켓 확인
$ ls /run/containerd/s/
abc123def456 # shim별 고유 소켓 파일 (ttrpc)
# containerd 재시작 후 컨테이너 확인 (shim이 유지하므로 running 상태 보존)
$ systemctl restart containerd
$ docker ps
CONTAINER ID IMAGE COMMAND STATUS NAMES
abc123def456 nginx "nginx" Up 10 minutes web
libcontainer 내부 구조
libcontainer는 runc 내부에 포함된 Go 라이브러리로, OCI 스펙을 커널 syscall로 변환합니다. 핵심 트릭은 /proc/self/exe 재실행으로, Go 런타임이 초기화되기 전에 namespace를 설정합니다.
/proc/self/exe 재실행 트릭 (nsexec.c)
Go 런타임은 multi-thread 환경에서 setns() 호출 시 문제가 발생합니다. libcontainer는 이를 C 생성자 함수로 우회합니다:
/* libcontainer/nsenter/nsexec.c */
/* __attribute__((constructor))으로 Go 런타임 전에 실행 */
__attribute__((constructor)) void nsexec(void)
{
int pipenum;
char *_nsenter_init = getenv("_LIBCONTAINER_INITPIPE");
if (_nsenter_init == NULL)
return; /* 환경변수 없으면 바이패스 (일반 runc 실행) */
pipenum = atoi(_nsenter_init);
/* 3단계 fork: parent → child1(user NS) → child2(기타 NS들) */
switch (setjmp(env)) {
case JUMP_PARENT:
update_uid_map(pid); /* /proc/PID/uid_map 쓰기 */
update_gid_map(pid); /* /proc/PID/gid_map 쓰기 */
break;
case JUMP_CHILD:
setns(fd, CLONE_NEWUSER); /* user namespace 진입 */
clone(..., CLONE_NEWPID|CLONE_NEWNET|CLONE_NEWNS|...);
break;
}
exit(0); /* C 생성자 완료 → Go 런타임 시작 */
}
init process 3가지 유형
| 유형 | 사용 시나리오 | 핵심 동작 |
|---|---|---|
initStandard | docker run — 새 컨테이너 생성 | clone() → namespace 생성 → pivot_root → exec(init) |
initSetns | docker exec — 실행 중 컨테이너에 프로세스 추가 | setns()로 기존 namespace에 합류 → exec(command) |
initUserns | Rootless Docker — user namespace 생성 필요 | user NS 먼저 생성 → uid_map 설정 → 나머지 NS 생성 |
// libcontainer/container_linux.go: init process 선택 로직
func (c *linuxContainer) newParentProcess(p *Process) (parentProcess, error) {
parentInitPipe, childInitPipe, _ := utils.NewSockPair("init")
if p.Init {
return c.newInitProcess(p, parentInitPipe, childInitPipe)
}
// docker exec: 기존 컨테이너에 exec
return c.newSetnsProcess(p, parentInitPipe, childInitPipe)
}
func (c *linuxContainer) newInitProcess(p *Process, ...) (*initProcess, error) {
// _LIBCONTAINER_INITPIPE 환경변수로 nsexec.c 트리거
cmd.Env = append(cmd.Env, "_LIBCONTAINER_INITPIPE="+strconv.Itoa(childInitPipe.Fd()))
if c.config.RootlessEUID {
return c.newUsernsProcess(p, ...) // Rootless: initUserns
}
return &initProcess{...}, nil // 일반: initStandard
}
state.json — 컨테이너 상태 파일
# state.json 경로 (containerd가 관리)
$ cat /run/containerd/runc/default/<container-id>/state.json
{
"id": "abc123",
"init_process_pid": 12345,
"init_process_start": 1234567890,
"created": "2024-01-01T00:00:00Z",
"config": { /* OCI config.json 전체 */ },
"rootless": false,
"cgroup_paths": {
"": "/sys/fs/cgroup/system.slice/docker-abc123.scope"
},
"namespace_paths": {
"NEWNET": "/proc/12345/ns/net",
"NEWPID": "/proc/12345/ns/pid",
"NEWNS": "/proc/12345/ns/mnt",
"NEWUTS": "/proc/12345/ns/uts",
"NEWIPC": "/proc/12345/ns/ipc",
"NEWUSER": "/proc/12345/ns/user"
},
"external_descriptors": ["/dev/pts/0"]
}
Factory 인터페이스
// libcontainer/factory_linux.go
type Factory interface {
Create(id string, config *configs.Config) (Container, error)
Load(id string) (Container, error)
StartInitialization() error
}
type Container interface {
ID() string
Status() (Status, error)
State() (*State, error)
Config() configs.Config
Run(process *Process) error
Start(process *Process) error
Exec() error
Pause() error
Resume() error
Destroy() error
Signal(s os.Signal, all bool) error
}
// 상태 파일: /run/containerd/runc/<id>/state.json
type State struct {
BaseState
Rootless bool `json:"rootless"`
CgroupPaths map[string]string `json:"cgroup_paths"`
NamespacePaths map[configs.NamespaceType]string `json:"namespace_paths"`
}
컨테이너 스토리지 (OverlayFS)
OverlayFS는 컨테이너의 기본 스토리지 드라이버로, 읽기 전용 이미지 레이어 위에 쓰기 가능한 컨테이너 레이어를 오버레이(Overlay)합니다. 커널 3.18에서 mainline에 통합되었습니다.
OverlayFS 레이어 구조
| 디렉토리 | 역할 | 설명 |
|---|---|---|
lowerdir | 읽기 전용 | 이미지 레이어 (여러 개 콜론으로 연결) |
upperdir | 읽기/쓰기 | 컨테이너의 변경사항이 기록되는 레이어 |
workdir | 작업용 | atomic 연산을 위한 임시 디렉토리 |
merged | 통합 뷰 | lower + upper를 합친 최종 마운트 포인트 |
Copy-up 메커니즘
lower 레이어의 파일을 수정할 때, OverlayFS는 해당 파일을 upper 레이어로 복사한 뒤 수정합니다. 이를 copy-up이라 합니다.
/* OverlayFS copy-up 핵심 (fs/overlayfs/copy_up.c) */
static int ovl_copy_up_one(struct dentry *parent,
struct dentry *dentry,
struct path *lowerpath,
struct kstat *stat)
{
/* 1. upper에 임시 파일 생성 (workdir에서) */
temp = ovl_create_temp(workdir, ...);
/* 2. lower에서 데이터 복사 */
ovl_copy_up_data(lowerpath, temp, stat->size);
/* 3. xattr, 권한, 타임스탬프 복사 */
ovl_copy_xattr(lowerpath->dentry, temp);
ovl_set_attr(temp, stat);
/* 4. atomic rename으로 upper에 배치 */
vfs_rename(workdir, temp, upperdir, dentry);
return 0;
}
-v)를 사용하여 OverlayFS를 우회하세요.대안 스토리지 드라이버
| 드라이버 | 특징 | 사용 사례 |
|---|---|---|
| overlay2 | 다중 lower 레이어, 커널 4.0+ | Docker/containerd 기본 |
| devicemapper | 블록 수준 thin provisioning | RHEL 7 레거시 |
| btrfs | 스냅샷 기반, CoW 파일시스템 | SUSE 계열 |
| ZFS | 스냅샷/클론, 압축, 체크섬(Checksum) | Ubuntu (실험적) |
| fuse-overlayfs | FUSE 기반 overlay | rootless (커널 5.11 이전) |
Whiteout / Opaque 디렉토리
OverlayFS에서 lower 레이어는 읽기 전용이므로, 파일을 "삭제"할 때 실제 lower의 파일을 제거하는 것이 아니라 upper 레이어에 whiteout 파일을 생성하여 해당 파일을 가립니다. Whiteout은 character device(major=0, minor=0)로 표현됩니다.
/* fs/overlayfs/overlayfs.h — whiteout 관련 상수 */
#define OVL_WHITEOUT_DEV MKDEV(0, 0)
/* whiteout 생성 (fs/overlayfs/dir.c) */
static int ovl_whiteout(struct ovl_fs *ofs,
struct dentry *upperdir,
struct dentry *dentry)
{
struct dentry *whiteout;
/* workdir에서 whiteout 장치 파일(0,0) 생성 */
whiteout = ovl_lookup_temp(ofs, ofs->workdir);
ovl_do_whiteout(ofs, whiteout); /* mknod(S_IFCHR, 0/0) */
/* atomic rename으로 upper에 배치 */
return ovl_do_rename(ofs, ofs->workdir, whiteout,
upperdir, dentry, 0);
}
디렉토리 전체를 삭제한 뒤 같은 이름으로 재생성하는 경우, OverlayFS는 opaque 디렉토리를 사용합니다. upper에 새 디렉토리를 만들고 trusted.overlay.opaque="y" xattr을 설정하면, 이 디렉토리 하위에서는 lower의 동명 디렉토리를 참조하지 않습니다.
# Whiteout 확인 예시
$ ls -la upper/
c--------- 1 root root 0, 0 ... config.txt # whiteout: char device 0/0
# Opaque 디렉토리 확인
$ getfattr -n trusted.overlay.opaque upper/data/
trusted.overlay.opaque="y"
# merged에서 확인
$ ls merged/
data/ README # config.txt는 whiteout에 의해 숨겨짐
tar --xattrs --xattrs-include='trusted.*' 옵션을 사용하거나, OverlayFS 전용 도구를 활용하세요.Redirect Dir (디렉토리 Rename)
OverlayFS에서 디렉토리를 rename하면 upper에만 새 디렉토리가 생기고, lower의 원본 디렉토리는 여전히 존재합니다. 이를 올바르게 처리하기 위해 커널 4.10+에서 redirect dir 기능이 도입되었습니다. 새 디렉토리의 trusted.overlay.redirect xattr에 원본 경로를 저장하여, lower의 원본 디렉토리 내용을 새 위치에서 투명하게 참조합니다.
| 옵션 값 | 설명 | 기본값 |
|---|---|---|
redirect_dir=on | 디렉토리 rename 시 redirect xattr 생성 | - |
redirect_dir=off | 디렉토리 rename 실패 (EXDEV 반환) | - |
redirect_dir=follow | 기존 redirect xattr 해석만, 새로 생성 안 함 | 커널 기본값 |
redirect_dir=nofollow | redirect xattr 무시 | - |
/* 디렉토리 rename 시 redirect lookup 의사코드 */
static int ovl_rename(struct inode *olddir, struct dentry *old,
struct inode *newdir, struct dentry *new)
{
/* 1. old가 디렉토리이고 lower에 존재하면 redirect 설정 필요 */
if (ovl_type_merge_or_lower(old)) {
/* 2. new 디렉토리에 redirect xattr 설정 */
ovl_set_redirect(old, ovl_dentry_get_redirect(old));
/* trusted.overlay.redirect="/original/path" */
}
/* 3. 이후 lookup 시 redirect xattr을 읽어 lower 검색에 사용 */
return ovl_do_rename(...);
}
redirect_dir=on은 lower 레이어의 임의 디렉토리를 upper에서 참조할 수 있게 하므로, 신뢰할 수 없는 upper 레이어를 마운트할 때 보안 위험이 있습니다. 커널 기본값이 follow인 이유입니다. 다중 사용자 환경에서는 redirect_dir=nofollow를 고려하세요.Metacopy 최적화
커널 4.19+에서 도입된 metacopy 기능은 copy-up 시 파일 데이터 대신 메타데이터(소유권, 권한, xattr)만 upper에 복사합니다. 실제 데이터 읽기/쓰기가 발생할 때까지 데이터 복사를 지연(lazy copy-up)하여 성능을 대폭 개선합니다.
| 시나리오 | 기존 copy-up | metacopy | 개선 |
|---|---|---|---|
chmod 1GB 파일 | ~1GB 복사 + chmod | 메타데이터만 복사 | ~1000x 빠름 |
chown 대량 파일 | 전체 데이터 복사 | xattr만 기록 | 디스크 I/O 극소 |
touch (타임스탬프) | 전체 데이터 복사 | 메타데이터만 | 즉시 완료 |
파일 내용 수정 (write) | 전체 데이터 복사 | 지연 후 데이터 복사 | 동일 (최초 write 시) |
/* metacopy 분기 로직 (fs/overlayfs/copy_up.c) */
static int ovl_copy_up_meta_inode_data(struct ovl_copy_up_ctx *c)
{
/* metacopy 상태 확인: 메타데이터만 복사된 파일인지 */
if (ovl_is_metacopy_dentry(c->dentry)) {
/* 데이터 접근 발생 → 이제 실제 데이터 복사 */
err = ovl_copy_up_data(&c->lowerpath, &datapath,
c->stat.size);
if (err)
return err;
/* metacopy xattr 제거 (이제 전체 복사 완료) */
ovl_do_removexattr(ofs, upperpath.dentry,
OVL_XATTR_METACOPY);
}
return 0;
}
# metacopy 활성화 마운트
$ mount -t overlay overlay \
-o lowerdir=/lower,upperdir=/upper,workdir=/work,metacopy=on \
/merged
# chmod 후 metacopy xattr 확인
$ chmod 644 /merged/large_file.dat
$ getfattr -n trusted.overlay.metacopy /upper/large_file.dat
trusted.overlay.metacopy # 값 존재 = 데이터 미복사
# 파일 내용을 수정하면 데이터 copy-up 발생
$ echo "data" >> /merged/large_file.dat
$ getfattr -n trusted.overlay.metacopy /upper/large_file.dat
# xattr 없음 = 전체 데이터 복사 완료
OverlayFS 커널 내부 구조
OverlayFS의 커널 구현은 fs/overlayfs/ 디렉토리에 위치하며, VFS 레이어와 통합되어 upper/lower 레이어를 투명하게 합성합니다.
| 소스 파일 | 역할 |
|---|---|
super.c | 파일시스템 등록, 마운트 옵션 파싱, superblock 초기화 |
inode.c | inode operations (getattr, permission, get_acl 등) |
dir.c | 디렉토리 operations (lookup, mkdir, rmdir, rename, whiteout) |
file.c | 파일 operations (open, read_iter, write_iter, mmap, fsync) |
copy_up.c | copy-up 핵심 로직, metacopy 처리 |
readdir.c | 디렉토리 읽기, whiteout 필터링, merge sort |
namei.c | 경로 탐색 (redirect, metacopy lookup) |
util.c | 유틸리티 함수 (xattr 조작, dentry 헬퍼) |
export.c | NFS export / file handle 지원 |
params.c | 마운트 파라미터 파싱 (커널 6.5+, 기존 super.c에서 분리) |
/* 핵심 자료구조: OverlayFS 파일시스템 인스턴스 (fs/overlayfs/ovl_entry.h) */
struct ovl_fs {
unsigned int numlayer; /* 전체 레이어 수 */
struct ovl_layer *layers; /* 레이어 배열 (upper + lowers) */
struct ovl_sb *fs; /* 레이어별 superblock 정보 */
struct dentry *workdir; /* workdir dentry */
long namelen; /* 파일명 최대 길이 */
struct ovl_config config; /* 마운트 옵션 구조체 */
const struct cred *creator_cred; /* 마운트 수행자 credential */
};
/* OverlayFS inode 확장 */
struct ovl_inode {
struct ovl_dir_cache *cache; /* readdir 캐시 */
const char *redirect; /* redirect 경로 */
struct inode *upper; /* upper inode (있으면) */
struct ovl_path lowerpath; /* lower 경로 */
struct inode vfs_inode; /* VFS inode (임베딩) */
unsigned long flags; /* OVL_UPPERDATA, OVL_METACOPY 등 */
};
/* dentry 개인 데이터: upper/lower 매핑 */
struct ovl_entry {
unsigned numlower; /* lower 경로 수 */
struct ovl_path lowerstack[]; /* flexible array: lower dentry 배열 */
};
xattr 기반 메타데이터
OverlayFS는 확장 속성(xattr)을 통해 레이어 간 관계와 파일 상태를 관리합니다. 모든 xattr은 trusted.overlay.* 네임스페이스에 저장됩니다.
| xattr | 용도 | 값 예시 |
|---|---|---|
trusted.overlay.opaque | opaque 디렉토리 표시 | "y" |
trusted.overlay.redirect | 디렉토리 rename 원본 경로 | "/original/dir" |
trusted.overlay.metacopy | 메타데이터만 copy-up됨 표시 | (빈 값) |
trusted.overlay.impure | 디렉토리에 copy-up된 항목 존재 | "y" |
trusted.overlay.origin | copy-up 원본의 file handle | (바이너리 fh) |
trusted.overlay.upper | upper에서 생성된 파일 표시 | (빈 값) |
trusted.overlay.nlink | hardlink 보정 nlink 값 | "U+3/L+2" |
trusted.* 네임스페이스는 root만 읽기/쓰기 가능하여 일반 사용자의 조작을 방지합니다. 커널 5.11+ rootless 환경에서는 userxattr 마운트 옵션을 사용하여 user.overlay.* 네임스페이스로 매핑합니다.
# xattr 전체 조회
$ getfattr -d -m "trusted.overlay.*" upper/mydir/
# file: upper/mydir/
trusted.overlay.impure="y"
trusted.overlay.opaque="y"
# origin file handle 확인 (바이너리)
$ getfattr -n trusted.overlay.origin -e hex upper/copied_file
trusted.overlay.origin=0x00fb010020...
# userxattr 환경 (rootless, 커널 5.11+)
$ getfattr -d -m "user.overlay.*" upper/mydir/
user.overlay.opaque="y"
trusted.overlay.* xattr을 수동으로 추가/제거/수정하면 OverlayFS 파일시스템이 손상될 수 있습니다. 특히 origin이나 nlink를 잘못 편집하면 파일이 사라지거나 데이터가 불일치합니다. 디버깅 용도의 읽기만 권장합니다.마운트 옵션 상세
| 옵션 | 값 | 커널 | 설명 |
|---|---|---|---|
lowerdir | 경로(:경로...) | 3.18+ | 읽기 전용 레이어 (우측이 최하위, 최대 500개) |
upperdir | 경로 | 3.18+ | 읽기/쓰기 레이어 (생략 시 read-only 마운트) |
workdir | 경로 | 3.18+ | atomic 연산 임시 디렉토리 (upperdir와 같은 FS) |
redirect_dir | on/off/follow/nofollow | 4.10+ | 디렉토리 rename redirect 처리 방식 |
metacopy | on/off | 4.19+ | 메타데이터 전용 copy-up (데이터 지연 복사) |
index | on/off | 4.13+ | inode index (hardlink copy-up, NFS export 지원) |
nfs_export | on/off | 4.16+ | NFS file handle 지원 (index=on 필요) |
xino | on/off/auto | 4.17+ | inode 번호에 레이어 비트 추가 (st_ino 충돌 방지) |
volatile | (플래그) | 5.6+ | sync/fsync 무시 (비정상 종료 시 데이터 손실 위험) |
userxattr | (플래그) | 5.11+ | user.overlay.* 네임스페이스 사용 (rootless용) |
uuid | on/off/null | 6.6+ | 레이어 UUID 검증 방식 제어 |
# 기본 마운트
$ mount -t overlay overlay \
-o lowerdir=/lower,upperdir=/upper,workdir=/work \
/merged
# 다중 lower 레이어 (왼쪽이 최상위)
$ mount -t overlay overlay \
-o lowerdir=/app:/runtime:/base,upperdir=/upper,workdir=/work \
/merged
# read-only 마운트 (upperdir/workdir 생략)
$ mount -t overlay overlay \
-o lowerdir=/layer3:/layer2:/layer1 \
/merged
# CI/CD 최적화: volatile + metacopy
$ mount -t overlay overlay \
-o lowerdir=/lower,upperdir=/upper,workdir=/work,volatile,metacopy=on \
/merged
# xino 활성화 (크로스-레이어 inode 번호 일관성)
$ mount -t overlay overlay \
-o lowerdir=/lower,upperdir=/upper,workdir=/work,xino=on \
/merged
주요 옵션 상세 설명:
volatile—sync(2),syncfs(2),fsync(2)를 upper 파일시스템에 전파하지 않습니다. 비정상 종료 시 upper 레이어가 손상될 수 있으나, CI/CD 빌드 캐시(Cache)처럼 일시적 데이터에 사용하면 쓰기 성능이 크게 향상됩니다. Docker BuildKit에서--mount=type=tmpfs대신 활용할 수 있습니다.xino— 각 레이어의 inode 번호 상위 비트에 레이어 인덱스를 인코딩합니다. 이를 통해 서로 다른 레이어에서 동일한 inode 번호를 가진 파일들이 merged 마운트에서 고유한st_ino를 갖습니다.find,rsync등 inode 번호 기반 도구가 정상 동작하려면 활성화가 권장됩니다.index— upper 레이어의work/index/디렉토리에 lower 파일의 file handle을 저장합니다. 이를 통해 hardlink가 copy-up 후에도 올바르게 유지되고, NFS export 시 reboot 후에도 file handle이 유효합니다.
Docker OverlayFS — /var/lib/docker/overlay2/ 구조
Docker는 이미지 레이어를 OverlayFS로 구성합니다. 이미지 레이어는 읽기 전용(lowerdir), 컨테이너 쓰기 레이어는 읽기-쓰기(upperdir)입니다.
/var/lib/docker/overlay2/ 디렉토리 구조
/var/lib/docker/overlay2/
├── <layer-hash-1>/ # 이미지 레이어 1 (lowerdir)
│ ├── diff/ # 실제 파일들
│ └── link # 짧은 심볼릭 링크 ID
├── <layer-hash-2>/ # 이미지 레이어 2 (lowerdir)
│ ├── diff/
│ ├── lower # 하위 레이어 참조 ("l/<id1>:l/<id2>")
│ └── link
├── <container-hash>/ # 컨테이너 레이어
│ ├── diff/ # upperdir: 수정된 파일들
│ ├── work/ # workdir: 원자적 작업용
│ ├── merged/ # 마운트 포인트 (lowerdir+upperdir 합성 뷰)
│ └── lower # 참조하는 이미지 레이어 체인
└── l/ # 짧은 ID 심볼릭 링크 디렉토리
# 실제 OverlayFS 마운트 명령 (runc가 수행)
$ mount -t overlay overlay \
-o lowerdir=l/ID1:l/ID2:l/ID3,upperdir=<container>/diff,workdir=<container>/work \
<container>/merged
# 현재 마운트된 overlay 확인
$ mount | grep overlay
overlay on /var/lib/docker/overlay2/<hash>/merged type overlay
(rw,lowerdir=l/A:l/B:l/C,upperdir=.../diff,workdir=.../work)
Docker copy-up 실전
# 컨테이너에서 이미지 파일 수정 시 copy-up 발생
$ docker exec -it mycontainer bash
# 파일이 lowerdir(읽기전용)에 존재
container# cat /etc/nginx/nginx.conf # lowerdir에서 읽기 (빠름)
# 처음 쓰기 시 copy-up 발생
container# echo "# test" >> /etc/nginx/nginx.conf
# 커널 OverlayFS가 수행하는 작업:
# 1. lowerdir에서 nginx.conf를 workdir로 복사 (원자적)
# 2. workdir → upperdir로 rename (원자적)
# 3. 이후 쓰기는 upperdir의 복사본에 직접 적용
# upperdir에 copy-up된 파일 확인
$ ls /var/lib/docker/overlay2/<container>/diff/etc/nginx/
nginx.conf # copy-up된 파일 - 이미지 레이어는 변경 없음
ovl_copy_up_one() — 커널 copy-up 경로 (fs/overlayfs/copy_up.c)
/* fs/overlayfs/copy_up.c — copy-up 핵심 함수 */
static int ovl_copy_up_one(struct dentry *parent, struct dentry *dentry,
int flags)
{
struct ovl_fs *ofs = OVL_FS(dentry->d_sb);
struct path lowerpath;
ovl_path_lower(dentry, &lowerpath);
/* metacopy 최적화: 메타데이터만 copy-up (파일 내용은 lowerdir 참조) */
if (flags & OVL_COPY_UP_METADATA_ONLY) {
return ovl_copy_up_metadata(ofs, dentry, parent);
}
/* 1단계: workdir에 임시 파일 생성 */
struct dentry *temp = ovl_create_temp(ofs->workdir, ...);
/* 2단계: lowerdir → temp 파일 내용 복사 */
ovl_copy_up_data(&lowerpath, temp, ...);
/* 3단계: xattr 복사 (trusted.overlay.* 포함) */
ovl_copy_xattr(ofs, lowerpath.dentry, temp);
/* 4단계: workdir/temp → upperdir/filename rename (원자적) */
ovl_rename(workdir, temp, upperdir, dentry, ...);
return 0;
}
metacopy 최적화 — 메타데이터만 copy-up
metacopy 기능을 활성화하면 파일 내용을 복사하지 않고 메타데이터(권한, xattr)만 upperdir로 복사합니다. 파일 내용은 여전히 lowerdir를 참조합니다:
# metacopy 활성화 (Ubuntu 20.04+, 커널 4.19+)
$ mount -t overlay overlay \
-o lowerdir=lower,upperdir=upper,workdir=work,metacopy=on \
merged/
# 확인: 퍼미션 변경 시 내용 복사 없이 메타데이터만 upperdir에 생성
$ chmod 644 merged/large-file.bin
$ ls -la upper/large-file.bin # 작은 크기 (내용 없음, xattr로 lowerdir 참조)
# trusted.overlay.metacopy xattr 확인
$ getfattr -d upper/large-file.bin
trusted.overlay.metacopy = "" # 메타데이터 전용 copy-up 표시
trusted.overlay.origin = ... # lowerdir 원본 참조
volatile 마운트 — 빌드 성능 향상
# volatile 옵션: fsync/fdatasync 생략 (컨테이너 빌드 시 성능 향상)
$ mount -t overlay overlay \
-o lowerdir=lower,upperdir=upper,workdir=work,volatile \
merged/
# BuildKit은 내부적으로 volatile 사용 (빌드 캐시 레이어)
# 주의: 비정상 종료 시 upperdir 데이터 손실 가능 → 최종 이미지 레이어에는 사용 불가
# volatile 마운트 여부 확인
$ cat /proc/mounts | grep overlay | grep volatile
whiteout — 삭제 표현
# OverlayFS whiteout: lowerdir의 파일 삭제 표현
# 방법 1: character device (major=0, minor=0)
$ mknod upper/deleted-file c 0 0 # whiteout 파일 직접 생성
# 방법 2: .wh. 접두사 (OCI tar 레이어에서 사용)
$ ls upper/
.wh.deleted-file # tar 레이어에서의 whiteout 표현
# opaque 디렉토리: 하위 lowerdir 숨김 (디렉토리 삭제 후 재생성)
$ getfattr -d upper/recreated-dir/
trusted.overlay.opaque = "y" # lowerdir의 같은 이름 디렉토리 내용 숨김
# Docker 컨테이너에서 파일 삭제 후 확인
$ docker exec mycontainer rm /etc/nginx/nginx.conf
$ ls -la $(docker inspect mycontainer --format='{{.GraphDriver.Data.UpperDir}}')/etc/nginx/
c --------- 1 root root 0, 0 nginx.conf # 문자 디바이스(whiteout)
BuildKit과 이미지 레이어 최적화
# BuildKit 최적화 Dockerfile 예시
# syntax=docker/dockerfile:1
FROM ubuntu:22.04
# RUN --mount: BuildKit 캐시 마운트 (이미지에 포함 안됨)
RUN --mount=type=cache,target=/var/cache/apt \
apt-get update && apt-get install -y nginx
# RUN --mount=type=secret: 비밀값 마운트
RUN --mount=type=secret,id=aws,target=/root/.aws/credentials \
aws s3 cp s3://bucket/file .
# 빌드: content-addressable, 병렬 처리
$ DOCKER_BUILDKIT=1 docker build --progress=plain .
# BuildKit 원격 캐시 활용 (CI/CD 빌드 속도 향상)
$ docker build \
--cache-from type=registry,ref=myregistry/myapp:cache \
--cache-to type=registry,ref=myregistry/myapp:cache,mode=max \
-t myregistry/myapp:latest .
# BuildKit 빌드 로그 분석 (레이어 캐시 히트/미스 확인)
$ docker build --progress=plain . 2>&1 | grep -E "CACHED|RUN|COPY"
#5 CACHED # 캐시 히트 → 빠름
#6 RUN apt-get update # 캐시 미스 → 다시 실행
성능 튜닝 및 모범 사례
OverlayFS 성능의 핵심은 copy-up 최소화입니다. 컨테이너 이미지 빌드와 런타임 양쪽에서 최적화할 수 있습니다.
# Bad: 불필요한 copy-up 유발 Dockerfile
FROM ubuntu:22.04
COPY large_dataset.tar.gz /data/ # 레이어 1: 5GB
RUN tar xzf /data/large_dataset.tar.gz # 레이어 2: 5GB 풀림 + 5GB tar 잔존
RUN rm /data/large_dataset.tar.gz # 레이어 3: whiteout만 (5GB는 여전히 레이어 1에)
RUN chmod -R 755 /data/extracted/ # 레이어 4: 전체 copy-up 발생!
# Good: copy-up 최소화 Dockerfile
FROM ubuntu:22.04
COPY large_dataset.tar.gz /tmp/
RUN tar xzf /tmp/large_dataset.tar.gz -C /data/ \
&& rm /tmp/large_dataset.tar.gz \
&& chmod -R 755 /data/extracted/ # 단일 레이어에서 모두 처리
Copy-up 최소화 전략:
- Dockerfile 레이어 병합 — 권한 변경, 파일 삭제를 같은
RUN명령에서 처리 - 볼륨 마운트 — 빈번히 수정되는 데이터(DB, 로그)는
-v로 OverlayFS 우회 - tmpfs 활용 — 임시 파일은
--tmpfs /tmp으로 메모리에 배치 - multi-stage 빌드 — 빌드 도구/중간 산물을 최종 이미지에서 제외
| 모니터링 메트릭 | 도구 | 경로/명령 | 해석 |
|---|---|---|---|
| upper 디스크 사용량 | du -sh | upperdir 경로 | copy-up 누적량 파악 |
| inode 사용량 | df -i | upper 파일시스템 | whiteout + copy-up 파일 수 |
| copy-up 이벤트 | ftrace | ovl_copy_up_one | copy-up 빈도 추적 |
| overlay 마운트 정보 | cat | /proc/mounts | 마운트 옵션 확인 |
| 레이어 구성 | mount -l | type overlay | lower/upper/work 경로 확인 |
# ftrace로 copy-up 이벤트 실시간 추적
$ echo 1 > /sys/kernel/debug/tracing/events/overlayfs/ovl_copy_up/enable
$ cat /sys/kernel/debug/tracing/trace_pipe
# TASK-PID CPU# TIMESTAMP FUNCTION
# | | | | |
bash-1234 [002] 1234.567: ovl_copy_up: path=/etc/config.conf size=4096
node-5678 [001] 1235.890: ovl_copy_up: path=/app/node_modules/.cache size=52428800
# 추적 중지
$ echo 0 > /sys/kernel/debug/tracing/events/overlayfs/ovl_copy_up/enable
| 문제 | 원인 | 해결 |
|---|---|---|
| ENOSPC (공간 부족) | copy-up으로 upper 파일시스템 가득 | 불필요한 컨테이너 정리 (docker system prune), upper 볼륨 확장 |
| inode 고갈 | 대량 whiteout/small 파일로 inode 소진 | df -i로 확인, 이미지 레이어 최적화, mkfs -N으로 inode 수 증가 |
| 느린 첫 쓰기 | 대용량 파일 copy-up | metacopy=on 활성화, 볼륨 마운트 사용 |
| stat 불일치 | cross-layer inode 번호 충돌 | xino=on 활성화 |
| NFS export 실패 | file handle 미지원 | index=on,nfs_export=on 마운트 옵션 |
| rename EXDEV | cross-device rename 시도 | redirect_dir=on 활성화 |
workdir 디렉토리의 내용을 수동으로 편집하거나 삭제하면 안 됩니다. OverlayFS는 workdir을 atomic 연산(rename, whiteout 생성)의 임시 공간으로 사용하며, 수동 조작 시 진행 중인 연산이 실패하고 파일시스템이 불일치 상태에 빠질 수 있습니다. 마운트 해제 후에도 work/incompat/volatile 등의 내부 파일이 존재할 수 있으며, 이는 정상입니다.Docker 이미지 레이어와 BuildKit
OCI Image Spec v1.0은 이미지를 content-addressable store로 정의합니다. 각 레이어는 sha256 해시(Hash)로 참조됩니다.
Docker OCI Image Spec 구조
# OCI 이미지 내부 구조 (docker save로 추출)
$ docker save nginx:latest | tar x -C ./oci-image/
$ ls ./oci-image/
blobs/ index.json oci-layout
# index.json: 이미지 인덱스 (manifest 참조)
$ cat index.json
{
"manifests": [{ "digest": "sha256:abc...", "mediaType": "...manifest.v2+json" }]
}
# manifest: config + layers 목록
$ cat blobs/sha256/abc...
{
"config": { "digest": "sha256:def..." },
"layers": [
{ "digest": "sha256:111...", "mediaType": "...tar.gzip" },
{ "digest": "sha256:222...", "mediaType": "...tar.gzip" },
{ "digest": "sha256:333...", "mediaType": "...tar.gzip" }
]
}
# 레이어 내용: 델타 tar.gz (이전 레이어 대비 추가/변경/삭제 파일)
$ tar tz -f blobs/sha256/111... | head -20
etc/
etc/nginx/
etc/nginx/nginx.conf
usr/
usr/sbin/
usr/sbin/nginx
content-addressable store — 이미지 레이어 공유
# 이미지 레이어 공유 확인 (동일 기반 이미지를 가진 여러 이미지)
$ docker images --digests
REPOSITORY TAG DIGEST SIZE
nginx latest sha256:abc... 143MB
myapp v1 sha256:def... 145MB # nginx 기반 → 레이어 공유
# containerd snapshotter로 레이어 내용 확인
$ ctr snapshots list | head -10
KEY PARENT KIND
sha256:111... <empty> Committed # base 레이어
sha256:222... sha256:111... Committed # 두 번째 레이어
abc123 sha256:222... Active # 컨테이너 쓰기 레이어
# 레이어 chain ID 계산: SHA256(parent_chain_id + " " + layer_diff_id)
$ LAYER1="sha256:$(sha256sum blobs/sha256/111... | cut -d' ' -f1)"
$ LAYER2="sha256:$(sha256sum blobs/sha256/222... | cut -d' ' -f1)"
$ CHAIN2="sha256:$(echo -n "${LAYER1} ${LAYER2}" | sha256sum | cut -d' ' -f1)"
# chain ID로 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/ 에 저장
# 이미지 레이어 총 디스크 사용량 분석
$ docker system df -v
Images: 15 total 1.23GB (shared: 890MB) # 실제 사용 = total - shared
Containers: 8 total running: 3
Volumes: 5 total 250MB
# skopeo로 레지스트리에서 이미지 정보 직접 조회 (pull 없이)
$ skopeo inspect docker://nginx:latest | python3 -m json.tool | grep -A3 Layers
BuildKit 캐시 레이어 최적화
# 레이어 캐시 최적화 순서 (변경 빈도 낮은 것 먼저)
FROM node:18-alpine AS base
# 1. 패키지 정보만 먼저 복사 (캐시 히트 가능성 높음)
COPY package*.json ./
RUN npm ci --only=production # package.json 변경 시에만 재빌드
# 2. 소스 코드 복사 (자주 변경됨 → 마지막)
COPY . .
# Multi-stage build: 빌드 도구를 최종 이미지에서 제거
FROM node:18-alpine AS builder
COPY --from=base /app/node_modules ./node_modules
RUN npm run build
FROM node:18-alpine
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
볼륨과 bind mount 커널 구현
Docker 볼륨과 bind mount는 커널의 mount(MS_BIND) syscall로 구현됩니다. 볼륨은 Docker가 관리하는 bind mount이며, 컨테이너의 OverlayFS와 독립적으로 동작하여 컨테이너 삭제 후에도 데이터가 유지됩니다.
볼륨 드라이버 플러그인 아키텍처
# 볼륨 플러그인 목록 확인
$ docker volume ls
DRIVER VOLUME NAME
local mydata # 기본 local 드라이버
overlay2 overlay-vol # OverlayFS 기반
# 볼륨 상세 정보 (마운트 포인트 확인)
$ docker volume inspect mydata
[{
"Driver": "local",
"Mountpoint": "/var/lib/docker/volumes/mydata/_data",
"Options": {},
"Scope": "local"
}]
# 볼륨 생성 시 커널 경로
# docker volume create → dockerd → mkdir /var/lib/docker/volumes/mydata/_data
# docker run -v mydata:/app → runc → mount("/var/.../mydata/_data", "/app", MS_BIND)
# tmpfs 볼륨: 메모리 기반 (민감 데이터용)
$ docker run --tmpfs /run:rw,size=100m,mode=1777 \
--tmpfs /tmp:exec,size=50m nginx
# mount("tmpfs", "/run", "tmpfs", MS_NOSUID|MS_NODEV, "size=104857600,mode=1777")
bind mount 커널 경로
/* fs/namespace.c — bind mount 처리 */
static int do_loopback(struct path *path, const char *old_name,
int recurse)
{
struct path old_path;
struct mount *mnt = NULL, *old = real_mount(old_path.mnt);
if (recurse)
mnt = copy_tree(old, old_path.dentry, ...); /* --recursive: 하위 마운트 포함 */
else
mnt = clone_mnt(old, old_path.dentry, ...); /* 단순 bind */
attach_recursive_mnt(mnt, path, ...);
}
마운트 전파 옵션 (Mount Propagation)
| 전파 옵션 | syscall 플래그 | 동작 | Docker 기본값 |
|---|---|---|---|
| shared | MS_SHARED | 호스트↔컨테이너 양방향 전파 | 아니오 |
| slave | MS_SLAVE | 호스트→컨테이너 단방향 전파 | 아니오 |
| private | MS_PRIVATE | 전파 없음 (독립) | 네임드 볼륨 |
| rprivate | MS_PRIVATE + recursive | 하위 마운트도 전파 없음 | bind mount 기본 |
# bind mount 사용 예
$ docker run -v /host/data:/container/data:ro nginx
# 내부적으로: mount("/host/data", "/container/data", "", MS_BIND|MS_RDONLY, "")
# 네임드 볼륨 (Docker 관리)
$ docker volume create mydata
$ docker run -v mydata:/app/data nginx
# Docker가 /var/lib/docker/volumes/mydata/_data/ 생성
# 내부적으로: mount("/var/lib/docker/volumes/mydata/_data", "/app/data", "", MS_BIND, "")
# tmpfs 마운트
$ docker run --tmpfs /run:size=100m,exec nginx
# mount("tmpfs", "/run", "tmpfs", MS_NODEV, "size=104857600")
# 마운트 전파: 호스트에서 마운트한 것이 컨테이너에도 반영
$ docker run -v /host/data:/container/data:shared nginx
# 호스트에서 /host/data에 새 마운트 시 컨테이너에도 보임
pivot_root() — rootfs 교체의 커널 경로
pivot_root(new_root, put_old)는 현재 마운트 네임스페이스의 루트 파일시스템을 new_root로 교체하고, 이전 루트를 put_old로 이동시키는 syscall입니다. chroot()와 달리 마운트 네임스페이스와 함께 사용하면 이전 루트로 돌아갈 수 없어 완전한 격리가 가능합니다.
pivot_root() vs chroot() 보안 비교
| 항목 | chroot() | pivot_root() |
|---|---|---|
| 이전 루트 접근 | 가능 (파일 디스크립터로 탈출) | 불가 (umount 후 완전 차단) |
| 마운트 네임스페이스 | 불필요 | CLONE_NEWNS 필수 |
| 격리 수준 | 낮음 (CAP_SYS_CHROOT로 우회) | 높음 (마운트 구조 완전 변경) |
| 사용처 | 레거시 chroot 감옥 (취약) | Docker/OCI 컨테이너 (표준) |
| 커널 코드 | fs/open.c:ksys_chroot() | fs/namespace.c:sys_pivot_root() |
fs/namespace.c — sys_pivot_root() 코드
/* fs/namespace.c: pivot_root syscall 구현 */
int pivot_root(const char __user *new_root, const char __user *put_old)
{
struct path new, old, parent_path, root_parent;
/* 기본 유효성 검사 */
if (!may_mount())
return -EPERM; /* CAP_SYS_ADMIN 또는 마운트 NS 소유자 */
user_path_at(AT_FDCWD, new_root, ..., &new);
user_path_at(AT_FDCWD, put_old, ..., &old);
/* new_root는 마운트 포인트여야 함 */
if (!path_is_mountpoint(&new))
return -EINVAL;
/* put_old는 new_root 아래에 있어야 함 */
if (!is_path_reachable(real_mount(old.mnt), old.dentry, &new))
return -EINVAL;
/* 마운트 트리 교체: 현재 루트 → put_old, new_root → 현재 루트 */
detach_mnt(new_mnt, &parent_path);
detach_mnt(root_mnt, &root_parent);
attach_mnt(root_mnt, real_mount(old.mnt), old.dentry); /* 이전 루트 → put_old */
attach_mnt(new_mnt, real_mount(root_parent.mnt), root_parent.dentry); /* new → / */
return 0;
}
runc의 pivot_root 구현 (libcontainer/rootfs_linux.go)
// libcontainer/rootfs_linux.go
func pivotRoot(rootfs string) error {
// 1. rootfs를 스스로에게 bind mount (마운트 포인트로 만들기)
if err := mount(rootfs, rootfs, "", unix.MS_BIND|unix.MS_REC, ""); err != nil {
return err
}
// 2. put_old 디렉토리 생성
pivotDir := filepath.Join(rootfs, ".pivot_root")
os.Mkdir(pivotDir, 0700)
// 3. pivot_root syscall
if err := unix.PivotRoot(rootfs, pivotDir); err != nil {
return fmt.Errorf("pivot_root %s: %w", rootfs, err)
}
// 4. 새 루트로 이동
if err := unix.Chdir("/"); err != nil {
return err
}
// 5. 이전 루트 언마운트 (lazy: 이미 열린 파일은 유지)
pivotDir = filepath.Join("/", ".pivot_root")
if err := unix.Unmount(pivotDir, unix.MNT_DETACH); err != nil {
return err
}
return os.Remove(pivotDir)
}
컨테이너 네트워킹
컨테이너 네트워킹은 network namespace로 격리된 네트워크 스택(Network Stack)을 veth 페어와 브릿지로 연결하는 구조가 기본입니다.
veth + bridge 모델 (Docker 기본)
# Docker 브릿지 네트워크 확인
$ ip link show type bridge # docker0 확인
$ bridge link show # bridge에 연결된 veth 목록
$ ip netns list # 네트워크 네임스페이스 목록
# 수동 veth + bridge 구성 상세: network-namespaces.html#veth-setup 참조
veth_xmit, dev_change_net_namespace), macvlan/ipvlan 비교, 성능 벤치마크는 Network Namespace — veth 쌍으로 네임스페이스 연결에서 다룹니다.
Docker veth/bridge/netfilter 네트워킹 내부
Docker 기본 네트워킹은 veth 쌍 + docker0 브리지(Bridge) + iptables MASQUERADE로 구현됩니다. 패킷(Packet)이 컨테이너에서 인터넷으로 나가는 전체 경로를 추적합니다.
# Docker 네트워킹 설정 확인
$ ip link show type bridge
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
link/ether 02:42:xx:xx:xx:xx brd ff:ff:ff:ff:ff:ff
$ ip addr show docker0
4: docker0: inet 172.17.0.1/16
# 컨테이너의 veth 확인
$ ip link show type veth
5: vethAbc123@if2: <BROADCAST,MULTICAST,UP,LOWER_UP>
link/ether xx:xx:xx:xx:xx:xx brd ff:ff:ff:ff:ff:ff
master docker0
# iptables MASQUERADE 규칙 확인
$ iptables -t nat -L POSTROUTING -n -v
Chain POSTROUTING (policy ACCEPT)
target prot source destination
MASQUERADE all 172.17.0.0/16 !172.17.0.0/16
# 컨테이너 내부 네트워크 확인
$ docker exec -it <id> ip route
default via 172.17.0.1 dev eth0
172.17.0.0/16 dev eth0 proto kernel
Docker iptables/nftables 체인 구조
# Docker가 생성하는 iptables 체인 전체 구조
$ iptables -t filter -L -n --line-numbers
Chain FORWARD (policy DROP)
1 DOCKER-USER # 사용자 정의 규칙 (우선순위 최상위)
2 DOCKER-ISOLATION-STAGE-1 # 브리지 간 격리 1단계
3 ACCEPT ...if RELATED,ESTABLISHED
4 DOCKER # 컨테이너 포트 포워딩 규칙
5 ACCEPT ... docker0
6 DOCKER-ISOLATION-STAGE-2 # 브리지 간 격리 2단계
# 컨테이너 간 통신 격리: docker0 → 다른 브리지 금지
Chain DOCKER-ISOLATION-STAGE-1
1 DOCKER-ISOLATION-STAGE-2 # docker0에서 나가는 패킷
2 RETURN
Chain DOCKER-ISOLATION-STAGE-2
1 DROP # docker0 → 다른 브리지 (br-xxx) 차단
2 RETURN
# Docker-USER 체인: 사용자 방화벽 규칙 추가 위치
$ iptables -I DOCKER-USER -s 10.0.0.0/8 -j DROP # 특정 IP 차단
$ iptables -I DOCKER-USER -j RETURN # 모든 트래픽 허용
# nftables 환경에서 Docker (iptables-nft 모드)
$ nft list ruleset | grep DOCKER
# Docker는 nftables를 직접 지원하지 않고 iptables-nft 래퍼 사용
# nftables로 완전 전환 시: iptables-legacy-save → iptables-nft-restore 필요
# conntrack으로 컨테이너 연결 상태 확인
$ conntrack -L -s 172.17.0.2 -p tcp
tcp 6 431947 ESTABLISHED src=172.17.0.2 dst=1.1.1.1 sport=54321 dport=443
src=1.1.1.1 dst=192.168.1.100 sport=443 dport=54321 [ASSURED]
# MASQUERADE로 src IP가 호스트 eth0 IP로 변환됨
CNI (Container Network Interface)
CNI는 CNCF 표준으로, 컨테이너 네트워크 설정을 플러그인 체인으로 구성합니다. Kubernetes, Podman, CRI-O가 CNI를 사용합니다.
// CNI 네트워크 설정 예시 (/etc/cni/net.d/10-bridge.conflist)
{
"cniVersion": "1.0.0",
"name": "mynet",
"plugins": [
{
"type": "bridge",
"bridge": "cni0",
"isGateway": true,
"ipam": {
"type": "host-local",
"subnet": "10.22.0.0/16",
"routes": [{ "dst": "0.0.0.0/0" }]
}
},
{ "type": "loopback" },
{
"type": "portmap",
"capabilities": { "portMappings": true }
}
]
}
macvlan / ipvlan 모드
| 모드 | 동작 | 사용 사례 |
|---|---|---|
| macvlan bridge | 각 컨테이너에 고유 MAC, 물리 NIC 서브인터페이스 | 외부 네트워크 직접 연결 |
| ipvlan L2 | MAC 공유, L2 수준 분리 | MAC 수 제한 환경 |
| ipvlan L3 | MAC 공유, L3 라우팅(Routing) 기반 분리 | 대규모 컨테이너 환경 |
Docker 네트워크 드라이버
Docker는 플러그인 아키텍처로 여러 네트워크 드라이버를 제공합니다. 각 드라이버는 커널 수준에서 서로 다른 구현을 사용하며, 사용 목적에 따라 적절한 드라이버를 선택해야 합니다.
bridge 드라이버 — 커널 구현
# bridge 네트워크 생성 및 커널 레벨 확인
$ docker network create --driver bridge --subnet 172.20.0.0/16 mynet
# 생성된 브리지 확인
$ ip link show type bridge
br-abc123: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
link/ether 02:42:xx:xx:xx:xx brd ff:ff:ff:ff:ff:ff
# 컨테이너 연결 시 veth pair 자동 생성
$ docker run --network=mynet -d nginx
$ ip link show type veth
vethXXXXXX@if5: master br-abc123 # docker0 브리지에 연결됨
# iptables DOCKER 체인 확인
$ iptables -t nat -L DOCKER -n --line-numbers
1 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.20.0.2:80
macvlan / ipvlan — L2/L3 직통 연결
# macvlan: 컨테이너에 물리 NIC와 같은 서브넷의 IP 직접 할당
$ docker network create -d macvlan \
--subnet=192.168.1.0/24 \
--gateway=192.168.1.1 \
-o parent=eth0 \ # 연결할 물리 NIC
macvlan-net
$ docker run --network=macvlan-net --ip=192.168.1.100 -d nginx
# 컨테이너가 192.168.1.100으로 직접 접근 가능 (NAT 없음)
# ipvlan L2 모드: 같은 MAC 주소 공유 (스위치가 promiscuous 모드 불필요)
$ docker network create -d ipvlan \
--subnet=192.168.1.0/24 \
-o parent=eth0 -o ipvlan_mode=l2 \
ipvlan-l2-net
# ipvlan L3 모드: 라우팅 기반 (다른 서브넷도 가능)
$ docker network create -d ipvlan \
--subnet=10.10.0.0/24 \
-o parent=eth0 -o ipvlan_mode=l3 \
ipvlan-l3-net
overlay 드라이버 — VXLAN 커널 구현
# Docker Swarm overlay 네트워크
$ docker swarm init
$ docker network create -d overlay --attachable swarm-net
# VXLAN 터널 인터페이스 확인
$ ip link show type vxlan
vxlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450
link/ether ...: UDP port 4789
# 커널 VXLAN 통계
$ cat /proc/net/dev | grep vxlan
# VXLAN 패킷: L2 프레임 → UDP/IP 인캡슐 → 물리 네트워크 → 디캡슐 → 원격 컨테이너
# FDB(Forwarding Database) 확인 — VTEP 학습
$ bridge fdb show dev vxlan0
00:00:00:00:00:00 dst 192.168.1.2 self permanent # 원격 호스트 VTEP
Rootless 컨테이너
Rootless 컨테이너는 User namespace를 활용하여 일반 사용자(non-root)가 컨테이너를 실행하는 기술입니다. 호스트의 root 권한 없이 컨테이너 내부에서 "가짜 root"로 동작합니다.
UID/GID 매핑
# /etc/subuid — 사용자별 subordinate UID 범위
user1:100000:65536 # user1에게 UID 100000-165535 할당
# /etc/subgid — 사용자별 subordinate GID 범위
user1:100000:65536 # user1에게 GID 100000-165535 할당
# 매핑: 컨테이너 내 root(UID 0) → 호스트 user1의 subuid(100000)
# 컨테이너 내 UID 1 → 호스트 100001
# ...
# 컨테이너 내 UID 65535 → 호스트 165535
/* 커널 User namespace UID 매핑 (/proc/<pid>/uid_map 형식) */
/* container_uid host_uid count */
0 100000 65536
/* newuidmap/newgidmap 명령으로 설정 */
$ newuidmap <pid> 0 100000 65536
$ newgidmap <pid> 0 100000 65536
Rootless 네트워킹
rootless 환경에서는 veth 페어를 생성할 수 없으므로(CAP_NET_ADMIN 부재), 유저스페이스 네트워크 스택을 사용합니다:
| 구현 | 방식 | 성능 |
|---|---|---|
| slirp4netns | libslirp 기반 TAP 디바이스 에뮬레이션 | 보통 (유저스페이스 TCP/IP 스택) |
| pasta | 호스트와 NS 간 패킷 복사 (splice) | 빠름 (Linux 5.7+) |
| RootlessKit | slirp4netns/pasta 래퍼 | 구현에 따라 다름 |
Rootless OverlayFS
Linux 5.11 이전에는 OverlayFS 마운트에 CAP_SYS_ADMIN이 필요했습니다. 커널 5.11부터 User namespace 내부에서 OverlayFS 마운트가 가능해져, FUSE 기반 fuse-overlayfs 없이도 rootless 컨테이너의 이미지 레이어를 직접 사용할 수 있습니다.
네이티브 rootless OverlayFS는 userxattr 마운트 옵션을 자동으로 사용합니다. 이 옵션은 trusted.overlay.* 대신 user.overlay.* 네임스페이스에 메타데이터를 저장하여, 권한 없는 사용자도 xattr을 관리할 수 있게 합니다.
# unshare로 User namespace + Mount namespace 생성 후 OverlayFS 마운트
$ unshare -Urm
# (이제 User NS 내부에서 root 권한)
$ mkdir -p /tmp/lower /tmp/upper /tmp/work /tmp/merged
$ mount -t overlay overlay \
-o lowerdir=/tmp/lower,upperdir=/tmp/upper,workdir=/tmp/work,userxattr \
/tmp/merged
# 커널 5.11+ 확인
$ uname -r
5.11.0-generic
$ cat /proc/mounts | grep overlay
overlay /tmp/merged overlay rw,lowerdir=/tmp/lower,upperdir=/tmp/upper,workdir=/tmp/work,userxattr 0 0
커널 5.11 미만이거나 특수한 파일시스템(NFS, FUSE 자체)에서는 fuse-overlayfs가 폴백으로 사용됩니다:
| 구현 | 커널 요구 | 성능 (상대) | 제약 |
|---|---|---|---|
| Native OverlayFS | 5.11+ | 100% (커널 레벨) | upperdir/lowerdir가 같은 FS 타입 권장 |
| fuse-overlayfs | 4.18+ (FUSE) | ~60-80% | 유저스페이스 오버헤드(Overhead), context switch 비용 |
Podman과 Buildah는 런타임에 커널 버전을 감지하여 자동으로 적합한 드라이버를 선택합니다:
# Podman 스토리지 드라이버 확인
$ podman info --format '{{.Store.GraphDriverName}}'
overlay # 커널 5.11+ → native overlay
# 설정 파일에서 직접 제어 (~/.config/containers/storage.conf)
[storage]
driver = "overlay"
[storage.options.overlay]
mount_program = "/usr/bin/fuse-overlayfs" # 강제 FUSE 사용 시
/proc/sys/kernel/unprivileged_userns_clone과 실제 mount(2) 시도를 통해 native overlay 지원 여부를 판별합니다. 별도 설정 없이 최적의 드라이버가 자동 선택되므로, 대부분의 경우 수동 설정은 불필요합니다.Rootless Docker — newuidmap 커널 경로
Rootless Docker는 root 권한 없이 컨테이너를 실행합니다. 핵심은 user namespace + newuidmap/newgidmap을 통한 UID 매핑입니다.
# Rootless Docker 설정 확인
$ cat /etc/subuid
alice:100000:65536 # alice 사용자가 100000부터 65536개 UID 사용 가능
$ cat /etc/subgid
alice:100000:65536
# rootless dockerd 실행 (비root 사용자로)
$ dockerd-rootless.sh &
# 컨테이너 내 root가 실제로는 호스트 alice UID임을 확인
$ docker run --rm alpine id
uid=0(root) gid=0(root) # 컨테이너 내 뷰
$ ps aux | grep sh
alice 12345 ... # 호스트에서는 alice UID로 실행
# uid_map 직접 확인
$ cat /proc/12345/uid_map
0 1000 1 # 컨테이너 UID 0 = 호스트 UID 1000
1 100000 65535 # 컨테이너 UID 1-65535 = 호스트 100000-165534
# newuidmap suid 바이너리 확인
$ ls -la /usr/bin/newuidmap
-rwsr-xr-x 1 root root ... /usr/bin/newuidmap # SUID 비트 필수
Rootless Docker 네트워킹 — rootlesskit 슬레이브 프로세스
# Rootless Docker 네트워킹 구현 방식 확인
$ ps aux | grep -E 'rootlesskit|slirp|vpnkit'
alice 5000 /usr/bin/rootlesskit --port-driver=slirp4netns ...
alice 5001 /usr/bin/slirp4netns --configure --mtu=65520 ... 5000 tap0
# slirp4netns 방식 (기본): 사용자 공간 TCP/IP 스택
# - User NS 내부에서 tap0 인터페이스 생성
# - 호스트에서 slirp4netns가 user-mode networking 처리
# - 성능: 호스트 bridge 대비 낮음 (사용자 공간 TCP/IP 오버헤드)
# bypass4netns 방식 (고성능): eBPF + seccomp_unotify 활용
# - connect()/bind() syscall을 seccomp_unotify로 인터셉트
# - 컨테이너 내부 connect() → 호스트 NS의 실제 소켓으로 리다이렉트
# - 성능: 호스트 bridge와 동등 (커널 패스 유지)
$ docker run --annotation run.oci.keep_original_groups=1 \
--net host myapp # host 모드 + Rootless (bypass4netns 필요)
# 포트 포워딩 확인 (rootlesskit이 처리)
$ docker run -d -p 8080:80 nginx
$ ss -tlnp | grep 8080
LISTEN 0 128 0.0.0.0:8080 # rootlesskit이 호스트에서 바인드
# 내부: rootlesskit → slirp4netns tap → 컨테이너 eth0:80
Rootless 제한사항
- 특권 포트 — 1024 미만 포트 바인딩 불가 (
sysctl net.ipv4.ip_unprivileged_port_start로 조정 가능) - ping — CAP_NET_RAW 부재로 ICMP 소켓 생성 불가 (
sysctl net.ipv4.ping_group_range로 허용 가능) - cgroup — cgroup v2 + systemd 기반 위임(delegation)이 필요
- NFS — NFS 위의 rootless overlayfs 미지원 (대부분의 환경)
컨테이너 보안
보안 계층 종합
| 계층 | 기술 | 보호 대상 |
|---|---|---|
| 프로세스 격리 | Namespaces | PID/네트워크/마운트/사용자 가시성 |
| 리소스 제한 | cgroups | CPU, 메모리, I/O, PID 수 |
| 시스템 콜 필터 | seccomp-bpf | 커널 공격 표면 축소 |
| 권한 최소화 | Capabilities | root 권한 세분화 |
| MAC | AppArmor / SELinux | 파일/네트워크/프로세스 접근 제어(Access Control) |
| 읽기 전용 rootfs | OverlayFS + readonly | 파일시스템 무결성(Integrity) |
| No New Privileges | PR_SET_NO_NEW_PRIVS | setuid 바이너리를 통한 권한 상승 |
AppArmor 컨테이너 프로파일
# Docker 기본 AppArmor 프로파일 (docker-default) 핵심 규칙
profile docker-default flags=(attach_disconnected,mediate_deleted) {
# 기본적으로 대부분의 파일 접근 허용
file,
network,
capability,
# 명시적 deny 규칙
deny mount, # 마운트 차단
deny /sys/firmware/** rwklx, # 펌웨어 접근 차단
deny /sys/kernel/security/** rwklx, # LSM 인터페이스 접근 차단
deny /proc/sys/** wklx, # 커널 파라미터 쓰기 차단
deny /proc/sysrq-trigger rwklx, # SysRq 차단
deny /proc/kcore rwklx, # 커널 메모리 접근 차단
}
SELinux 컨테이너 정책
| SELinux 타입 | 설명 |
|---|---|
container_t | 일반 컨테이너 프로세스 타입 |
container_file_t | 컨테이너 파일시스템 타입 |
container_var_lib_t | /var/lib/containers 하위 |
spc_t | 슈퍼 특권 컨테이너 (--privileged) |
# SELinux 컨테이너 컨텍스트 확인
$ ps -eZ | grep container
system_u:system_r:container_t:s0:c123,c456 ... nginx
# 볼륨 마운트 시 레이블 지정
$ podman run -v /data:/data:Z alpine # :Z = private 레이블 재지정
$ podman run -v /data:/data:z alpine # :z = shared 레이블 재지정
Docker seccomp-bpf 프로필 실전
Docker는 기본적으로 seccomp-bpf 필터를 적용하여 컨테이너가 호출할 수 있는 syscall을 제한합니다.
Docker 기본 seccomp 프로필
// /etc/docker/seccomp-default.json (간략화)
{
"defaultAction": "SCMP_ACT_ERRNO", // 목록에 없는 syscall: EPERM 반환
"architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32"],
"syscalls": [
{
"names": ["accept", "accept4", "access", "bind", "brk",
"capget", "capset", "chdir", "chmod", "chown",
"clone", "close", "connect", "creat", ...],
"action": "SCMP_ACT_ALLOW" // ~300개 syscall 허용
},
{
"names": ["ptrace"],
"action": "SCMP_ACT_ALLOW",
"includes": { "minKernel": "4.8" } // 커널 버전 조건
}
]
}
// 차단되는 주요 syscall: keyctl, kexec_load, mount (비root), swapon,
// create_module, delete_module, init_module, nfsservctl,
// open_by_handle_at, perf_event_open, pivot_root, reboot...
커널 seccomp BPF 프로그램 구조
/* seccomp BPF 프로그램: Docker가 생성하는 구조 */
struct sock_filter filter[] = {
/* 아키텍처 확인 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
/* syscall 번호 로드 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
/* 허용 목록 확인 (syscall 번호별 점프) */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
/* ... 모든 허용 syscall에 대해 반복 ... */
/* 기본 동작: 거부 */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)),
};
/* prctl로 필터 설치 (runc가 컨테이너 exec 전에 수행) */
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
커스텀 seccomp 프로필 작성
# 특정 syscall 추가 차단 (strace 방지)
$ cat custom-seccomp.json
{
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [
{ "names": ["ptrace", "strace"], "action": "SCMP_ACT_ERRNO" }
// ... 나머지는 기본 프로필 상속
]
}
$ docker run --security-opt seccomp=custom-seccomp.json nginx
# seccomp 완전 비활성화 (디버깅 전용)
$ docker run --security-opt seccomp=unconfined nginx
# 현재 컨테이너의 seccomp 상태 확인
$ docker inspect <id> | grep -A5 SeccompProfile
$ cat /proc/<pid>/status | grep Seccomp
Seccomp: 2 # 0=off, 1=strict, 2=filter(BPF)
SCMP_ACT_LOG — 차단 없이 감사 로그
// SCMP_ACT_LOG: 개발 시 프로필 작성 보조 — 차단 없이 audit log만 기록
{
"defaultAction": "SCMP_ACT_LOG", // 모든 syscall 허용 + syslog/audit 기록
"syscalls": [
{
"names": ["kexec_load", "reboot", "swapon"],
"action": "SCMP_ACT_ERRNO" // 이것들만 실제 차단
}
]
}
# SCMP_ACT_LOG 사용: 실제 차단 없이 컨테이너가 어떤 syscall을 사용하는지 로그
$ docker run --security-opt seccomp=log-only.json myapp
$ dmesg | grep audit | grep SECCOMP | head -20
# audit: type=1326 syscall=319 (memfd_create) — 허용됨 + 로그
# 이를 바탕으로 필요한 syscall 목록 파악 → 실제 프로필 작성
seccomp_unotify — 사용자 공간(User Space) syscall 인터셉트 (Linux 5.0+)
/* seccomp SECCOMP_RET_USER_NOTIF: syscall을 사용자 공간에서 처리 */
/* 1단계: 슈퍼바이저가 SECCOMP_RET_USER_NOTIF로 필터 설치 */
struct sock_filter filter[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mount, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_USER_NOTIF), /* mount()를 사용자 공간으로 전달 */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
/* 2단계: 슈퍼바이저가 fd를 통해 알림 수신 및 응답 */
struct seccomp_notif req;
struct seccomp_notif_resp resp;
ioctl(notify_fd, SECCOMP_IOCTL_NOTIF_RECV, &req);
/* req.data.args[0..5]: mount() 인자 검사 */
/* 안전한 경우: 허용 / 위험한 경우: EPERM 반환 */
resp.id = req.id;
resp.val = 0;
resp.error = 0; /* 0 = 허용, -EPERM = 거부 */
ioctl(notify_fd, SECCOMP_IOCTL_NOTIF_SEND, &resp);
/* 활용: Rootless 컨테이너에서 mount() 같은 특권 syscall을
슈퍼바이저가 안전하게 에뮬레이션 (slirp4netns, bypass4netns 등에서 활용) */
# 커널이 지원하는 seccomp 액션 확인
$ python3 -c "
import ctypes, struct
SECCOMP_GET_ACTION_AVAIL = 2
actions = {
'SCMP_ACT_KILL_PROCESS': 0x80000000,
'SCMP_ACT_KILL_THREAD': 0x00000000,
'SCMP_ACT_TRAP': 0x00030000,
'SCMP_ACT_ERRNO': 0x00050000,
'SCMP_ACT_USER_NOTIF': 0x7fc00000,
'SCMP_ACT_LOG': 0x7ffc0000,
'SCMP_ACT_ALLOW': 0x7fff0000,
}
libc = ctypes.CDLL(None)
for name, action in actions.items():
buf = struct.pack('I', action)
ret = libc.prctl(22, SECCOMP_GET_ACTION_AVAIL, buf, 0, 0)
print(f'{name}: {\"supported\" if ret == 0 else \"not supported\"}')
"
Docker Capabilities 실전 — 권한 모델
Linux capabilities는 전통적인 루트 권한(ALL-or-NOTHING)을 37개의 독립적인 권한으로 세분화한 시스템입니다. Docker는 컨테이너 시작 시 최소 권한 원칙에 따라 안전한 기본값을 설정합니다.
Docker 기본 허용 Capabilities (14개)
| Capability | 설명 | docker run 옵션 |
|---|---|---|
CAP_CHOWN | 파일 소유자/그룹 변경 | 기본 허용 |
CAP_DAC_OVERRIDE | 파일 읽기/쓰기/실행 권한 우회 | 기본 허용 |
CAP_FSETID | setuid/setgid 비트 설정 | 기본 허용 |
CAP_FOWNER | 소유자 확인 우회 | 기본 허용 |
CAP_MKNOD | 특수 파일(device) 생성 | 기본 허용 |
CAP_NET_RAW | RAW/PACKET 소켓 사용 (ping 등) | 기본 허용 |
CAP_SETGID | 프로세스 GID 변경 | 기본 허용 |
CAP_SETUID | 프로세스 UID 변경 | 기본 허용 |
CAP_SETFCAP | 파일 capabilities 설정 | 기본 허용 |
CAP_SETPCAP | 허용 집합에서 상속 집합으로 capability 이동 | 기본 허용 |
CAP_NET_BIND_SERVICE | 1024 미만 포트 바인드 | 기본 허용 |
CAP_SYS_CHROOT | chroot() syscall 사용 | 기본 허용 |
CAP_KILL | 다른 UID 프로세스에 시그널(Signal) 전송 | 기본 허용 |
CAP_AUDIT_WRITE | 커널 audit 로그에 쓰기 | 기본 허용 |
Docker 기본 차단 Capabilities (위험 기능)
| Capability | 위험 이유 | 필요 시 추가 방법 |
|---|---|---|
CAP_SYS_ADMIN | mount/umount, ptrace, 네임스페이스 생성 등 광범위한 권한 | --cap-add SYS_ADMIN |
CAP_SYS_PTRACE | 다른 프로세스 메모리 읽기/쓰기 (컨테이너 탈출 위험) | --cap-add SYS_PTRACE |
CAP_NET_ADMIN | 네트워크 설정 변경, iptables 수정 | --cap-add NET_ADMIN |
CAP_SYS_MODULE | 커널 모듈(Kernel Module) 로드/언로드 (호스트 커널 조작) | 보안 위험 — 사용 자제 |
CAP_SYS_BOOT | reboot(), kexec_load() | 보안 위험 — 사용 자제 |
CAP_SYS_RAWIO | I/O 포트 직접 접근 (iopl/ioperm) | 보안 위험 — 사용 자제 |
kernel/capability.c — cap_bounding set 적용 경로
/* kernel/capability.c: capability 검사 경로 */
bool has_capability(struct task_struct *t, int cap)
{
return has_ns_capability(t, &init_user_ns, cap);
}
bool has_ns_capability(struct task_struct *t,
struct user_namespace *ns, int cap)
{
const struct cred *cred;
bool ret;
rcu_read_lock();
cred = __task_cred(t);
ret = security_capable(cred, ns, cap, CAP_OPT_NONE);
rcu_read_unlock();
return ret == 0;
}
/* cap_bounding: 프로세스가 가질 수 있는 최대 capability 집합 */
/* runc는 execve() 전에 prctl(PR_CAPBSET_DROP, cap)으로 차단 capability 제거 */
for (int i = 0; i < CAP_LAST_CAP; i++) {
if (!cap_raised(config.capabilities.bounding, i))
prctl(PR_CAPBSET_DROP, i); /* bounding set에서 제거 */
}
--cap-add / --cap-drop 사용법
# 기본 14개에서 CAP_SYS_PTRACE 추가 (디버깅용 strace 허용)
$ docker run --cap-add SYS_PTRACE --rm alpine strace ls
# 모든 capability 제거 후 필요한 것만 추가 (최소 권한)
$ docker run --cap-drop ALL --cap-add NET_BIND_SERVICE nginx
# --privileged: 모든 capability 허용 + 디바이스 접근 (위험)
$ docker run --privileged myapp # 절대 프로덕션 금지
# 실행 중 컨테이너의 capability 확인
$ cat /proc/$(docker inspect -f '{{.State.Pid}}' mycontainer)/status | grep Cap
CapInh: 0000000000000000
CapPrm: 00000000a80425fb # 허용 집합 (비트마스크)
CapEff: 00000000a80425fb # 유효 집합
CapBnd: 00000000a80425fb # bounding 집합
CapAmb: 0000000000000000
# capsh로 비트마스크 해석
$ capsh --decode=00000000a80425fb
0x00000000a80425fb=cap_chown,cap_dac_override,...,cap_net_bind_service,cap_net_raw
컨테이너 탈출 공격 벡터와 방어
| 공격 벡터 | 설명 | 방어 |
|---|---|---|
| /proc, /sys 남용 | 호스트 정보 유출, 커널 파라미터 조작 | read-only bind mount, masked paths |
| CAP_SYS_ADMIN | mount, BPF, namespace 생성 등 광범위 권한 | --cap-drop=ALL, 필요 cap만 추가 |
| 커널 취약점(Vulnerability) | CVE를 통한 호스트 커널 직접 공격 | seccomp 필터, 커널 업데이트, gVisor/Kata |
| Docker 소켓 마운트 | /var/run/docker.sock으로 호스트 컨테이너 제어 | 소켓 마운트 금지, rootless 사용 |
| --privileged 플래그 | 모든 보안 장치 비활성화 | 절대 사용 금지, 대안 capability 사용 |
컨테이너 보안 주요 CVE 사례
컨테이너 탈출과 권한 상승 취약점의 실제 사례를 분석합니다. 컨테이너 런타임, OverlayFS, cgroup 등 다양한 계층에서 발견된 취약점들이 컨테이너 보안 아키텍처의 발전을 이끌어왔습니다.
runc의 /proc/self/exe 처리에서 컨테이너 내부의 악성 프로세스가 호스트의 runc 바이너리를 덮어쓸 수 있는 취약점입니다. 컨테이너가 runc exec을 통해 진입될 때, 컨테이너 내 악성 /bin/sh가 /proc/self/exe 심볼릭 링크를 통해 호스트의 runc 바이너리에 접근하여 덮어씁니다. 이후 호스트에서 runc가 실행될 때마다 공격자의 코드가 root 권한으로 실행됩니다.
/* CVE-2019-5736: runc /proc/self/exe 악용 흐름 */
/*
* 공격 조건: 컨테이너 내부에서 코드 실행 가능 (이미지 포이즌 또는 RCE)
*
* 1. 컨테이너 entrypoint를 악성 바이너리로 교체
* 2. 관리자가 "docker exec <container> /bin/sh" 실행
* 3. runc가 컨테이너의 네임스페이스에 진입
* → /proc/self/exe가 호스트의 runc 바이너리를 가리킴
* 4. 악성 프로세스가 /proc/self/exe를 O_WRONLY로 열기 시도
* → runc 바이너리가 실행 중이므로 ETXTBSY 반환
* 5. runc가 execve()로 /bin/sh를 실행한 후,
* /proc/<runc_pid>/exe → 호스트 runc 바이너리
* → 이제 ETXTBSY 없이 쓰기 가능
* 6. 호스트의 runc 바이너리가 악성 코드로 덮어써짐
*
* 수정: runc가 자신의 바이너리를 memfd_create()로
* 메모리에 복사 후 실행 (호스트 파일 직접 참조 방지)
*/
cgroup v1의 release_agent 파일을 통해 컨테이너 내부에서 호스트의 root 권한으로 임의 명령을 실행할 수 있습니다. 컨테이너가 CAP_SYS_ADMIN + cgroup 마운트 권한을 가진 경우, release_agent에 호스트 경로의 스크립트를 지정하고 cgroup을 비우면 해당 스크립트가 호스트에서 실행됩니다.
/* CVE-2022-0492 공격 흐름 (CAP_SYS_ADMIN 필요) */
# 1. 컨테이너 내부에서 cgroup v1 마운트
mkdir /tmp/cgrp && mount -t cgroup -o rdma cgroup /tmp/cgrp
# 2. release_agent에 호스트에서 실행할 명령 지정
echo /path/on/host/exploit.sh > /tmp/cgrp/release_agent
# 3. notify_on_release 활성화
mkdir /tmp/cgrp/x && echo 1 > /tmp/cgrp/x/notify_on_release
# 4. cgroup을 비우면 release_agent가 호스트에서 실행됨
sh -c "echo \$\$ > /tmp/cgrp/x/cgroup.procs" && sleep 1
/*
* 방어:
* 1. --cap-drop=ALL (CAP_SYS_ADMIN 제거)
* 2. seccomp 프로파일에서 mount, unshare 시스콜 차단
* 3. AppArmor/SELinux로 cgroupfs 마운트 거부
* 4. cgroup v2 사용 (release_agent 메커니즘 없음)
* 5. 커널 5.17+에서 cgroup namespace 내 release_agent 쓰기 차단
*/
OverlayFS의 lower layer에 FUSE 파일시스템을 마운트하면, FUSE에서 setuid 비트가 설정된 파일을 제공할 수 있습니다. OverlayFS가 이 파일을 upper layer로 copy-up할 때 setuid 비트가 유지되어, 일반 사용자가 user namespace 내에서 root 소유의 setuid 바이너리를 생성할 수 있습니다.
runc 1.1.11 이전 버전에서 WORKDIR 지시문이 /proc/self/fd/<fd>와 같은 경로로 설정된 경우, 내부 파일 디스크립터 누수를 통해 호스트 파일시스템에 접근할 수 있습니다. 컨테이너 이미지 빌드 시 또는 runc exec --cwd 실행 시 트리거됩니다.
/* CVE-2024-21626: 파일 디스크립터 누수를 통한 탈출 */
/*
* runc가 컨테이너 초기화 중 일부 fd를 닫지 않아
* 호스트 파일시스템의 fd가 컨테이너 내부로 누수
*
* 공격: WORKDIR=/proc/self/fd/7 (호스트 / 를 가리키는 fd)
* → 컨테이너 내부에서 호스트 루트 파일시스템 접근 가능
*
* 수정: runc 초기화 시 불필요한 fd를 O_CLOEXEC으로 닫고,
* /proc/self/fd 순회하여 예상치 못한 fd 정리
*/
/* 컨테이너 보안 점검 체크리스트 */
# 1. 런타임 버전 확인
$ runc --version # 1.1.12+ 필수
$ containerd --version
# 2. 컨테이너 capability 확인
$ docker inspect --format '{{.HostConfig.CapAdd}}' <container>
# SYS_ADMIN, SYS_PTRACE, NET_ADMIN 등 불필요 cap 제거
# 3. seccomp 프로파일 확인
$ docker inspect --format '{{.HostConfig.SecurityOpt}}' <container>
# 4. AppArmor/SELinux 상태 확인
$ aa-status # AppArmor
$ getenforce # SELinux
2019: CVE-2019-5736 (runc 탈출) → memfd 기반 self-clone 도입
2020: CVE-2020-15257 (containerd shim) → abstract socket → filesystem socket 전환
2022: CVE-2022-0185 (fsconfig 힙 오버플로우), CVE-2022-0492 (cgroup v1 release_agent) → cgroup v2 전환 가속화
2023: CVE-2023-0386 (OverlayFS setuid) → OverlayFS inode 검증 강화
2024: CVE-2024-21626 (runc fd 누수) → fd 정리 로직 강화
핵심 교훈: 컨테이너 격리의 각 계층(런타임, 커널 서브시스템, 파일시스템)에서 독립적인 취약점이 발견될 수 있으므로, 심층 방어(defense-in-depth) 원칙 적용이 필수적입니다.
gVisor / Kata Containers: 강화 격리
기본 컨테이너는 호스트 커널을 공유하므로 커널 취약점에 노출됩니다. 이를 해결하는 두 가지 접근법이 있습니다:
| 기술 | 접근법 | 오버헤드 | 호환성 |
|---|---|---|---|
| gVisor (runsc) | 유저스페이스 커널 — syscall interception | 중간 (syscall 오버헤드) | 일부 syscall 미구현 |
| Kata Containers | 경량 VM 내부에서 컨테이너 실행 | 높음 (VM 부팅 오버헤드) | 매우 높음 |
AppArmor/SELinux 컨테이너 보안
Docker는 seccomp-bpf 외에도 AppArmor(Ubuntu/Debian 계열)와 SELinux(RHEL/Fedora 계열)를 통해 추가적인 LSM(Linux Security Modules) 기반 보안을 제공합니다. 이 두 시스템은 capabilities와 seccomp와 함께 다중 보안 레이어를 형성합니다.
Docker AppArmor 프로필 해부
# Docker 기본 AppArmor 프로필 위치 및 확인
$ cat /etc/apparmor.d/docker-default # 또는 /etc/apparmor.d/docker
# 프로필 주요 규칙:
#include <tunables/global>
profile docker-default flags=(attach_disconnected,mediate_deleted) {
# 파일시스템 접근
file, # 모든 파일 읽기/쓰기 허용
deny @{PROC}/sys/kernel/** w, # /proc/sys/kernel 쓰기 금지
deny @{PROC}/sysrq-trigger rwklx, # SysRq 트리거 금지
deny @{PROC}/mem rwklx, # /proc/mem 직접 접근 금지
deny @{PROC}/kmem rwklx,
deny @{PROC}/kcore rwklx, # 커널 코어 메모리 접근 금지
# 마운트 제한
deny mount, # mount() 금지 (CAP_SYS_ADMIN 없으면 이미 제한)
# 네트워크
network, # 모든 네트워크 허용
deny network raw, # RAW 소켓 금지 (CAP_NET_RAW 없으면 이미 제한)
# capabilities
capability, # 허용된 capabilities 전부 사용 가능
deny capability mac_admin, # MAC 관리자 권한 금지
deny capability mac_override,
}
# 커스텀 AppArmor 프로필 적용
$ apparmor_parser -r -W /etc/apparmor.d/my-docker-profile
$ docker run --security-opt apparmor=my-docker-profile nginx
# AppArmor 비활성화 (개발 환경)
$ docker run --security-opt apparmor=unconfined nginx
# 컨테이너의 AppArmor 프로필 확인
$ cat /proc/$(docker inspect -f '{{.State.Pid}}' mycontainer)/attr/current
docker-default (enforce)
SELinux container_t 라벨
# RHEL/Fedora에서 SELinux + Docker 설정
$ getenforce
Enforcing
# 컨테이너 프로세스 SELinux 컨텍스트 확인
$ ps -eZ | grep container
system_u:system_r:container_t:s0:c123,c456 # container_t 도메인
# 컨테이너 파일 SELinux 라벨 확인
$ ls -laZ /var/lib/docker/overlay2/<hash>/
system_u:object_r:container_var_lib_t:s0 # container_var_lib_t 타입
# docker run --security-opt label 지정
$ docker run --security-opt label=type:container_t \
--security-opt label=level:s0:c100,c200 nginx
# SELinux denial 확인 (컨테이너가 접근 거부된 경우)
$ ausearch -m AVC -ts recent | grep container_t | head -10
type=AVC msg=audit(...): avc: denied { write } for pid=12345 comm="nginx"
scontext=system_u:system_r:container_t ...
tcontext=system_u:object_r:shadow_t ... # /etc/shadow 접근 거부
# audit2allow로 허용 정책 생성 (디버깅 용도)
$ ausearch -m AVC | audit2allow -M my-container
$ semodule -i my-container.pp
LSM 훅 경로 — seccomp + capabilities + AppArmor/SELinux 계층
/* 커널 내 보안 레이어 적용 순서 (syscall 진입 시) */
/* 1단계: seccomp-bpf (가장 먼저, 저비용) */
/* arch/x86/entry/common.c → do_syscall_64() */
ret = __secure_computing(&sd); /* BPF 프로그램 실행 → ALLOW/ERRNO/KILL */
if (ret == -1)
goto exit;
/* 2단계: capabilities 검사 */
/* 각 특권 작업에서 capable() 호출 */
if (!capable(CAP_SYS_ADMIN))
return -EPERM;
/* 3단계: LSM 훅 (AppArmor 또는 SELinux) */
/* security/apparmor/lsm.c 또는 security/selinux/hooks.c */
ret = security_file_permission(file, mask); /* 파일 접근 시 */
ret = security_socket_connect(sock, ...); /* 소켓 연결 시 */
ret = security_sb_mount(dev_name, ...); /* mount() 시 */
/* 세 계층이 모두 ALLOW해야 작업 허용
seccomp BLOCK → 즉시 거부 (LSM 도달 전)
capabilities 없음 → EPERM (LSM 도달 전)
LSM DENY → EACCES/EPERM */
Kata Containers
Kata Containers는 경량 가상 머신(VM) 내부에서 컨테이너 워크로드를 실행하여, VM 수준의 격리와 컨테이너 수준의 사용자 경험을 결합합니다.
containerd shimv2 연동
Kata는 containerd의 shimv2 인터페이스를 구현하여, 기존 containerd 워크플로에 투명하게 통합됩니다.
# containerd 설정에서 Kata 런타임 등록
# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata]
runtime_type = "io.containerd.kata.v2"
# Kubernetes에서 RuntimeClass로 사용
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: kata
handler: kata
# Pod에서 Kata 런타임 지정
apiVersion: v1
kind: Pod
spec:
runtimeClassName: kata
containers:
- name: app
image: nginx
주요 설정 옵션 (configuration.toml)
| 옵션 | 설명 | 기본값 |
|---|---|---|
hypervisor | QEMU, Cloud Hypervisor, Firecracker | QEMU |
default_memory | VM 메모리 (MB) | 256 |
default_vcpus | VM vCPU 수 | 1 |
shared_fs | 파일시스템 공유 방식 | virtiofs |
kernel_path | 게스트 커널 경로 | /usr/share/kata-containers/vmlinux |
enable_debug | 디버그 모드 | false |
최소 컨테이너 직접 구현
컨테이너의 내부 동작을 이해하기 위해, 셸 스크립트와 커널 API만으로 최소 컨테이너를 직접 구현해 봅니다.
셸 스크립트로 컨테이너 구현 (교육 목적)
#!/bin/bash
# mini-container.sh — 교육용 최소 컨테이너
# root 권한 필요 (rootless는 unshare --user 추가)
set -e
ROOTFS="./rootfs" # alpine rootfs 등 준비 필요
HOSTNAME="mini-container"
## 1단계: 네임스페이스 생성 + 새 프로세스 시작
unshare --mount --uts --ipc --pid --net --fork \
--mount-proc=$ROOTFS/proc \
/bin/bash -c "
# 2단계: 호스트명 설정 (UTS namespace)
hostname $HOSTNAME
# 3단계: 루트 파일시스템 전환 (pivot_root)
mount --bind $ROOTFS $ROOTFS
cd $ROOTFS
mkdir -p .put_old
pivot_root . .put_old
umount -l /.put_old
rmdir /.put_old
# 4단계: 필수 파일시스템 마운트
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t tmpfs tmpfs /dev
# 5단계: cgroup 리소스 제한 (v2)
# (호스트에서 미리 cgroup 디렉토리를 생성해야 함)
# echo 50000 100000 > /sys/fs/cgroup/mini/cpu.max
# echo 67108864 > /sys/fs/cgroup/mini/memory.max # 64MB
# echo \$\$ > /sys/fs/cgroup/mini/cgroup.procs
# 6단계: 컨테이너 셸 실행
exec /bin/sh
"
runc bundle 생성 및 실행 예제
# OCI Bundle 생성 (rootfs + config.json)
$ mkdir -p mycontainer/rootfs
# Alpine rootfs 준비
$ docker export $(docker create alpine:latest) | \
tar -C mycontainer/rootfs -xf -
# OCI 기본 config.json 생성
$ cd mycontainer
$ runc spec
# config.json 수정 (예: 메모리 256MB 제한 추가)
# "resources": { "memory": { "limit": 268435456 } }
# 컨테이너 실행
$ runc run my-test-container
# 다른 터미널에서 상태 확인
$ runc list
ID PID STATUS BUNDLE CREATED
my-test-container 12345 running /home/user/mycontainer 2024-...
# 컨테이너 내부에서 격리 확인
/ # hostname
runc
/ # ps aux
PID USER TIME COMMAND
1 root 0:00 sh
7 root 0:00 ps aux
/ # cat /proc/1/cgroup
0::/
컨테이너 런타임 설정
Docker 데몬 설정과 대안 OCI 런타임(crun, youki)을 다룹니다.
/etc/docker/daemon.json 주요 설정
{
"data-root": "/var/lib/docker",
"storage-driver": "overlay2",
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" },
"default-ulimits": { "nofile": { "Name": "nofile", "Hard": 64000, "Soft": 64000 }},
"runtimes": {
"crun": { "path": "/usr/bin/crun" },
"youki": { "path": "/usr/local/bin/youki" }
},
"default-runtime": "runc",
"live-restore": true, // dockerd 재시작 시 컨테이너 보존
"max-concurrent-downloads": 3,
"experimental": false,
"features": { "containerd-snapshotter": true }
}
runc vs crun vs youki 비교
| 런타임 | 언어 | 컨테이너 시작 지연 | 메모리 사용량 | 특징 |
|---|---|---|---|---|
| runc | Go | ~200ms | ~15MB | Docker/Kubernetes 기본값, 안정성 최우선 |
| crun | C | ~20ms (10x 빠름) | ~2MB | CRIU 통합, cgroup v2 완전 지원, RHEL 기본값 |
| youki | Rust | ~30ms | ~4MB | 안전성, crun과 유사 성능, 활발한 개발 |
# 대안 런타임 사용
$ docker run --runtime=crun --rm alpine echo "fast start"
# containerd 런타임 설정 (/etc/containerd/config.toml)
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes]
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = true # cgroup v2 systemd driver 필수
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.crun]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.crun.options]
BinaryName = "/usr/bin/crun"
Docker 런타임 성능 비교 실측
# 런타임별 컨테이너 시작 시간 측정
$ for rt in runc crun youki; do
echo -n "$rt: "
time docker run --runtime=$rt --rm alpine true
done
# runc: real 0m0.215s
# crun: real 0m0.022s ← 10배 빠름
# youki: real 0m0.031s
# 메모리 사용량 비교 (runc vs crun)
$ /usr/bin/time -v runc create --bundle /tmp/bundle mycontainer 2>&1 | grep "Maximum resident"
# runc: Maximum resident set size: 14592 kB
# crun: Maximum resident set size: 1856 kB
# crun 장점: cgroup v2 네이티브, CRIU 완전 통합, IoT/엣지 환경 적합
# youki 장점: Rust 메모리 안전성, 보안 감사 용이, crun과 성능 유사
컨테이너 내부 systemd 실행
컨테이너는 일반적으로 단일 프로세스(Single Process) 원칙을 따르지만, 레거시 애플리케이션 마이그레이션, 다중 서비스 컨테이너, CI/CD 환경에서 systemd를 PID 1으로 실행해야 하는 경우가 있습니다. 이 섹션에서는 Docker 컨테이너 내부에서 systemd를 실행하기 위한 커널 수준 요구사항과 실전 설정을 다룹니다.
systemd를 PID 1로 사용하는 이유
컨테이너 내부에서 systemd를 init 시스템으로 사용하는 주요 사례는 다음과 같습니다.
| 사용 사례 | 설명 | 예시 |
|---|---|---|
| 레거시 마이그레이션 | VM 기반 서비스를 컨테이너로 전환할 때 기존 systemd unit 파일을 재활용 | 온프레미스 LAMP 스택 → Docker 컨테이너 |
| 다중 서비스 관리 | 하나의 컨테이너에서 여러 데몬을 관리해야 하는 경우 | DB + 웹서버 + cron을 포함한 올인원 컨테이너 |
| CI/CD 테스트 | systemd unit 파일의 동작을 컨테이너 환경에서 검증 | Ansible/Molecule 테스트, 패키지 빌드 후 서비스 시작 테스트 |
| 개발/디버깅 환경 | 프로덕션과 유사한 init 환경을 로컬에서 재현 | journald 로그, 타이머, 소켓 활성화 테스트 |
OCI/Docker의 설계 철학은 컨테이너당 하나의 프로세스입니다. systemd를 PID 1으로 실행하면 이미지 크기 증가, 시작 시간 지연, 보안 표면 확대 등의 단점이 발생합니다. 가능하다면 다중 서비스는 Docker Compose나 Kubernetes Pod로 분리하는 것이 권장됩니다. systemd가 반드시 필요한 경우에만 이 방법을 사용하세요.
PID 1의 커널 특성과 systemd 요구사항
systemd가 컨테이너 내부에서 정상 동작하려면 PID 1으로 실행되어야 합니다. 커널은 PID 1에 대해 특별한 동작을 보장합니다.
PID 1의 커널 특수 동작 (kernel/signal.c)
- 시그널 면역: PID 1은 명시적으로 핸들러를 등록하지 않은 시그널을 무시합니다. 커널의
sig_task_ignored()함수가is_global_init()를 검사하여, 핸들러가 없는 시그널(SIGTERM, SIGINT 포함)을 전달하지 않습니다. - 좀비 수거(Reaper): 부모 프로세스가 먼저 종료된 고아(orphan) 프로세스는 PID 1에 재할당됩니다. PID 1이
wait()를 호출하지 않으면 좀비 프로세스가 누적됩니다. - 종료 불가: PID 1이 종료되면 커널은 해당 PID 네임스페이스 전체를 종료합니다(
zap_pid_ns_processes()).
/* kernel/signal.c — PID 1 시그널 특수 처리 (간략화) */
static bool sig_task_ignored(struct task_struct *t, int sig, bool force)
{
struct sighand_struct *sighand;
/* PID 1(init)은 명시적 핸들러가 없으면 시그널 무시 */
if (unlikely(is_global_init(t) || is_child_reaper(task_pid(t))))
if (sig_handler(t, sig) == SIG_DFL)
return true; /* ← SIGTERM, SIGINT 등 기본 핸들러 무시 */
return sig_handler_ignored(sig_handler(t, sig), sig);
}
/* PID 네임스페이스 내부에서도 동일하게 적용 */
/* is_child_reaper()는 네임스페이스의 init 프로세스(PID 1)를 검사 */
PID 네임스페이스와 systemd
Docker는 기본적으로 CLONE_NEWPID로 새로운 PID 네임스페이스를 생성합니다. 컨테이너의 첫 번째 프로세스가 네임스페이스 내 PID 1이 됩니다. systemd는 시작 시 getpid() == 1을 검사하며, PID 1이 아니면 실행을 거부합니다.
# 컨테이너 내부 PID 네임스페이스 확인
$ docker run --rm -it ubuntu:22.04 bash -c 'echo "PID=$$"; cat /proc/1/status | head -5'
PID=1
Name: bash
Umask: 0022
State: S (sleeping)
Tgid: 1
Pid: 1
# 호스트에서 해당 컨테이너 프로세스의 실제 PID 확인
$ docker inspect --format '{{.State.Pid}}' mycontainer
28571 # ← 호스트 기준 PID
cgroup v2 위임(Delegation)과 systemd
systemd는 서비스별 자원 관리를 위해 자신만의 cgroup 서브트리에 대한 쓰기 권한이 필요합니다. 이것이 컨테이너 내부 systemd 실행의 핵심 난관입니다.
systemd의 cgroup 요구사항
/sys/fs/cgroup/마운트 및 쓰기 권한 — 서비스 유닛을 slice/scope에 배치하기 위함cgroup.subtree_control쓰기 — 하위 컨트롤러 활성화cgroup.procs쓰기 — 프로세스를 cgroup 간 이동
| 항목 | cgroup v1 | cgroup v2 |
|---|---|---|
| 마운트 구조 | /sys/fs/cgroup/{controller}/ (다중 마운트) | /sys/fs/cgroup/ (단일 통합 트리) |
| 위임 방식 | 디렉토리 생성 권한 필요, 컨트롤러별 별도 위임 | 서브트리 소유권 + cgroup.subtree_control 쓰기 |
| Docker 기본 | --cgroup-parent로 하위 디렉토리 할당 | --cgroupns=private + systemd 드라이버 권장 |
| systemd 호환 | 부분 지원 (hybrid 모드 필요) | 완전 지원 (권장) |
cgroup 네임스페이스 모드
--cgroupns=host: 호스트의 cgroup 트리를 직접 사용. 컨테이너가 호스트 cgroup 계층을 볼 수 있으므로 보안상 비권장이나, systemd 호환성이 높음--cgroupns=private: 별도의 cgroup 네임스페이스를 생성. 컨테이너의/sys/fs/cgroup/가 자신의 cgroup 루트처럼 보임. Docker 20.10+ 기본값이며 systemd와 사용 시 적절한 위임 설정 필요
# 컨테이너 내부에서 cgroup v2 위임 상태 확인
$ cat /sys/fs/cgroup/cgroup.controllers
cpu io memory pids # ← 사용 가능한 컨트롤러
$ cat /sys/fs/cgroup/cgroup.subtree_control
cpu io memory pids # ← 하위 cgroup에 위임된 컨트롤러
# systemd가 생성한 cgroup 계층 확인
$ systemd-cgls
Control group /:
-.slice
├─init.scope
│ └─1 /usr/lib/systemd/systemd --system
├─system.slice
│ ├─dbus.service
│ │ └─42 /usr/bin/dbus-daemon --system
│ └─systemd-journald.service
│ └─28 /usr/lib/systemd/systemd-journald
└─user.slice
└─...
Dockerfile 작성: systemd 기반 이미지
systemd 컨테이너 이미지 작성의 핵심은 불필요한 유닛을 마스킹하여 하드웨어/호스트 의존성을 제거하는 것입니다.
RHEL/AlmaLinux 기반
FROM almalinux:9
# systemd 설치
RUN dnf -y install systemd && dnf clean all
# 컨테이너 환경에서 불필요한 유닛 마스킹
RUN systemctl mask \
systemd-remount-fs.service \
dev-hugepages.mount \
sys-fs-fuse-connections.mount \
systemd-logind.service \
getty.target \
console-getty.service
# systemd 진입점 설정
STOPSIGNAL SIGRTMIN+3
CMD ["/usr/sbin/init"]
Debian/Ubuntu 기반
FROM ubuntu:22.04
# systemd 설치 (systemd-sysv가 /sbin/init 심볼릭 링크 제공)
RUN apt-get update && \
apt-get install -y systemd systemd-sysv dbus && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# 불필요한 유닛 마스킹
RUN systemctl mask \
systemd-remount-fs.service \
dev-hugepages.mount \
sys-fs-fuse-connections.mount \
systemd-logind.service \
getty.target \
console-getty.service
STOPSIGNAL SIGRTMIN+3
CMD ["/lib/systemd/systemd"]
마스킹 대상 유닛과 이유
| 유닛 | 마스킹 이유 | 커널 의존성 |
|---|---|---|
systemd-remount-fs.service | 루트 파일시스템 재마운트 불필요 (OverlayFS) | mount(MS_REMOUNT) — 컨테이너에서 실패 |
dev-hugepages.mount | Huge Pages 마운트 불필요 | hugetlbfs — 호스트 커널 설정에 의존 |
sys-fs-fuse-connections.mount | FUSE 연결 파일시스템 불필요 | fusectl — 커널 모듈 미로드 시 실패 |
systemd-logind.service | 로그인 세션 관리 불필요 (tty 없음) | /dev/tty*, VT 서브시스템 의존 |
getty.target | 가상 콘솔 불필요 | /dev/console — 컨테이너 환경에서 제한적 |
docker run 옵션과 커널 요구사항
systemd 컨테이너를 실행할 때 각 옵션은 특정 커널 메커니즘을 활성화합니다.
Privileged 모드 (간편하지만 보안 취약)
docker run -d --name systemd-test \
--privileged \
--cgroupns=host \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
--tmpfs /run \
--tmpfs /run/lock \
--stop-signal SIGRTMIN+3 \
my-systemd-image
각 옵션의 커널 메커니즘 매핑
| Docker 옵션 | 커널 메커니즘 | systemd 요구 이유 |
|---|---|---|
--privileged | 모든 Linux Capabilities 부여 + seccomp 비활성화 + /dev 전체 접근 | cgroup 조작, 마운트, 네트워크 설정 등 다양한 특권 작업 수행 |
--cgroupns=host | CLONE_NEWCGROUP 미사용 — 호스트 cgroup 트리 공유 | cgroup v2 서브트리 쓰기 권한 확보 (가장 확실한 방법) |
-v /sys/fs/cgroup:rw | cgroup2 파일시스템 읽기/쓰기 마운트 | 서비스별 cgroup 생성 및 컨트롤러 설정 |
--tmpfs /run | mount -t tmpfs tmpfs /run | systemd 런타임 소켓, PID 파일, 상태 파일 저장 (/run/systemd/) |
--tmpfs /run/lock | mount -t tmpfs tmpfs /run/lock | 파일 잠금을 위한 tmpfs (POSIX lock 파일) |
--stop-signal SIGRTMIN+3 | 컨테이너 중지 시 SIGRTMIN+3 (시그널 37) 전송 | systemd는 SIGRTMIN+3을 수신하면 poweroff.target으로 전환하여 정상 종료 |
비특권 모드 (보안 강화)
--privileged 대신 최소한의 Capabilities만 부여하여 보안을 강화할 수 있습니다.
# 최소 Capabilities로 systemd 실행
docker run -d --name systemd-secure \
--cap-add SYS_ADMIN \
--cap-add NET_ADMIN \
--cap-add SYS_PTRACE \
--security-opt seccomp=unconfined \
--security-opt apparmor=unconfined \
--cgroupns=host \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
--tmpfs /run \
--tmpfs /run/lock \
--stop-signal SIGRTMIN+3 \
my-systemd-image
# SYS_ADMIN: cgroup, mount, namespace 조작
# NET_ADMIN: 네트워크 설정 (systemd-networkd 등)
# SYS_PTRACE: 프로세스 추적 (systemd-coredump 등)
# seccomp=unconfined: systemd가 사용하는 시스템 콜 허용
일반적인 프로세스는 SIGTERM(15)으로 종료하지만, PID 1인 systemd는 SIGTERM을 기본적으로 무시합니다(커널의 PID 1 보호).
systemd는 SIGRTMIN+3에 대해 poweroff.target 전환 핸들러를 등록하여, 모든 서비스를 순서대로 정지한 후 종료합니다.
docker stop이 정상 작동하려면 이 시그널 설정이 필수입니다.
Podman의 systemd 네이티브 지원
Podman은 컨테이너의 진입점이 systemd인지 자동으로 감지하여 필요한 설정을 적용합니다. Docker에서 수동으로 지정해야 하는 옵션들이 자동 처리됩니다.
| 설정 | Docker (수동 지정) | Podman (자동 감지) |
|---|---|---|
| 중지 시그널 | --stop-signal SIGRTMIN+3 | 자동 설정 |
/run tmpfs | --tmpfs /run | 자동 마운트 |
/run/lock tmpfs | --tmpfs /run/lock | 자동 마운트 |
| cgroup 위임 | -v /sys/fs/cgroup:rw | private cgroup 네임스페이스 + 읽기/쓰기 위임 |
/tmp tmpfs | 별도 설정 필요 | 자동 마운트 |
| 환경 변수 | 수동 설정 | container=podman 자동 설정 |
# Podman: systemd 자동 감지 — 추가 옵션 불필요
$ podman run -d --name systemd-test my-systemd-image
# Podman이 /sbin/init 또는 systemd를 감지하면 자동으로:
# - SIGRTMIN+3 설정
# - tmpfs /run, /run/lock, /tmp 마운트
# - cgroup 읽기/쓰기 위임
# - container=podman 환경 변수 설정
# 명시적 제어: --systemd 옵션
$ podman run --systemd=true ... # 감지 시 systemd 모드 활성화 (기본값)
$ podman run --systemd=always ... # 항상 systemd 모드 강제
$ podman run --systemd=false ... # systemd 모드 비활성화
# 서비스 관리 예제
$ podman exec systemd-test systemctl status
● systemd-test
State: running
Units: 45 loaded (incl. loaded), 12 running
Jobs: 0 queued
Failed: 0 units
$ podman exec systemd-test systemctl start nginx
$ podman exec systemd-test systemctl enable --now myapp.service
일반적인 문제와 커널 추적
systemd 컨테이너에서 자주 발생하는 문제와 커널 수준 진단 방법입니다.
| 증상 | 원인 | 해결 방법 |
|---|---|---|
Failed to mount cgroup at /sys/fs/cgroup | cgroup 파일시스템 쓰기 권한 없음 | -v /sys/fs/cgroup:/sys/fs/cgroup:rw 또는 --privileged |
Failed to create /init.scope control group | cgroup 서브트리 생성 권한 없음 | --cgroupns=host 또는 cgroup v2 위임 설정 |
systemd-journald 시작 실패 | /run이 tmpfs가 아님 | --tmpfs /run --tmpfs /run/lock |
| systemd 시작 후 행(hang) | 불필요한 유닛이 하드웨어 접근 시도 | getty, logind 등 마스킹 (Dockerfile 참조) |
docker stop 시 10초 대기 후 강제 종료 | SIGTERM을 PID 1이 무시 (커널 기본 동작) | --stop-signal SIGRTMIN+3 |
Detected virtualization docker 후 기능 제한 | systemd가 컨테이너 환경을 감지하여 일부 기능 비활성화 | 정상 동작 — systemd가 systemd-detect-virt로 자동 감지 |
커널 추적을 통한 디버깅
# 컨테이너 PID 확인
$ CPID=$(docker inspect --format '{{.State.Pid}}' systemd-test)
# strace: systemd 시작 시 시스템 콜 추적
$ strace -fp $CPID -e trace=mount,unshare,clone,openat 2>&1 | head -30
mount("tmpfs", "/run", "tmpfs", MS_NOSUID|MS_NODEV, "mode=0755,size=...") = 0
mount("cgroup2", "/sys/fs/cgroup", "cgroup2", MS_NOSUID|MS_NODEV|MS_NOEXEC, ...) = -1 EPERM
# ↑ EPERM이면 cgroup 마운트 권한 부족 — --privileged 또는 CAP_SYS_ADMIN 필요
# bpftrace: cgroup 디렉토리 생성 추적
$ bpftrace -e 'kprobe:cgroup_mkdir { printf("cgroup mkdir: %s pid=%d\n", str(arg1), pid); }'
cgroup mkdir: /sys/fs/cgroup/system.slice pid=28571
cgroup mkdir: /sys/fs/cgroup/system.slice/dbus.service pid=28571
# journalctl: 컨테이너 내부 systemd 부팅 로그 확인
$ docker exec systemd-test journalctl -b --no-pager | head -20
대안: tini, dumb-init, s6-overlay
systemd가 과도한 경우, 경량 init 시스템이 PID 1의 핵심 역할(시그널 전달, 좀비 수거)만 수행할 수 있습니다.
| init 시스템 | 바이너리 크기 | 시그널 전달 | 좀비 수거 | cgroup 요구 | 적합한 용도 |
|---|---|---|---|---|---|
| systemd | ~4 MB | 완전한 시그널 관리 | 완전한 wait() | cgroup v2 위임 필수 | 다중 서비스, 레거시 마이그레이션 |
| tini | ~30 KB | SIGTERM → 자식 전달 | wait() 루프 | 없음 | 단일 서비스 컨테이너 |
| dumb-init | ~50 KB | 시그널 전달 + 재작성 | wait() 루프 | 없음 | 단일 서비스, 시그널 변환 필요 시 |
| s6-overlay | ~2 MB | 완전한 시그널 관리 | 완전한 wait() | 없음 | 다중 서비스 (systemd 없이) |
# tini: Docker --init 플래그 (내장 tini 사용)
$ docker run --init alpine sleep 100
$ docker exec <container> ps aux
PID USER TIME COMMAND
1 root 0:00 /sbin/docker-init -- sleep 100 # ← tini가 PID 1
7 root 0:00 sleep 100 # ← 실제 프로세스
# Dockerfile에서 tini 직접 사용
# RUN apt-get install -y tini
# ENTRYPOINT ["tini", "--"]
# CMD ["my-application"]
# dumb-init: 시그널 재작성 기능
# dumb-init은 SIGTERM → SIGQUIT 등 시그널 변환 지원
# ENTRYPOINT ["dumb-init", "--rewrite", "15:3", "--"]
# s6-overlay: 다중 서비스 관리 (systemd 없이)
# s6-overlay는 서비스 디렉토리 기반으로 다중 데몬을 관리
# cgroup 위임이 불필요하여 rootless 환경에서도 완전 동작
단일 프로세스: docker run --init (tini) — 좀비 수거와 시그널 전달만 필요할 때.
다중 서비스 + systemd unit 불필요: s6-overlay — cgroup 위임 없이 여러 데몬을 관리.
기존 systemd unit 재활용 필수: systemd — 레거시 마이그레이션, CI/CD systemd 테스트.
CRIU 체크포인트/복원
CRIU(Checkpoint/Restore In Userspace)는 실행 중인 컨테이너의 전체 상태를 파일로 저장하고, 나중에 동일하거나 다른 호스트에서 복원할 수 있는 기술입니다. 라이브 마이그레이션, 빠른 시작, 컨테이너 스냅샷에 활용됩니다.
docker checkpoint 명령
# Docker experimental 기능 활성화 (daemon.json)
$ cat /etc/docker/daemon.json
{ "experimental": true }
# 체크포인트 생성
$ docker checkpoint create mycontainer cp1
# → /var/lib/docker/containers/<id>/checkpoints/cp1/ 에 이미지 파일 저장
# 체크포인트에서 재시작
$ docker start --checkpoint cp1 mycontainer
# 컨테이너가 체크포인트 시점부터 즉시 재개
# 체크포인트 목록
$ docker checkpoint ls mycontainer
CHECKPOINT NAME
cp1
# 체크포인트 이미지 파일 확인
$ ls /var/lib/docker/containers/<id>/checkpoints/cp1/
core-1.img fdinfo-2.img fs-1.img mm-1.img pagemap-1.img pages-1.img
CRIU 내부: parasite 코드 인젝션과 /proc/PID/mem
# crun + CRIU 통합 (crun이 runc보다 CRIU 지원 더 완전)
$ docker run --runtime=crun -d --name=stateful myapp
$ docker checkpoint create stateful snapshot1
# CRIU 직접 사용 (낮은 수준 제어)
$ CPID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
# dump: 실행 중인 프로세스 상태 저장
$ criu dump -t $CPID --images-dir /tmp/criu-dump \
--shell-job --tcp-established --ext-unix-sk -vv
# restore: 저장된 상태에서 재시작
$ criu restore --images-dir /tmp/criu-dump \
--shell-job --tcp-established --ext-unix-sk -vv &
# 이미지 파일 분석 (crit 도구)
$ crit decode -i /tmp/criu-dump/core-1.img | python3 -m json.tool | head -40
# → thread_info.gpregs: 레지스터 상태 확인
디버깅과 모니터링
네임스페이스 진입 및 확인
# nsenter — 실행 중인 컨테이너의 네임스페이스에 진입
$ nsenter -t <PID> --mount --uts --ipc --net --pid -- /bin/sh
# -t: 대상 프로세스 PID
# 각 플래그로 원하는 네임스페이스만 선택적으로 진입 가능
# lsns — 시스템의 모든 네임스페이스 나열
$ lsns
NS TYPE NPROCS PID USER COMMAND
4026531836 pid 1 1234 root nginx: master process
4026531840 mnt 1 1234 root nginx: master process
4026531992 net 1 1234 root nginx: master process
# 특정 프로세스의 네임스페이스 ID 확인
$ ls -la /proc/<PID>/ns/
lrwxrwxrwx 1 root root 0 ... cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 ... mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 root root 0 ... net -> 'net:[4026531992]'
lrwxrwxrwx 1 root root 0 ... pid -> 'pid:[4026531836]'
프로세스 네임스페이스 정보
# /proc/<PID>/status에서 네임스페이스별 PID 확인
$ grep NS /proc/<PID>/status
NSpid: 1234 1 # 호스트 PID 1234, 컨테이너 내 PID 1
NStgid: 1234 1
NSsid: 1234 1
NSpgid: 1234 1
# 컨테이너 프로세스의 cgroup 경로
$ cat /proc/<PID>/cgroup
0::/system.slice/docker-abc123.scope
cgroup 모니터링
# systemd-cgtop — cgroup별 리소스 사용량 실시간 모니터링
$ systemd-cgtop
Control Group Tasks %CPU Memory Input/s Output/s
/ 85 12.3 1.2G - -
/system.slice/docker-abc.. 3 5.1 128.0M 4.0K 12.0K
# cgroup v2 직접 확인
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.current
134217728 # 128MB 사용 중
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.stat
usage_usec 5234567
user_usec 3123456
system_usec 2111111
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/pids.current
3 # 3개 프로세스
디버깅 도구 비교
| 도구 | 대상 런타임 | 용도 |
|---|---|---|
| docker inspect | Docker | 컨테이너 상세 정보 (설정, 네트워크, 마운트) |
| crictl | CRI 런타임 | Kubernetes 노드의 컨테이너/파드 디버깅 |
| ctr | containerd | containerd 직접 제어 (이미지, 컨테이너, 태스크) |
| nerdctl | containerd | Docker-호환 CLI (compose 지원) |
| podman inspect | Podman | Docker와 동일한 inspect 인터페이스 |
| runc list/state | runc | OCI 런타임 수준 상태 확인 |
# crictl — Kubernetes 노드 디버깅
$ crictl pods # 파드 목록
$ crictl ps # 컨테이너 목록
$ crictl inspect <container-id> # 컨테이너 상세
$ crictl logs <container-id> # 로그 확인
$ crictl exec -it <container-id> /bin/sh # 컨테이너 내부 셸
일반적인 문제
| 문제 | 증상 | 해결 |
|---|---|---|
| OOM Kill | 컨테이너 갑자기 종료, exit code 137 | memory.max 확인, dmesg에서 OOM 로그, 메모리 제한 조정 |
| PID 1 좀비 | 좀비 프로세스 누적, PID 고갈 | init 프로세스 사용 (tini, dumb-init), --init 플래그 |
| 마운트 누수 | 컨테이너 삭제 후 마운트 포인트 잔존 | MNT_DETACH로 lazy unmount, findmnt로 확인 |
| DNS 실패 | 컨테이너에서 이름 해석 불가 | /etc/resolv.conf 확인, --dns 옵션, bridge 설정 검증 |
| 퍼미션 에러 | rootless에서 파일 접근 거부 | subuid/subgid 매핑 확인, 볼륨 소유권 조정 |
Docker 진단 — nsenter/bpftrace/ftrace
실행 중인 Docker 컨테이너의 커널 활동을 실시간(Real-time)으로 추적하는 도구와 기법을 정리합니다.
Docker nsenter
# 컨테이너 PID 찾기
$ CPID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
# 모든 네임스페이스에 진입하여 디버깅
$ nsenter -t $CPID --mount --uts --ipc --net --pid -- /bin/bash
# 특정 네임스페이스만 (네트워크만)
$ nsenter -t $CPID --net -- ip route show
# 호스트 네임스페이스 도구로 컨테이너 프로세스 검사
$ ls -la /proc/$CPID/ns/
lrwxrwxrwx net -> 'net:[4026532xxx]'
lrwxrwxrwx pid -> 'pid:[4026532yyy]'
lrwxrwxrwx mnt -> 'mnt:[4026532zzz]'
# 여러 네임스페이스를 동시에 진입하는 순서 (nsenter 내부)
# user NS → mnt/uts/ipc/net → pid 순으로 진입해야 권한 오류 방지
$ nsenter --user --mount --uts --ipc --net --pid -t $CPID \
--preserve-credentials -- bash # --preserve-credentials: capability 보존
# docker top: 컨테이너 내 프로세스 호스트 관점에서 확인
$ docker top mycontainer aux
USER PID %CPU %MEM VSZ RSS COMMAND
root 12345 0.0 0.1 12345 5678 nginx: master process nginx
www-data 12346 0.0 0.0 12345 3456 nginx: worker process
bpftrace 스크립트 — Docker 특화 추적
# 스크립트 1: Docker 컨테이너 clone() syscall 추적
$ bpftrace -e '
tracepoint:syscalls:sys_enter_clone {
if (comm == "runc:[2:INIT]" || comm == "runc") {
printf("[%s PID:%d] clone flags=0x%x\n", comm, pid, args->flags);
printf(" NEWPID=%d NEWNET=%d NEWNS=%d NEWUSER=%d\n",
(args->flags & 0x20000000) != 0,
(args->flags & 0x40000000) != 0,
(args->flags & 0x00020000) != 0,
(args->flags & 0x10000000) != 0);
}
}'
# 스크립트 2: OverlayFS copy-up 이벤트 추적
$ bpftrace -e '
kprobe:ovl_copy_up_one {
printf("[copy-up] path=%s pid=%d comm=%s\n",
str(((struct dentry *)arg1)->d_name.name),
pid, comm);
}'
# 스크립트 3: 컨테이너 process exec 추적 (특정 net NS)
$ CONTAINER_NETNS_INO=$(stat -Lc %i /proc/$CPID/ns/net)
$ bpftrace -e "
tracepoint:sched:sched_process_exec {
\$ns = nsid(\"net\");
if (\$ns == $CONTAINER_NETNS_INO) {
printf(\"[container exec] %s (pid=%d)\n\", str(args->filename), pid);
}
}"
ftrace — Docker 컨테이너 syscall 추적
# ftrace로 컨테이너 프로세스의 mount syscall 추적
$ echo 1 > /sys/kernel/debug/tracing/events/syscalls/sys_enter_mount/enable
$ echo "pid == $CPID" > /sys/kernel/debug/tracing/events/syscalls/sys_enter_mount/filter
$ cat /sys/kernel/debug/tracing/trace_pipe
runc-init-12345: sys_enter_mount(dev_name=overlay, dir_name=/merged, ...)
runc-init-12345: sys_enter_mount(dev_name=proc, dir_name=/merged/proc, ...)
# cgroup 생성 이벤트 추적
$ echo 1 > /sys/kernel/debug/tracing/events/cgroup/cgroup_mkdir/enable
$ cat /sys/kernel/debug/tracing/trace_pipe
containerd-12346: cgroup_mkdir: path=docker/abc123 level=2
# ftrace function_graph로 runc의 clone() 내부 경로
$ echo function_graph > /sys/kernel/debug/tracing/current_tracer
$ echo "pid == $RUNC_PID" > /sys/kernel/debug/tracing/set_ftrace_pid
$ echo 1 > /sys/kernel/debug/tracing/tracing_on
$ cat /sys/kernel/debug/tracing/trace_pipe | grep -A20 "clone"
1) | do_syscall_64() {
1) | sys_clone() {
1) | copy_process() {
1) | copy_namespaces() {
1) | create_new_namespaces() { ... }
1) | } /* copy_namespaces */
1) | } /* copy_process */
1) | } /* sys_clone */
perf stat — 컨테이너 생성 비용 측정
# docker run 중 clone() syscall 횟수 측정
$ perf stat -e 'syscalls:sys_enter_clone,syscalls:sys_enter_clone3' \
docker run --rm alpine echo "hello" 2>&1
Performance counter stats for 'docker run --rm alpine echo hello':
3 syscalls:sys_enter_clone # runc 내 clone() 호출 수
1 syscalls:sys_enter_clone3 # clone3() 호출 수
0.214802893 seconds time elapsed # 총 컨테이너 시작 시간
# containerd-shim 포크 비용 측정
$ perf stat -e 'sched:sched_process_fork' \
-p $(pgrep containerd) -- sleep 10 &
$ docker run --rm alpine true
# fork 이벤트: containerd → shim → runc → container-init
# 컨테이너별 syscall 통계 (runc 프로세스 추적)
$ perf stat -e 'syscalls:*' --pid=$(pgrep runc) -- sleep 5 2>&1 | \
sort -rk2 | head -20
strace — 컨테이너 초기화 추적
# runc init 프로세스의 syscall 순서 추적
$ strace -f -e trace=clone,unshare,mount,pivot_root,setns,chdir \
docker run --rm alpine echo "traced" 2>&1 | head -50
clone(child_stack=NULL, flags=CLONE_NEWUSER|CLONE_NEWNET|CLONE_NEWPID|CLONE_NEWNS|...
unshare(CLONE_NEWNS) # mount namespace 분리
mount("overlay", "merged/", "overlay", MS_RELATIME, "lowerdir=...,upperdir=...")
mount("proc", "merged/proc", "proc", MS_NOSUID|MS_NODEV|MS_NOEXEC)
pivot_root("merged/", "merged/put_old") # rootfs 교체
chdir("/")
umount2("put_old", MNT_DETACH) # 이전 루트 분리
# 컨테이너 프로세스 전체 추적 (포크된 자식 포함)
$ CPID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
$ strace -f -p $CPID -e trace=network 2>&1 | grep -v EAGAIN
skbtracer / bpftrace — 컨테이너 네트워크 패킷 진단
# 컨테이너 네트워크 패킷 드롭 진단 (bpftrace)
$ CPID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
$ NETNS_INO=$(stat -Lc %i /proc/$CPID/ns/net)
$ bpftrace -e "
kprobe:kfree_skb {
\$sk = (struct sk_buff *)arg0;
\$ns = nsid(\"net\");
if (\$ns == $NETNS_INO) {
printf(\"[drop] reason=%d src=%s dst=%s\\n\",
((struct sk_buff *)arg0)->dev->ifindex,
ntop(AF_INET, arg0), ntop(AF_INET, arg1));
}
}"
# veth 지연 측정 (컨테이너 → docker0 브리지)
$ bpftrace -e '
kprobe:dev_queue_xmit / comm == "nginx" / {
@ts[tid] = nsecs;
}
kretprobe:dev_queue_xmit / @ts[tid] / {
@latency_ns = hist(nsecs - @ts[tid]);
delete(@ts[tid]);
}
interval:s:10 { print(@latency_ns); clear(@latency_ns); exit(); }'
containerd-stress — 부하 테스트 및 메모리 릭 탐지
# containerd-stress: 대량 컨테이너 생성 부하 테스트
$ containerd-stress -c 10 -d 60s --runtime io.containerd.runc.v2
# -c 10: 동시 10개 컨테이너, -d 60s: 60초간 반복
# containerd 메모리 릭 탐지 (pprof)
$ curl http://localhost:1338/debug/pprof/heap > heap.prof
$ go tool pprof heap.prof
(pprof) top20 # 메모리 사용 상위 20개 함수
# shim 프로세스 수 모니터링
$ watch -n2 'ps aux | grep containerd-shim | wc -l'
# 컨테이너 종료 후에도 shim이 남아있으면 좀비 shim 문제
# cgroup 계층 통계 (전체 Docker 오버헤드)
$ systemd-cgtop -d 2 --depth=3 | grep docker
docker stats와 cgroup 지표 매핑
# docker stats 출력 항목과 cgroup v2 파일 매핑
$ docker stats --no-stream mycontainer
CONTAINER CPU % MEM USAGE / LIMIT MEM % NET I/O
mycontainer 5.23% 256MiB / 512MiB 50.0% 1.5kB / 800B
# 동일 정보를 cgroup v2에서 직접 읽기
CGPATH=/sys/fs/cgroup/system.slice/docker-$HASH.scope
# CPU 사용량 (ns 단위)
$ cat $CGPATH/cpu.stat | grep usage_usec
# 메모리 사용량 (rss+cache)
$ cat $CGPATH/memory.current
# 네트워크 I/O (컨테이너 NS에서)
$ nsenter -t $CPID --net -- cat /proc/net/dev
Docker 컨테이너 OOM 사후 분석
# OOM으로 컨테이너 종료 시 커널 로그 분석
$ dmesg | grep -A10 "oom-kill"
[ 123.456] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),
cpuset=docker-abc123.scope,mems_allowed=0,
oom_memcg=/system.slice/docker-abc123.scope,
task_memcg=/system.slice/docker-abc123.scope,
task=nginx,pid=12345,uid=33
# cgroup OOM 이벤트 카운터
$ cat /sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' mycontainer).scope/memory.events
oom 1
oom_kill 1
# docker events로 실시간 이벤트 모니터링
$ docker events --filter event=oom &
$ docker events --filter event=die &
$ docker run --memory=32m stress --vm 1 --vm-bytes 64m
# → event: container oom (src=abc123, container=mytest)
# → event: container die (exitCode=137, container=mytest)
# 컨테이너 파일시스템 레이어 변경 사항 확인 (copy-up 추적)
$ docker diff mycontainer
C /etc # Changed (copy-up 발생)
C /etc/nginx
A /etc/nginx/conf.d/custom.conf # Added (upperdir에 생성)
D /usr/share/nginx/html/index.html # Deleted (whiteout)
# docker inspect로 컨테이너 완전한 상태 정보
$ docker inspect mycontainer | python3 -c "
import json, sys
d = json.load(sys.stdin)[0]
print('PID:', d['State']['Pid'])
print('NetworkSettings:', json.dumps(d['NetworkSettings']['Networks'], indent=2))
print('UpperDir:', d['GraphDriver']['Data']['UpperDir'])
print('Mounts:', len(d['Mounts']))
print('SecurityOpt:', d['HostConfig']['SecurityOpt'])
print('CgroupParent:', d['HostConfig']['CgroupParent'])
"
# 전체 Docker 환경 상태 요약 (관리자 점검용)
$ docker system info | grep -E "Cgroup|Runtime|Storage|Kernel"
Kernel Version: 6.1.141
Storage Driver: overlay2
Cgroup Driver: systemd
Cgroup Version: 2
Default Runtime: runc
Runtimes: crun io.containerd.runc.v2 runc youki
# 컨테이너 시작/종료 이력 분석
$ docker events --since=1h --format '{{.Time}} {{.Action}} {{.Actor.Attributes.name}}'
# 빠른 종료 반복 시 OOM 또는 seccomp 위반 의심
참고 링크
Kernel & OCI Documentation
- OCI Runtime Specification — 컨테이너 런타임의 표준 동작을 정의하는 OCI 공식 사양입니다
- OCI Image Specification — 컨테이너 이미지 포맷과 레이어 구조를 정의하는 OCI 공식 사양입니다
- OCI Distribution Specification — 컨테이너 이미지 배포 및 레지스트리 API를 정의하는 OCI 공식 사양입니다
- Kernel Namespaces — 커널 네임스페이스 호환성 목록에 관한 공식 문서입니다
- Control Group v2 — cgroup v2 인터페이스와 컨트롤러를 설명하는 커널 공식 문서입니다
Man Pages
- namespaces(7) — Linux 네임스페이스 종류와 동작 원리를 설명하는 매뉴얼 페이지입니다
- cgroups(7) — cgroup v1/v2 인터페이스와 리소스 제어 방법을 설명하는 매뉴얼 페이지입니다
- capabilities(7) — Linux Capabilities의 종류와 권한 분리 메커니즘을 설명하는 매뉴얼 페이지입니다
- seccomp(2) — seccomp 시스템 콜 필터링 인터페이스를 설명하는 매뉴얼 페이지입니다
- pivot_root(2) — 컨테이너 루트 파일시스템 전환에 사용되는 시스템 콜 매뉴얼 페이지입니다
- clone(2) — 네임스페이스 생성과 프로세스 복제에 사용되는 시스템 콜 매뉴얼 페이지입니다
LWN Articles
- LWN: Namespaces in operation (series) — Linux 네임스페이스의 종류와 실제 동작을 심층 분석하는 연재 시리즈입니다
- LWN: A look at Docker's containerd — Docker의 핵심 런타임인 containerd 아키텍처를 분석하는 기사입니다
- LWN: Container-aware filesystems — 컨테이너 환경에 최적화된 파일시스템 설계를 다루는 기사입니다
- LWN: User namespaces and rootless containers — 사용자 네임스페이스와 루트리스 컨테이너 구현을 분석하는 기사입니다
- LWN: Rethinking the container runtime — 컨테이너 런타임의 발전 방향과 설계 재고를 논의하는 기사입니다
Container Runtime References
- runc (OCI reference runtime) — OCI 표준을 구현하는 레퍼런스 컨테이너 런타임입니다
- containerd — Docker와 Kubernetes에서 사용하는 산업 표준 컨테이너 런타임입니다
- crun (C-based OCI runtime) — C 언어로 작성된 경량 OCI 호환 컨테이너 런타임입니다
- Podman — 데몬 없이 동작하는 루트리스 컨테이너 관리 도구입니다
- Docker Documentation — Docker Engine의 설치, 설정, 사용법을 다루는 공식 문서입니다
Kernel Source
kernel/nsproxy.c— 네임스페이스 프록시, 컨테이너 격리 기반kernel/cgroup/cgroup.c— cgroup 코어, 리소스 제한 기반fs/overlayfs/— OverlayFS, 컨테이너 레이어 파일시스템security/apparmor/— AppArmor, 컨테이너 MAC 정책kernel/seccomp.c— seccomp-bpf, 시스템 콜 필터링include/linux/capability.h— Linux Capabilities 정의
관련 문서
Linux Containers와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.