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)·장애 복구까지 플랫폼 운영에 필요한 핵심을 다룹니다.

전제 조건: 프로세스(Process)네임스페이스(Namespace) 문서를 먼저 읽으세요. 컨테이너 격리는 커널 객체 자체를 복제하는 것이 아니라 가시성 범위를 분리하는 방식이므로, 경계 모델을 먼저 이해해야 합니다.
일상 비유: 이 주제는 공유 건물의 층별 출입 통제와 비슷합니다. 건물은 같지만 각 층의 출입 권한과 표지판이 다르듯이, 네임스페이스는 같은 커널 위에서 관측 범위를 분리합니다.

핵심 요약

  • 격리 모델 — 관측 범위와 자원 한계를 분리해 이해합니다.
  • 자원 회계 — cgroups 제한과 스케줄링 효과를 함께 봅니다.
  • 가상화(Virtualization) 경계 — 호스트/게스트 전환 비용을 파악합니다.
  • 시간/상태 일관성 — 체크포인트/복원 시 기준값을 점검합니다.
  • 운영 정책 — 격리 강도와 성능 비용의 균형을 맞춥니다.

단계별 이해

  1. 경계 정의
    무엇을 공유하고 무엇을 분리할지 먼저 정합니다.
  2. 제한 적용
    CPU/메모리/IO 제한을 단계적으로 설정합니다.
  3. 관측 검증
    네임스페이스/가상화 경계에서 보이는 값을 확인합니다.
  4. 장애 복구 점검
    마이그레이션/재시작(Reboot) 시 일관성을 검증합니다.
관련 표준: OCI Runtime Specification 1.0 (컨테이너 런타임), OCI Image Format Specification (이미지 형식) — 리눅스 컨테이너 기술 스택의 표준 규격입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.

컨테이너 개요

컨테이너는 운영체제 수준 가상화(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 등
네임스페이스/cgroups 상세: 각 프리미티브의 내부 구현은 네임스페이스, cgroups 페이지(Page)에서 자세히 다룹니다. 이 페이지에서는 컨테이너가 이들을 어떻게 조합하여 사용하는지에 집중합니다.

VM vs Container 아키텍처

Virtual Machine Hardware Host Kernel + Hypervisor (KVM) Guest VM 1 Guest Kernel Libs / Runtime App A App B Guest VM 2 Guest Kernel Libs / Runtime App C Container Hardware Host Kernel (shared) namespaces + cgroups + seccomp + capabilities Container Runtime (runc / containerd) Container 1 Libs App A Container 2 Libs App B Container 3 Libs App C

컨테이너 기술의 역사

연도기술의미
1979chroot파일시스템(Filesystem) 루트 격리의 시작
2001Linux namespacesmount namespace — CLONE_NEWNS (Linux 2.4.11)
2006cgroups (Google)프로세스 그룹 리소스 제한
2008LXC최초의 완전한 Linux 컨테이너 구현
2013Docker이미지 레이어 + 사용자 경험 혁신
2015OCI 설립런타임/이미지 스펙 표준화
2016runc 1.0OCI 참조 런타임 구현
2017containerd 1.0산업 표준 고수준 런타임
2019cgroup v2통합 계층 구조, PSI 지원
2021Podman 3.0+데몬리스, rootless 네이티브

커널 프리미티브 조합

컨테이너 런타임은 단일 프리미티브가 아닌, 여러 커널 기능을 특정 순서로 조합하여 격리 환경을 구성합니다. 아래 다이어그램은 컨테이너 생성 시 커널 프리미티브가 적용되는 흐름을 보여줍니다.

1. clone() CLONE_NEW* flags 2. Namespaces PID/NET/MNT/USER/UTS/IPC 3. cgroups CPU/Mem/IO/PID 제한 4. pivot_root rootfs 전환 5. Mounts /proc, /sys, /dev 마운트 6. Capabilities bounding set 축소 7. seccomp-bpf syscall 필터 적용 8. exec() 컨테이너 프로세스 실행 컨테이너가 사용하는 8개 네임스페이스 PID NS - 프로세스 ID 격리 (컨테이너 내부 PID 1) NET NS - 네트워크 스택 격리 (독립 인터페이스, 라우팅 테이블) MNT NS - 마운트 포인트 격리 (독립 파일시스템 뷰) USER NS - UID/GID 매핑 (rootless 컨테이너의 핵심) UTS NS - 호스트명/도메인명 격리 IPC NS - SysV IPC, POSIX MQ 격리 cgroup NS - cgroup 뷰 격리 (Linux 4.6+) time NS - CLOCK_MONOTONIC/BOOTTIME 격리 (Linux 5.6+) 각 네임스페이스의 커널 내부 구현은 namespaces.html 참조

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);
Docker 기본 seccomp 프로파일: Docker 19.x/20.x 기준으로 약 44개의 시스템 콜을 기본 차단합니다 (버전에 따라 변경될 수 있음). 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_NEWPIDPID namespace프로세스 ID — 컨테이너 내 init는 PID 1
CLONE_NEWNETNetwork namespace네트워크 인터페이스, 라우팅 테이블(Routing Table), iptables
CLONE_NEWNSMount namespace마운트(Mount) 포인트, pivot_root로 rootfs 교체
CLONE_NEWUTSUTS namespacehostname, domainname
CLONE_NEWIPCIPC namespaceSystem V IPC, POSIX 메시지 큐
CLONE_NEWUSERUser namespaceUID/GID 매핑 (Rootless Docker 핵심)
CLONE_NEWCGROUPcgroup namespacecgroup 루트 뷰

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=1gmemory.maxbytes 또는 "max"1073741824
--memory-swap=2gmemory.swap.maxbytes1073741824 (swap only)
--memory-reservation=512mmemory.lowbytes536870912
--cpus=0.5cpu.max"quota period"50000 100000
--cpu-shares=512cpu.weight1-10000 (기본 100)50 (512/1024*100)
--cpuset-cpus=0,1cpuset.cpusCPU 목록0-1
--cpuset-mems=0cpuset.memsNUMA 노드0
--pids-limit=100pids.max정수 또는 "max"100
--blkio-weight=300io.weight1-10000300
--device-read-bpsio.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 컨테이너 라이프사이클 상태 전이 creating config.json 파싱 환경 준비 중 created 환경 준비 완료 프로세스 미실행 running 컨테이너 프로세스 실행 중 stopped 프로세스 종료 리소스 미해제 삭제됨 create start exit delete OCI Hooks (선택적 실행 포인트) prestart hook created → running 전 네임스페이스 내부 네트워크 설정 등 poststart hook running 진입 직후 호스트 네임스페이스 로깅, 모니터링 등 poststop hook stopped 진입 후 호스트 네임스페이스 정리, 알림 전송 등 runc 실행 명령 runc create → created 상태 runc start → running 전이 runc delete → 리소스 해제 kill 명령은 시그널만 전달

OCI Image Specification

OCI 이미지는 content-addressable 스토리지 모델을 사용합니다. 각 레이어는 SHA256 다이제스트로 식별됩니다.

OCI Runtime Spec ↔ 커널 인터페이스 매핑

OCI Runtime Spec의 config.json 각 필드가 어떤 커널 syscall로 변환되는지 1:1로 정리합니다.

config.json 필드syscall / 커널 인터페이스세부 내용
linux.namespaces[].typeclone(CLONE_NEW*)pid/net/mnt/uts/ipc/user/cgroup namespace 생성
linux.resources.memory.limitcgroup v2: memory.max단위: bytes. -1이면 unlimited
linux.resources.memory.swapcgroup v2: memory.swap.maxswap 포함 총 메모리 한도
linux.resources.cpu.quotacgroup v2: cpu.max 첫 번째 값기본 period=100000µs
linux.resources.pids.limitcgroup v2: pids.max컨테이너 내 최대 프로세스 수
linux.seccompprctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER)BPF 프로그램으로 syscall 필터링
linux.capabilitiescapset(CAP_NET_ADMIN, ...)허용할 Linux capabilities 목록
process.user.uidsetuid() / setreuid()컨테이너 프로세스의 UID
process.user.gidsetgid() / setregid()컨테이너 프로세스의 GID
process.user.additionalGidssetgroups()보조 그룹 ID 목록
mounts[].source/targetmount(source, target, ...)bind mount 또는 tmpfs/proc/sys 마운트
root.pathpivot_root(new_root, put_old)rootfs 교체. chroot보다 안전
linux.maskedPathsmount("", path, "tmpfs", MS_BIND|MS_RDONLY)/proc/kcore 등 민감 경로 숨김
linux.readonlyPathsmount(path, path, "", MS_BIND|MS_RDONLY|MS_REMOUNT)/proc/sys 등 읽기 전용(Read-Only) 재마운트
linux.rlimitsprlimit64(RLIMIT_NOFILE, ...)프로세스별 리소스 제한
linux.sysctlwrite("/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비고
1namespace 생성clone(CLONE_NEWPID|CLONE_NEWNET|...)nsexec.c에서 Go 런타임 전에 처리
2uid/gid 매핑write("/proc/PID/uid_map")Rootless 시 newuidmap 사용
3cgroup 설정cgroup v2 파일 쓰기CLONE_INTO_CGROUP으로 대체 가능
4capabilities 설정prctl(PR_CAPBSET_DROP, ...)bounding set에서 불필요 cap 제거
5seccomp 필터 설치prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER)exec 전에 반드시 설치
6OverlayFS 마운트mount("overlay", merged, "overlay", ...)lowerdir/upperdir/workdir 설정
7바인드 마운트(Bind Mount)mount(src, dst, MS_BIND)볼륨, /proc, /sys, /dev 마운트
8rootfs 교체pivot_root(new_root, put_old)chroot가 아닌 pivot_root 사용
9AppArmor 프로필write("/proc/self/attr/exec", profile)exec 후 자동 적용
10컨테이너 init execexecve(entrypoint, args, env)runc 종료, init PID 1이 됨

컨테이너 런타임

컨테이너 런타임은 3계층으로 분류됩니다:

계층역할예시
CRI (인터페이스)오케스트레이터와 런타임 간 gRPC APIKubernetes CRI
High-level이미지 관리, 스냅샷, 네트워킹containerd, CRI-O
Low-level (OCI)실제 컨테이너 생성/실행runc, crun, youki
kubelet Kubernetes CRI containerd High-level Runtime exec shim-runc-v2 Runtime Shim exec runc OCI Runtime Kernel clone() Podman (Daemonless) podman → conmon → runc (데몬 없음) CRI-O kubelet → CRI-O → conmon → runc

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의 핵심 컨테이너 런타임입니다.

# 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와 달리 중앙 데몬이 없어 각 컨테이너가 독립적인 프로세스 트리를 가집니다.

# 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로 통신합니다.

User Space Kernel Space docker CLI /var/run/docker.sock REST dockerd API server / builder gRPC containerd image/snapshot 관리 shim API containerd-shim stdio 릴레이 / 재부모 fork/exec runc libcontainer nsexec.c + Go clone()/mount()/... Linux Namespaces pid/net/mnt/uts/ipc/user cgroup v2 cpu/memory/pids/io seccomp-bpf syscall 필터링 capabilities 권한 세분화 shim 프로세스: containerd 종료 후에도 생존 → 컨테이너 stdio 릴레이 유지 → containerd 재시작 시 재연결 runc: OCI 런타임 스펙 구현체. 실행 후 종료 (one-shot). shim이 컨테이너 생명주기 관리. 통신 프로토콜: docker CLI ↔ dockerd (REST/Unix socket) · dockerd ↔ containerd (gRPC) · containerd ↔ shim (ttrpc)

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명령 완료 후 종료
dockerdAPI 서버, 볼륨/네트워크/이미지 관리gRPC → containerd데몬으로 항상 실행
containerd이미지 pull/push, 스냅샷, 태스크(Task) 관리ttrpc → shim데몬으로 항상 실행
containerd-shim컨테이너 생명주기 관리, stdio 릴레이, exit 코드 수집fork/exec → runc컨테이너 종료까지 생존
runcOCI 스펙 실행: namespace 생성, cgroup 설정, pivot_root, execsyscall → 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를 설정합니다.

stopped (초기) created NS 생성됨 running 프로세스 실행 paused SIGSTOP Create() Start() Pause() Resume() Signal(SIGKILL) / exec 완료 Destroy() /proc/self/exe 재실행 트릭 (nsexec.c) runc 실행 → C 생성자(nsexec.c) 먼저 실행 → clone()/setns()로 NS 진입 → Go 런타임 초기화 → 컨테이너 init exec

/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가지 유형

유형사용 시나리오핵심 동작
initStandarddocker run — 새 컨테이너 생성clone() → namespace 생성 → pivot_root → exec(init)
initSetnsdocker exec — 실행 중 컨테이너에 프로세스 추가setns()로 기존 namespace에 합류 → exec(command)
initUsernsRootless 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를 합친 최종 마운트 포인트
merged (통합 뷰) upperdir (RW) - Container Layer lowerdir 3 (RO) - App Layer lowerdir 2 (RO) - Runtime Layer lowerdir 1 (RO) - Base Image Layer 파일 연산 READ : lower에서 검색 → 찾으면 반환 WRITE : copy-up → upper에 기록 DELETE: upper에 whiteout 파일 생성 CREATE: upper에 직접 생성 마운트 예시 mount -t overlay overlay \ -o lowerdir=/img/layer3:/img/layer2:/img/layer1,\ upperdir=/container/upper,\ workdir=/container/work \ /container/merged

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;
}
성능 주의: copy-up은 대용량 파일에서 심각한 지연을 유발할 수 있습니다. 예를 들어, lower에 있는 1GB 데이터베이스 파일을 처음 수정하면 전체 파일이 upper로 복사됩니다. 빈번히 수정되는 파일은 볼륨 마운트(-v)를 사용하여 OverlayFS를 우회하세요.

대안 스토리지 드라이버

드라이버특징사용 사례
overlay2다중 lower 레이어, 커널 4.0+Docker/containerd 기본
devicemapper블록 수준 thin provisioningRHEL 7 레거시
btrfs스냅샷 기반, CoW 파일시스템SUSE 계열
ZFS스냅샷/클론, 압축, 체크섬(Checksum)Ubuntu (실험적)
fuse-overlayfsFUSE 기반 overlayrootless (커널 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의 동명 디렉토리를 참조하지 않습니다.

삭제 전 upper (비어있음) lower: config.txt data/ README merged: config.txt data/ README rm rm config.txt 후 upper: ✖config.txt (whiteout c0,0) lower: config.txt data/ README merged: data/ README ✖ = whiteout (char device 0/0) | lower의 config.txt는 그대로 존재하지만 merged에서 불가시
# 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에 의해 숨겨짐
Tip: 백업 도구(rsync, tar 등)로 upper 디렉토리를 백업할 때, whiteout 장치 파일(0/0)과 opaque xattr을 올바르게 보존해야 합니다. 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=nofollowredirect 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-upmetacopy개선
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.cinode 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.ccopy-up 핵심 로직, metacopy 처리
readdir.c디렉토리 읽기, whiteout 필터링, merge sort
namei.c경로 탐색 (redirect, metacopy lookup)
util.c유틸리티 함수 (xattr 조작, dentry 헬퍼)
export.cNFS 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 배열 */
};
파일 수정 시 VFS → OverlayFS Copy-Up 흐름 파일 열기 vfs_open(merged_dentry) ovl_open() Copy-Up 수행 ovl_maybe_copy_up(dentry, O_WRONLY) ovl_copy_up_flags() ovl_copy_up_one() /* copy-up 시작 */ ovl_create_temp() /* workdir 임시파일 */ ovl_copy_up_data() /* 데이터 복사 */ Upper Layer 배치 vfs_rename() /* upper에 atomic 배치 */ open_with_fake_path(upper_file) Lower Layer (RO) 원본 파일 존재 변경 불가 (읽기 전용) Work Directory 임시 파일 생성 (atomic 보장) rename으로 upper로 이동 Upper Layer (RW) 복사된 파일 — 이후 쓰기 가능 xattr로 origin 추적 유지

xattr 기반 메타데이터

OverlayFS는 확장 속성(xattr)을 통해 레이어 간 관계와 파일 상태를 관리합니다. 모든 xattr은 trusted.overlay.* 네임스페이스에 저장됩니다.

xattr용도값 예시
trusted.overlay.opaqueopaque 디렉토리 표시"y"
trusted.overlay.redirect디렉토리 rename 원본 경로"/original/dir"
trusted.overlay.metacopy메타데이터만 copy-up됨 표시(빈 값)
trusted.overlay.impure디렉토리에 copy-up된 항목 존재"y"
trusted.overlay.origincopy-up 원본의 file handle(바이너리 fh)
trusted.overlay.upperupper에서 생성된 파일 표시(빈 값)
trusted.overlay.nlinkhardlink 보정 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_diron/off/follow/nofollow4.10+디렉토리 rename redirect 처리 방식
metacopyon/off4.19+메타데이터 전용 copy-up (데이터 지연 복사)
indexon/off4.13+inode index (hardlink copy-up, NFS export 지원)
nfs_exporton/off4.16+NFS file handle 지원 (index=on 필요)
xinoon/off/auto4.17+inode 번호에 레이어 비트 추가 (st_ino 충돌 방지)
volatile(플래그)5.6+sync/fsync 무시 (비정상 종료 시 데이터 손실 위험)
userxattr(플래그)5.11+user.overlay.* 네임스페이스 사용 (rootless용)
uuidon/off/null6.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

주요 옵션 상세 설명:

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 최소화 전략:

모니터링 메트릭도구경로/명령해석
upper 디스크 사용량du -shupperdir 경로copy-up 누적량 파악
inode 사용량df -iupper 파일시스템whiteout + copy-up 파일 수
copy-up 이벤트ftraceovl_copy_up_onecopy-up 빈도 추적
overlay 마운트 정보cat/proc/mounts마운트 옵션 확인
레이어 구성mount -ltype overlaylower/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-upmetacopy=on 활성화, 볼륨 마운트 사용
stat 불일치cross-layer inode 번호 충돌xino=on 활성화
NFS export 실패file handle 미지원index=on,nfs_export=on 마운트 옵션
rename EXDEVcross-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 기본값
sharedMS_SHARED호스트↔컨테이너 양방향 전파아니오
slaveMS_SLAVE호스트→컨테이너 단방향 전파아니오
privateMS_PRIVATE전파 없음 (독립)네임드 볼륨
rprivateMS_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()와 달리 마운트 네임스페이스와 함께 사용하면 이전 루트로 돌아갈 수 없어 완전한 격리가 가능합니다.

① CLONE_NEWNS 새 마운트 네임스페이스 생성 ② mount overlay lowerdir+upperdir → merged/ ③ mkdir put_old 이전 루트 임시 마운트 포인트 ④ sys_pivot_root() new_root ↔ / 교체 ⑤ chdir("/") 새 루트로 작업 디렉토리 변경 ⑥ umount(put_old) 이전 루트 마운트 해제 → 접근 차단 fs/namespace.c: sys_pivot_root() 커널 내부 경로 ① security_sb_pivotroot() — LSM 보안 훅 검사 ② lock_mount_hash() — 마운트 해시 테이블 잠금 ③ detach_mnt(old_root) / attach_mnt(new_root) — 마운트 트리 교체 필수 조건: CLONE_NEWNS로 격리된 마운트 네임스페이스 내에서만 유효 | 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 기본)

Host Network Namespace eth0 (물리 NIC) docker0 bridge (172.17.0.1/16) iptables NAT veth pair veth pair Container 1 (NET NS) eth0 (172.17.0.2) lo (127.0.0.1) default gw: 172.17.0.1 Container 2 (NET NS) eth0 (172.17.0.3) lo (127.0.0.1) default gw: 172.17.0.1
# Docker 브릿지 네트워크 확인
$ ip link show type bridge                    # docker0 확인
$ bridge link show                            # bridge에 연결된 veth 목록
$ ip netns list                               # 네트워크 네임스페이스 목록

# 수동 veth + bridge 구성 상세: network-namespaces.html#veth-setup 참조
veth 커널 구현: veth 쌍 생성·네임스페이스 이동·bridge 연결의 커널 수준 동작(veth_xmit, dev_change_net_namespace), macvlan/ipvlan 비교, 성능 벤치마크는 Network Namespace — veth 쌍으로 네임스페이스 연결에서 다룹니다.

Docker veth/bridge/netfilter 네트워킹 내부

Docker 기본 네트워킹은 veth 쌍 + docker0 브리지(Bridge) + iptables MASQUERADE로 구현됩니다. 패킷(Packet)이 컨테이너에서 인터넷으로 나가는 전체 경로를 추적합니다.

Container Network Namespace eth0 (veth0) 172.17.0.2/16 route default via 172.17.0.1 iptables: 컨테이너 전용 체인 veth pair Host Network Namespace veth1 (peer) docker0에 연결됨 docker0 (bridge) 172.17.0.1/16 netfilter hooks PREROUTING (DNAT) FORWARD (docker0→eth0) POSTROUTING (MASQUERADE) -j MASQUERADE --to-source eth0 eth0 (host) 192.168.1.x (MASQUERADE src IP) Internet 포트 포워딩 (-p 8080:80): DNAT PREROUTING: -d host-ip -p tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80 iptables 체인: DOCKER (nat), DOCKER-ISOLATION (filter), DOCKER-USER (filter) docker network inspect bridge → docker0 설정 확인 | ip netns exec로 NS 내부 확인
# 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 L2MAC 공유, L2 수준 분리MAC 수 제한 환경
ipvlan L3MAC 공유, L3 라우팅(Routing) 기반 분리대규모 컨테이너 환경
네트워크 관련 참조: 네트워크 네임스페이스와 라우팅 격리 상세는 네임스페이스, iptables/nftables NAT 규칙은 NATNetfilter 페이지를 참조하세요.

Docker 네트워크 드라이버

Docker는 플러그인 아키텍처로 여러 네트워크 드라이버를 제공합니다. 각 드라이버는 커널 수준에서 서로 다른 구현을 사용하며, 사용 목적에 따라 적절한 드라이버를 선택해야 합니다.

bridge (기본값) Container (172.17.0.2) eth0 ↕ veth pair docker0 bridge iptables MASQUERADE Host eth0 (NAT) 격리 + NAT | 기본 선택 host Container 호스트 Network NS 공유 Host eth0 (직접) 포트 포워딩 불필요 최고 성능 (NAT 없음) 포트 충돌 주의 overlay (Swarm) Container (10.0.0.2) VXLAN (VNI) UDP port 4789 multi-host L2 오버레이 Swarm/Kubernetes 사용 분산 컨테이너 통신 macvlan / ipvlan Container (192.168.1.x) L2 직통 (NAT 없음) macvlan: 별도 MAC 주소 ipvlan L2: 같은 MAC ipvlan L3: 라우팅 물리 네트워크 직접 연결 커널 구현 비교 bridge net/bridge/br_*.c veth pair 생성 netfilter MASQUERADE conntrack 추적 성능: 중간 host Network NS 미생성 socket이 호스트 NS NAT/veth 없음 커널 네트워크 스택 공유 성능: 최고 overlay drivers/net/vxlan.c VXLAN 헤더 인캡슐 IP-in-UDP 터널 etcd/swarm KV 백엔드 성능: 낮음 (인캡슐 오버헤드) macvlan/ipvlan drivers/net/macvlan.c drivers/net/ipvlan/ promiscuous 모드 불필요 물리 NIC에 직접 연결 성능: bridge와 유사, NAT 없음

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 부재), 유저스페이스 네트워크 스택을 사용합니다:

구현방식성능
slirp4netnslibslirp 기반 TAP 디바이스 에뮬레이션보통 (유저스페이스 TCP/IP 스택)
pasta호스트와 NS 간 패킷 복사 (splice)빠름 (Linux 5.7+)
RootlessKitslirp4netns/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 OverlayFS5.11+100% (커널 레벨)upperdir/lowerdir가 같은 FS 타입 권장
fuse-overlayfs4.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 사용 시
자동 감지: Podman 4.0+는 /proc/sys/kernel/unprivileged_userns_clone과 실제 mount(2) 시도를 통해 native overlay 지원 여부를 판별합니다. 별도 설정 없이 최적의 드라이버가 자동 선택되므로, 대부분의 경우 수동 설정은 불필요합니다.

Rootless Docker — newuidmap 커널 경로

Rootless Docker는 root 권한 없이 컨테이너를 실행합니다. 핵심은 user namespace + newuidmap/newgidmap을 통한 UID 매핑입니다.

Host UID Space UID 1000 (alice) = 실행 사용자 UID 100000-165535 (/etc/subuid) alice:100000:65536 /etc/subuid 항목 /etc/subgid 항목 newuidmap kernel validation Container UID Space (User Namespace) UID 0 (root) = Host UID 1000 UID 1-65535 = Host UID 100000-165534 /proc/PID/uid_map 0 1000 1 1 100000 65535 Kernel: newuidmap 1. newuidmap PID 설정 호출 2. /etc/subuid 권한 확인 (kernel/user_namespace.c) 3. proc_uid_map_write() 4. map_write() 검증 5. uid_map 설정 완료 rootlesskit — Rootless 컨테이너 네트워킹 해결 문제: User NS 내부에서는 veth/iptables 설정 불가 (NET_ADMIN 없음) 해결: rootlesskit이 슬레이브 프로세스로 host NS에서 네트워크 설정, tap 디바이스 + 포트 포워딩 docker run -p 8080:80 → rootlesskit이 host:8080 → container:80 포트 포워딩 설치: dockerd-rootless-setuptool.sh install | 실행: dockerd-rootless.sh
# 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 제한사항

컨테이너 보안

보안 계층 종합

계층기술보호 대상
프로세스 격리NamespacesPID/네트워크/마운트/사용자 가시성
리소스 제한cgroupsCPU, 메모리, I/O, PID 수
시스템 콜 필터seccomp-bpf커널 공격 표면 축소
권한 최소화Capabilitiesroot 권한 세분화
MACAppArmor / SELinux파일/네트워크/프로세스 접근 제어(Access Control)
읽기 전용 rootfsOverlayFS + readonly파일시스템 무결성(Integrity)
No New PrivilegesPR_SET_NO_NEW_PRIVSsetuid 바이너리를 통한 권한 상승

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_FSETIDsetuid/setgid 비트 설정기본 허용
CAP_FOWNER소유자 확인 우회기본 허용
CAP_MKNOD특수 파일(device) 생성기본 허용
CAP_NET_RAWRAW/PACKET 소켓 사용 (ping 등)기본 허용
CAP_SETGID프로세스 GID 변경기본 허용
CAP_SETUID프로세스 UID 변경기본 허용
CAP_SETFCAP파일 capabilities 설정기본 허용
CAP_SETPCAP허용 집합에서 상속 집합으로 capability 이동기본 허용
CAP_NET_BIND_SERVICE1024 미만 포트 바인드기본 허용
CAP_SYS_CHROOTchroot() syscall 사용기본 허용
CAP_KILL다른 UID 프로세스에 시그널(Signal) 전송기본 허용
CAP_AUDIT_WRITE커널 audit 로그에 쓰기기본 허용

Docker 기본 차단 Capabilities (위험 기능)

Capability위험 이유필요 시 추가 방법
CAP_SYS_ADMINmount/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_BOOTreboot(), kexec_load()보안 위험 — 사용 자제
CAP_SYS_RAWIOI/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_ADMINmount, BPF, namespace 생성 등 광범위 권한--cap-drop=ALL, 필요 cap만 추가
커널 취약점(Vulnerability)CVE를 통한 호스트 커널 직접 공격seccomp 필터, 커널 업데이트, gVisor/Kata
Docker 소켓 마운트/var/run/docker.sock으로 호스트 컨테이너 제어소켓 마운트 금지, rootless 사용
--privileged 플래그모든 보안 장치 비활성화절대 사용 금지, 대안 capability 사용

컨테이너 보안 주요 CVE 사례

컨테이너 탈출과 권한 상승 취약점의 실제 사례를 분석합니다. 컨테이너 런타임, OverlayFS, cgroup 등 다양한 계층에서 발견된 취약점들이 컨테이너 보안 아키텍처의 발전을 이끌어왔습니다.

CVE-2019-5736 — runc 컨테이너 탈출 (CVSS 8.6):

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()로
 *       메모리에 복사 후 실행 (호스트 파일 직접 참조 방지)
 */
CVE-2022-0492 — cgroup v1 release_agent 컨테이너 탈출:

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 쓰기 차단
 */
CVE-2023-0386 — OverlayFS setuid 권한 상승:

OverlayFS의 lower layer에 FUSE 파일시스템을 마운트하면, FUSE에서 setuid 비트가 설정된 파일을 제공할 수 있습니다. OverlayFS가 이 파일을 upper layer로 copy-up할 때 setuid 비트가 유지되어, 일반 사용자가 user namespace 내에서 root 소유의 setuid 바이너리를 생성할 수 있습니다.

CVE-2024-21626 — runc 작업 디렉터리 컨테이너 탈출:

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
컨테이너 보안 CVE 타임라인과 교훈:

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 수준의 격리와 컨테이너 수준의 사용자 경험을 결합합니다.

Host (containerd + Kata shimv2) Lightweight VM (QEMU / Cloud Hypervisor) Guest Linux Kernel (경량 커널, ~100ms 부팅) kata-agent (gRPC) virtiofs (파일시스템 공유) virtio-net (네트워킹) Container 1 rootfs (virtiofs) Application Container 2 rootfs (virtiofs) Application Kata 특성 VM 부팅: ~100-200ms 메모리: ~20-40MB (게스트 커널) hypervisor: QEMU / CLH / FC 격리: VM 수준 (hw virt)

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)

옵션설명기본값
hypervisorQEMU, Cloud Hypervisor, FirecrackerQEMU
default_memoryVM 메모리 (MB)256
default_vcpusVM vCPU 수1
shared_fs파일시스템 공유 방식virtiofs
kernel_path게스트 커널 경로/usr/share/kata-containers/vmlinux
enable_debug디버그 모드false
virtiofs 상세: Kata가 사용하는 virtiofs 프로토콜의 커널 내부 구현은 FUSE 페이지의 virtiofs 섹션을 참조하세요. KVM 가상화 내부는 가상화 (KVM) 페이지를 참조하세요.

최소 컨테이너 직접 구현

컨테이너의 내부 동작을 이해하기 위해, 셸 스크립트와 커널 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 비교

런타임언어컨테이너 시작 지연메모리 사용량특징
runcGo~200ms~15MBDocker/Kubernetes 기본값, 안정성 최우선
crunC~20ms (10x 빠름)~2MBCRIU 통합, cgroup v2 완전 지원, RHEL 기본값
youkiRust~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)

/* 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 요구사항

항목cgroup v1cgroup v2
마운트 구조/sys/fs/cgroup/{controller}/ (다중 마운트)/sys/fs/cgroup/ (단일 통합 트리)
위임 방식디렉토리 생성 권한 필요, 컨트롤러별 별도 위임서브트리 소유권 + cgroup.subtree_control 쓰기
Docker 기본--cgroup-parent로 하위 디렉토리 할당--cgroupns=private + systemd 드라이버 권장
systemd 호환부분 지원 (hybrid 모드 필요)완전 지원 (권장)

cgroup 네임스페이스 모드

# 컨테이너 내부에서 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.mountHuge Pages 마운트 불필요hugetlbfs — 호스트 커널 설정에 의존
sys-fs-fuse-connections.mountFUSE 연결 파일시스템 불필요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=hostCLONE_NEWCGROUP 미사용 — 호스트 cgroup 트리 공유cgroup v2 서브트리 쓰기 권한 확보 (가장 확실한 방법)
-v /sys/fs/cgroup:rwcgroup2 파일시스템 읽기/쓰기 마운트서비스별 cgroup 생성 및 컨트롤러 설정
--tmpfs /runmount -t tmpfs tmpfs /runsystemd 런타임 소켓, PID 파일, 상태 파일 저장 (/run/systemd/)
--tmpfs /run/lockmount -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가 사용하는 시스템 콜 허용
SIGRTMIN+3의 의미:

일반적인 프로세스는 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:rwprivate 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/cgroupcgroup 파일시스템 쓰기 권한 없음-v /sys/fs/cgroup:/sys/fs/cgroup:rw 또는 --privileged
Failed to create /init.scope control groupcgroup 서브트리 생성 권한 없음--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 KBSIGTERM → 자식 전달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)는 실행 중인 컨테이너의 전체 상태를 파일로 저장하고, 나중에 동일하거나 다른 호스트에서 복원할 수 있는 기술입니다. 라이브 마이그레이션, 빠른 시작, 컨테이너 스냅샷에 활용됩니다.

Checkpoint (Dump) 단계 docker checkpoint create mycontainer cp1 SIGSTOP → freeze 프로세스 일시 중지 parasite 인젝션 /proc/PID/mem 덤프 pages/regs/fds/mmap Restore 단계 criu restore 이미지 파일 로드 fork + namespace 동일 PID/NS 재생성 메모리 복원 SIGCONT → 재개 중단 지점부터 계속 이미지 파일 pages-*.img CRIU 체크포인트 이미지 파일 구성 pages-1.img: 프로세스 메모리 페이지 (/proc/PID/mem 직접 읽기) core-PID.img: 레지스터 상태 (rip, rsp, rbp, 범용 레지스터) mm-PID.img: 메모리 맵 (mmap 영역, 스택/힙/코드 주소) files.img: 열린 파일 디스크립터, 소켓 상태 parasite 기법: ptrace()로 컨테이너 프로세스에 작은 코드 인젝션 → 내부 상태 수집 라이브 마이그레이션: dump → 이미지 전송 → restore로 다운타임 없이 컨테이너 이동 가능

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 inspectDocker컨테이너 상세 정보 (설정, 네트워크, 마운트)
crictlCRI 런타임Kubernetes 노드의 컨테이너/파드 디버깅
ctrcontainerdcontainerd 직접 제어 (이미지, 컨테이너, 태스크)
nerdctlcontainerdDocker-호환 CLI (compose 지원)
podman inspectPodmanDocker와 동일한 inspect 인터페이스
runc list/stateruncOCI 런타임 수준 상태 확인
# 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 137memory.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

Man Pages

LWN Articles

Container Runtime References

Kernel Source

Linux Containers와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.