Docker 커널 내부 심화

Docker를 커널 기능 조합으로 구현된 컨테이너 런타임 스택 관점에서 심층 분석합니다. dockerd→containerd→shim→runc IPC 체인과 책임 분리, libcontainer가 생성하는 namespace/cgroup/mount 시퀀스, OCI spec과 커널 시스템 콜 매핑, rootless Docker의 user namespace/newuidmap 경로, seccomp-bpf·capability·AppArmor/SELinux 보안 프로파일, veth/bridge/netfilter 네트워킹 내부, OverlayFS copy-up과 이미지 레이어 성능 특성, cgroup v2 자원 제한 및 계측, bpftrace/ftrace로 병목을 찾는 실전 절차까지 운영 관점 핵심을 다룹니다.

전제 조건: Linux Containers 심화, 네임스페이스, cgroups v1/v2 문서를 먼저 읽으세요. 이 문서는 Docker의 커널 내부 구현에 집중하며, 컨테이너 격리 기본 개념은 이미 알고 있다고 가정합니다.
일상 비유: Docker 아키텍처는 배달 플랫폼과 비슷합니다. dockerd는 배달 앱(주문 접수), containerd는 배달원 관리 시스템, containerd-shim은 개별 배달원, runc는 오토바이, 커널은 도로입니다. 배달 앱(dockerd)이 꺼져도 배달원(shim)은 계속 배달(컨테이너 실행)을 완료합니다.

핵심 요약

  • 4계층 IPC — docker CLI → dockerd → containerd → shim → runc → 커널 syscall 경로를 이해합니다.
  • shim 역할 — containerd-shim은 containerd 재시작 후에도 컨테이너를 유지하는 고아 방지 프로세스입니다.
  • libcontainer — runc의 Go 라이브러리. nsexec.c의 /proc/self/exe 트릭으로 Go 런타임 전에 namespace를 설정합니다.
  • OCI ↔ 커널 매핑 — config.json의 각 필드가 정확히 어떤 syscall로 변환되는지 1:1로 매핑됩니다.
  • veth/bridge/NAT — 컨테이너 네트워킹은 veth 쌍 + docker0 브리지 + iptables MASQUERADE로 구성됩니다.
  • OverlayFS copy-up — 이미지 레이어(lowerdir)의 파일을 처음 수정할 때만 upperdir로 복사(copy-up)합니다.
  • Rootless newuidmap — root 없이도 user namespace + uid_map을 통해 컨테이너 내 UID 0을 구현합니다.
  • seccomp-bpf — Docker 기본 프로필은 ~300개 syscall을 허용하고 ~50개를 차단하는 BPF 프로그램입니다.
  • pivot_root() — chroot()와 달리 mount namespace와 함께 사용하면 rootfs를 완전히 교체하여 이전 루트로 돌아갈 수 없어 호스트 파일시스템 탈출이 불가능합니다.
  • capabilities — Docker는 14개 capabilities를 부여하고 나머지를 차단합니다. CAP_SYS_ADMIN이 없으면 mount/ptrace 등 위험한 조작이 불가하여 컨테이너 탈출이 매우 어렵습니다.

단계별 이해

  1. IPC 경로 추적
    docker run 명령이 dockerd → containerd → shim → runc → kernel로 전달되는 gRPC/fork/exec 경로를 따라갑니다.
  2. syscall 레벨 이해
    clone(CLONE_NEWPID|CLONE_NEWNET|...)가 어떻게 격리를 만드는지, setns()로 기존 namespace에 합류하는 방법을 이해합니다.
  3. libcontainer 상태 머신
    컨테이너가 stopped→created→running→paused 상태를 어떻게 전환하는지, /proc/self/exe 트릭의 동작 원리를 파악합니다.
  4. 네트워킹 경로
    컨테이너에서 패킷이 veth0 → docker0 → iptables MASQUERADE → eth0 → 인터넷으로 전달되는 경로를 이해합니다.
  5. 리소스 제한 확인
    docker run --memory=1g --cpus=0.5 명령이 cgroup v2의 어떤 파일에 어떤 값을 쓰는지 직접 확인합니다.
  6. 진단 도구 활용
    nsenter, bpftrace, ftrace로 실행 중인 컨테이너의 커널 활동을 실시간으로 추적합니다.
  7. 보안 레이어 이해
    seccomp-bpf + capabilities + AppArmor/SELinux가 어떻게 겹겹이 적용되는지 이해하고, 최소 권한 원칙에 따른 컨테이너 보안 프로필 작성 방법을 학습합니다.
  8. 체크포인트/복원
    CRIU로 실행 중인 컨테이너를 저장하고 다른 호스트에서 재시작하는 원리를 이해합니다. /proc/PID/mem 덤프와 parasite 코드 인젝션 메커니즘을 파악합니다.

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, 스냅샷, 태스크 관리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 도메인 소켓을 통해 통신합니다. 메모리 사용량이 gRPC보다 훨씬 적고 지연 시간이 낮아 수천 개의 컨테이너가 있는 환경에서도 효율적입니다.

# 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

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네트워크 인터페이스, 라우팅 테이블, iptables
CLONE_NEWNSMount namespace마운트 포인트, 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 파일 디스크립터와 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 공유 중

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//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"`
}

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 등 읽기 전용 재마운트
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바인드 마운트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이 됨

veth/bridge/netfilter 네트워킹 내부

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

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로 변환됨

Docker OverlayFS — copy-up 원리

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)

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   # 캐시 미스 → 다시 실행

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'

Rootless Docker 심화

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

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 — 사용자 공간 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 이미지 레이어와 BuildKit

OCI Image Spec v1.0은 이미지를 content-addressable store로 정의합니다. 각 레이어는 sha256 해시로 참조됩니다.

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...                            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)
}

Linux Capabilities 실전 — Docker 권한 모델

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 프로세스에 시그널 전송기본 허용
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커널 모듈 로드/언로드 (호스트 커널 조작)보안 위험 — 사용 자제
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

컨테이너 런타임 설정 심화

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과 성능 유사

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: 레지스터 상태 확인

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 */

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

진단 — nsenter/bpftrace/ftrace

실행 중인 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 위반 의심

Docker 커널 내부와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요. Docker의 각 구성 요소는 독립적인 커널 서브시스템을 활용하므로, 각 서브시스템의 깊은 이해가 Docker 운영 및 최적화에 필수적입니다.

학습 순서 제안:
  1. 네임스페이스cgroups v2Containers 심화 순으로 기초를 다진 후 이 문서로 돌아오세요.
  2. 보안 강화를 원한다면 커널 보안의 AppArmor + LSM 훅 경로를 학습하세요.
  3. 네트워크 성능 최적화를 원한다면 eBPF 기반 보안 정책의 XDP/eBPF 컨테이너 네트워킹을 참고하세요.
다음 학습: Docker 컨테이너의 보안 강화를 위해 커널 보안의 AppArmor 프로필과 Netfilter의 Docker 네트워크 격리 정책을 함께 학습하세요. 고성능 컨테이너 네트워킹이 목표라면 eBPF 기반 보안 정책의 Cilium/XDP 관련 내용을 참고하세요.