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로 병목을 찾는 실전 절차까지 운영 관점 핵심을 다룹니다.
핵심 요약
- 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 등 위험한 조작이 불가하여 컨테이너 탈출이 매우 어렵습니다.
단계별 이해
- IPC 경로 추적
docker run명령이 dockerd → containerd → shim → runc → kernel로 전달되는 gRPC/fork/exec 경로를 따라갑니다. - syscall 레벨 이해
clone(CLONE_NEWPID|CLONE_NEWNET|...)가 어떻게 격리를 만드는지,setns()로 기존 namespace에 합류하는 방법을 이해합니다. - libcontainer 상태 머신
컨테이너가 stopped→created→running→paused 상태를 어떻게 전환하는지, /proc/self/exe 트릭의 동작 원리를 파악합니다. - 네트워킹 경로
컨테이너에서 패킷이 veth0 → docker0 → iptables MASQUERADE → eth0 → 인터넷으로 전달되는 경로를 이해합니다. - 리소스 제한 확인
docker run --memory=1g --cpus=0.5명령이 cgroup v2의 어떤 파일에 어떤 값을 쓰는지 직접 확인합니다. - 진단 도구 활용
nsenter, bpftrace, ftrace로 실행 중인 컨테이너의 커널 활동을 실시간으로 추적합니다. - 보안 레이어 이해
seccomp-bpf + capabilities + AppArmor/SELinux가 어떻게 겹겹이 적용되는지 이해하고, 최소 권한 원칙에 따른 컨테이너 보안 프로필 작성 방법을 학습합니다. - 체크포인트/복원
CRIU로 실행 중인 컨테이너를 저장하고 다른 호스트에서 재시작하는 원리를 이해합니다. /proc/PID/mem 덤프와 parasite 코드 인젝션 메커니즘을 파악합니다.
Docker 데몬 아키텍처
Docker는 단일 프로세스가 아닌 4계층 분리 아키텍처로 동작합니다. 각 계층은 별도 프로세스로 실행되며 gRPC 또는 fork/exec로 통신합니다.
containerd → shim IPC 초기화 상세
# containerd가 새 컨테이너 생성 시 shim 프로세스 시작 순서
# 1. containerd가 shim 바이너리 실행
$ /usr/bin/containerd-shim-runc-v2 \
-namespace moby \
-id <container-id> \
-address /run/containerd/containerd.sock \
start # "start" 명령: shim 초기화
# 2. shim이 ttrpc 소켓 바인드 후 자신의 주소 출력 (containerd가 읽음)
# 출력: unix:///run/containerd/s/abc123def456
# 3. containerd가 shim 소켓에 연결하여 Task.Create 요청 (ttrpc)
# 4. shim이 runc create 실행 → namespace 생성 → 컨테이너 init 대기
# 5. containerd가 Task.Start 요청 → shim이 runc start → exec(init)
# shim v2 프로토콜: containerd/runtime/v2/task/shim.proto
# 주요 RPC: Create / Start / Delete / Exec / Kill / Wait / State / Pause / Resume
# shim이 containerd 없이도 동작함을 확인
$ kill -9 $(pgrep containerd) # containerd 강제 종료
$ ps aux | grep containerd-shim # shim은 계속 실행 중
$ docker ps # containerd 재시작 후 컨테이너 상태 보존 확인
각 계층의 역할
| 컴포넌트 | 역할 | IPC 방법 | 프로세스 생존 |
|---|---|---|---|
| docker CLI | 사용자 명령 처리, REST 요청 생성 | HTTP REST / Unix socket | 명령 완료 후 종료 |
| dockerd | API 서버, 볼륨/네트워크/이미지 관리 | gRPC → containerd | 데몬으로 항상 실행 |
| containerd | 이미지 pull/push, 스냅샷, 태스크 관리 | ttrpc → shim | 데몬으로 항상 실행 |
| containerd-shim | 컨테이너 생명주기 관리, stdio 릴레이, exit 코드 수집 | fork/exec → runc | 컨테이너 종료까지 생존 |
| runc | OCI 스펙 실행: namespace 생성, cgroup 설정, pivot_root, exec | syscall → kernel | 컨테이너 init 실행 후 종료 |
Docker 1.11(2016)부터 이 분리 아키텍처가 도입되었습니다. 핵심 이유는 containerd 재시작 시에도 실행 중인 컨테이너를 보존하기 위함입니다. shim이 컨테이너 PID 1의 부모 역할을 하므로, containerd가 재시작되어도 컨테이너는 계속 실행됩니다.
ttrpc — containerd ↔ shim 프로토콜
ttrpc는 gRPC의 경량 버전으로, containerd와 shim 사이에서 Unix 도메인 소켓을 통해 통신합니다. 메모리 사용량이 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_NEWPID | PID namespace | 프로세스 ID — 컨테이너 내 init는 PID 1 |
CLONE_NEWNET | Network namespace | 네트워크 인터페이스, 라우팅 테이블, iptables |
CLONE_NEWNS | Mount namespace | 마운트 포인트, pivot_root로 rootfs 교체 |
CLONE_NEWUTS | UTS namespace | hostname, domainname |
CLONE_NEWIPC | IPC namespace | System V IPC, POSIX 메시지 큐 |
CLONE_NEWUSER | User namespace | UID/GID 매핑 (Rootless Docker 핵심) |
CLONE_NEWCGROUP | cgroup namespace | cgroup 루트 뷰 |
unshare() — 기존 프로세스에서 새 namespace로 분리
/* kernel/fork.c */
int ksys_unshare(unsigned long unshare_flags)
{
/* 새 namespace 객체 생성 */
unshare_nsproxy_namespaces(unshare_flags, &new_nsproxy, ...);
/* 현재 태스크에 새 nsproxy 적용 */
task_lock(current);
current->nsproxy = new_nsproxy;
task_unlock(current);
return 0;
}
# unshare 사용 예: 루트 없이 user+net+pid namespace 생성
$ unshare --user --net --pid --mount-proc --fork bash
$ id
uid=0(root) gid=0(root) groups=0(root) # 컨테이너 내에서 root처럼 보임
$ ip link show
1: lo: <LOOPBACK> mtu 65536 ... # 격리된 네트워크 — loopback만 존재
struct nsproxy — 6개 네임스페이스 포인터
/* kernel/nsproxy.c — 태스크가 가진 모든 네임스페이스 참조 */
struct nsproxy {
atomic_t count; /* 참조 카운트 */
struct uts_namespace *uts_ns; /* hostname, domainname */
struct ipc_namespace *ipc_ns; /* SysV IPC, POSIX 메시지큐 */
struct mnt_namespace *mnt_ns; /* 마운트 트리 */
struct pid_namespace *pid_ns_for_children; /* 자식 PID NS */
struct net *net_ns; /* 네트워크 스택 */
struct time_namespace *time_ns; /* 클럭 오프셋 (Linux 5.6+) */
struct time_namespace *time_ns_for_children;
struct cgroup_namespace *cgroup_ns; /* cgroup 루트 뷰 */
};
/* init_nsproxy: 초기 프로세스(PID 1)가 공유하는 기본 nsproxy */
struct nsproxy init_nsproxy = {
.count = ATOMIC_INIT(1),
.uts_ns = &init_uts_ns,
.ipc_ns = &init_ipc_ns,
.mnt_ns = &init_mnt_ns,
.pid_ns_for_children = &init_pid_ns,
.net_ns = &init_net,
.time_ns = &init_time_ns,
.cgroup_ns = &init_cgroup_ns,
};
clone3() — 확장 플래그 + cgroup 직접 설정 (Linux 5.3+)
/* clone3(): clone()의 확장 버전 — 구조체로 플래그 전달 */
struct clone_args {
uint64_t flags; /* CLONE_* 플래그 */
uint64_t pidfd; /* pidfd 반환용 포인터 */
uint64_t child_tid; /* CLONE_CHILD_SETTID */
uint64_t parent_tid; /* CLONE_PARENT_SETTID */
uint64_t exit_signal; /* 부모에게 보낼 시그널 */
uint64_t stack;
uint64_t stack_size;
uint64_t tls;
uint64_t set_tid; /* 특정 PID 지정 */
uint64_t set_tid_size;
uint64_t cgroup; /* CLONE_INTO_CGROUP: 특정 cgroup에서 시작 (Linux 5.7) */
};
/* CLONE_INTO_CGROUP: 생성 즉시 지정 cgroup에 진입 (cgroup_fd 필요) */
int cgroup_fd = open("/sys/fs/cgroup/docker/mycontainer", O_RDONLY);
struct clone_args args = {
.flags = CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | CLONE_INTO_CGROUP,
.cgroup = cgroup_fd,
};
pid_t child = (pid_t)syscall(__NR_clone3, &args, sizeof(args));
/* 자식은 clone() 즉시 지정 cgroup 소속 — 기존 clone() + write(cgroup.procs) 2단계를 1단계로 */
setns() — 기존 namespace에 합류
/* kernel/nsproxy.c */
int ksys_setns(int fd, int nstype)
{
struct ns_common *ns;
struct file *file = fget(fd);
ns = get_proc_ns(file_inode(file)); /* /proc/PID/ns/net 등 파일에서 추출 */
ns->ops->install(nstype_info, ns); /* 해당 namespace에 현재 태스크 합류 */
return 0;
}
/* nsenter 유사 구현 예 */
int main(void) {
int fd = open("/proc/12345/ns/net", O_RDONLY);
setns(fd, CLONE_NEWNET); /* 컨테이너 net namespace에 진입 */
close(fd);
execv("/bin/ip", args); /* 컨테이너 네트워크에서 실행 */
}
namespace 파일 디스크립터와 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를 설정합니다.
/proc/self/exe 재실행 트릭 (nsexec.c)
Go 런타임은 multi-thread 환경에서 setns() 호출 시 문제가 발생합니다. libcontainer는 이를 C 생성자 함수로 우회합니다:
/* libcontainer/nsenter/nsexec.c */
/* __attribute__((constructor))으로 Go 런타임 전에 실행 */
__attribute__((constructor)) void nsexec(void)
{
int pipenum;
char *_nsenter_init = getenv("_LIBCONTAINER_INITPIPE");
if (_nsenter_init == NULL)
return; /* 환경변수 없으면 바이패스 (일반 runc 실행) */
pipenum = atoi(_nsenter_init);
/* 3단계 fork: parent → child1(user NS) → child2(기타 NS들) */
switch (setjmp(env)) {
case JUMP_PARENT:
update_uid_map(pid); /* /proc/PID/uid_map 쓰기 */
update_gid_map(pid); /* /proc/PID/gid_map 쓰기 */
break;
case JUMP_CHILD:
setns(fd, CLONE_NEWUSER); /* user namespace 진입 */
clone(..., CLONE_NEWPID|CLONE_NEWNET|CLONE_NEWNS|...);
break;
}
exit(0); /* C 생성자 완료 → Go 런타임 시작 */
}
init process 3가지 유형
| 유형 | 사용 시나리오 | 핵심 동작 |
|---|---|---|
initStandard | docker run — 새 컨테이너 생성 | clone() → namespace 생성 → pivot_root → exec(init) |
initSetns | docker exec — 실행 중 컨테이너에 프로세스 추가 | setns()로 기존 namespace에 합류 → exec(command) |
initUserns | Rootless Docker — user namespace 생성 필요 | user NS 먼저 생성 → uid_map 설정 → 나머지 NS 생성 |
// libcontainer/container_linux.go: init process 선택 로직
func (c *linuxContainer) newParentProcess(p *Process) (parentProcess, error) {
parentInitPipe, childInitPipe, _ := utils.NewSockPair("init")
if p.Init {
return c.newInitProcess(p, parentInitPipe, childInitPipe)
}
// docker exec: 기존 컨테이너에 exec
return c.newSetnsProcess(p, parentInitPipe, childInitPipe)
}
func (c *linuxContainer) newInitProcess(p *Process, ...) (*initProcess, error) {
// _LIBCONTAINER_INITPIPE 환경변수로 nsexec.c 트리거
cmd.Env = append(cmd.Env, "_LIBCONTAINER_INITPIPE="+strconv.Itoa(childInitPipe.Fd()))
if c.config.RootlessEUID {
return c.newUsernsProcess(p, ...) // Rootless: initUserns
}
return &initProcess{...}, nil // 일반: initStandard
}
state.json — 컨테이너 상태 파일
# state.json 경로 (containerd가 관리)
$ cat /run/containerd/runc/default/<container-id>/state.json
{
"id": "abc123",
"init_process_pid": 12345,
"init_process_start": 1234567890,
"created": "2024-01-01T00:00:00Z",
"config": { /* OCI config.json 전체 */ },
"rootless": false,
"cgroup_paths": {
"": "/sys/fs/cgroup/system.slice/docker-abc123.scope"
},
"namespace_paths": {
"NEWNET": "/proc/12345/ns/net",
"NEWPID": "/proc/12345/ns/pid",
"NEWNS": "/proc/12345/ns/mnt",
"NEWUTS": "/proc/12345/ns/uts",
"NEWIPC": "/proc/12345/ns/ipc",
"NEWUSER": "/proc/12345/ns/user"
},
"external_descriptors": ["/dev/pts/0"]
}
Factory 인터페이스
// libcontainer/factory_linux.go
type Factory interface {
Create(id string, config *configs.Config) (Container, error)
Load(id string) (Container, error)
StartInitialization() error
}
type Container interface {
ID() string
Status() (Status, error)
State() (*State, error)
Config() configs.Config
Run(process *Process) error
Start(process *Process) error
Exec() error
Pause() error
Resume() error
Destroy() error
Signal(s os.Signal, all bool) error
}
// 상태 파일: /run/containerd/runc//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[].type | clone(CLONE_NEW*) | pid/net/mnt/uts/ipc/user/cgroup namespace 생성 |
linux.resources.memory.limit | cgroup v2: memory.max | 단위: bytes. -1이면 unlimited |
linux.resources.memory.swap | cgroup v2: memory.swap.max | swap 포함 총 메모리 한도 |
linux.resources.cpu.quota | cgroup v2: cpu.max 첫 번째 값 | 기본 period=100000µs |
linux.resources.pids.limit | cgroup v2: pids.max | 컨테이너 내 최대 프로세스 수 |
linux.seccomp | prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER) | BPF 프로그램으로 syscall 필터링 |
linux.capabilities | capset(CAP_NET_ADMIN, ...) | 허용할 Linux capabilities 목록 |
process.user.uid | setuid() / setreuid() | 컨테이너 프로세스의 UID |
process.user.gid | setgid() / setregid() | 컨테이너 프로세스의 GID |
process.user.additionalGids | setgroups() | 보조 그룹 ID 목록 |
mounts[].source/target | mount(source, target, ...) | bind mount 또는 tmpfs/proc/sys 마운트 |
root.path | pivot_root(new_root, put_old) | rootfs 교체. chroot보다 안전 |
linux.maskedPaths | mount("", path, "tmpfs", MS_BIND|MS_RDONLY) | /proc/kcore 등 민감 경로 숨김 |
linux.readonlyPaths | mount(path, path, "", MS_BIND|MS_RDONLY|MS_REMOUNT) | /proc/sys 등 읽기 전용 재마운트 |
linux.rlimits | prlimit64(RLIMIT_NOFILE, ...) | 프로세스별 리소스 제한 |
linux.sysctl | write("/proc/sys/...", value) | 네트워크 NS 스코프 sysctl만 허용 |
// OCI config.json 예시 (일부)
{
"ociVersion": "1.0.2",
"process": {
"user": { "uid": 1000, "gid": 1000 },
"capabilities": {
"bounding": ["CAP_NET_BIND_SERVICE", "CAP_KILL", "CAP_AUDIT_WRITE"]
}
},
"linux": {
"namespaces": [
{ "type": "pid" }, // → clone(CLONE_NEWPID)
{ "type": "network", "path": "/var/run/netns/existing" } // → setns()
],
"resources": {
"memory": { "limit": 536870912 }, // → memory.max = 512MB
"cpu": { "quota": 50000, "period": 100000 } // → cpu.max "50000 100000"
},
"seccomp": { "defaultAction": "SCMP_ACT_ERRNO", "syscalls": [...] }
}
}
runc OCI 스펙 처리 커널 실행 순서
runc가 config.json을 파싱하여 실제 커널 syscall을 호출하는 순서를 정리합니다:
| 단계 | runc 동작 | 커널 syscall | 비고 |
|---|---|---|---|
| 1 | namespace 생성 | clone(CLONE_NEWPID|CLONE_NEWNET|...) | nsexec.c에서 Go 런타임 전에 처리 |
| 2 | uid/gid 매핑 | write("/proc/PID/uid_map") | Rootless 시 newuidmap 사용 |
| 3 | cgroup 설정 | cgroup v2 파일 쓰기 | CLONE_INTO_CGROUP으로 대체 가능 |
| 4 | capabilities 설정 | prctl(PR_CAPBSET_DROP, ...) | bounding set에서 불필요 cap 제거 |
| 5 | seccomp 필터 설치 | prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER) | exec 전에 반드시 설치 |
| 6 | OverlayFS 마운트 | mount("overlay", merged, "overlay", ...) | lowerdir/upperdir/workdir 설정 |
| 7 | 바인드 마운트 | mount(src, dst, MS_BIND) | 볼륨, /proc, /sys, /dev 마운트 |
| 8 | rootfs 교체 | pivot_root(new_root, put_old) | chroot가 아닌 pivot_root 사용 |
| 9 | AppArmor 프로필 | write("/proc/self/attr/exec", profile) | exec 후 자동 적용 |
| 10 | 컨테이너 init exec | execve(entrypoint, args, env) | runc 종료, init PID 1이 됨 |
veth/bridge/netfilter 네트워킹 내부
Docker 기본 네트워킹은 veth 쌍 + docker0 브리지 + iptables MASQUERADE로 구현됩니다. 패킷이 컨테이너에서 인터넷으로 나가는 전체 경로를 추적합니다.
# 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=1g | memory.max | bytes 또는 "max" | 1073741824 |
--memory-swap=2g | memory.swap.max | bytes | 1073741824 (swap only) |
--memory-reservation=512m | memory.low | bytes | 536870912 |
--cpus=0.5 | cpu.max | "quota period" | 50000 100000 |
--cpu-shares=512 | cpu.weight | 1-10000 (기본 100) | 50 (512/1024*100) |
--cpuset-cpus=0,1 | cpuset.cpus | CPU 목록 | 0-1 |
--cpuset-mems=0 | cpuset.mems | NUMA 노드 | 0 |
--pids-limit=100 | pids.max | 정수 또는 "max" | 100 |
--blkio-weight=300 | io.weight | 1-10000 | 300 |
--device-read-bps | io.max | "major:minor rbps=N" | 8:0 rbps=10485760 |
# docker run 실행 후 cgroup v2 확인
$ docker run -d --name=myapp --memory=512m --cpus=0.5 --pids-limit=50 nginx
# 컨테이너의 cgroup 경로 찾기
$ CGPATH=$(cat /proc/$(docker inspect -f '{{.State.Pid}}' myapp)/cgroup | grep "^0::" | cut -d: -f3)
$ echo "/sys/fs/cgroup${CGPATH}"
/sys/fs/cgroup/system.slice/docker-abc123.scope
# 리소스 제한 확인
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.max
536870912
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.max
50000 100000
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/pids.max
50
# PSI (Pressure Stall Information) 메모리 압박 모니터링
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.pressure
some avg10=0.00 avg60=0.00 avg300=0.00 total=0
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
memory.events — OOM 이벤트 모니터링
# memory.events: OOM 및 throttling 이벤트 카운터
$ cat $CGPATH/memory.events
low 0 # memory.low 아래로 떨어진 횟수
high 15 # memory.high 초과 횟수 (throttling 발생)
max 3 # memory.max 도달 횟수 (할당 실패 발생)
oom 1 # OOM killer 호출 횟수
oom_kill 1 # OOM으로 프로세스 킬 횟수
oom_group_kill 0 # 그룹 전체 OOM kill 횟수
# PSI (Pressure Stall Information) 임계값 기반 이벤트 감시
# 형식: "some/full <stall_us> <window_us>"
$ echo "some 5000000 1000000" > $CGPATH/memory.pressure # 1초 중 5ms 이상 stall 시 이벤트
# inotify로 memory.events 변경 감지 (Docker daemon이 사용하는 방식)
$ inotifywait -e modify $CGPATH/memory.events &
$ docker run --memory=64m stress --vm 1 --vm-bytes 100m &
# → memory.events 변경 → OOM kill 발생
cpu.stat / io.stat — 상세 사용량 분석
# cpu.stat: CPU 사용량 전체 필드
$ cat $CGPATH/cpu.stat
usage_usec 5432100 # 총 CPU 사용 시간 (μs)
user_usec 3210000 # 사용자 모드 시간
system_usec 2222100 # 커널 모드 시간
nr_periods 1000 # CFS 스케줄링 주기 수
nr_throttled 45 # 쿼터 초과로 throttle된 주기 수
throttled_usec 450000 # 총 throttle 시간 (μs)
nr_bursts 0 # burst credit 사용 횟수 (cpu.max.burst)
burst_usec 0
# io.stat: 블록 I/O 통계
$ cat $CGPATH/io.stat
8:0 rbytes=1073741824 wbytes=524288000 rios=12345 wios=6789 dbytes=0 dios=0
# 8:0 = 디바이스 major:minor
# rbytes/wbytes: 읽기/쓰기 바이트, rios/wios: I/O 작업 수
# dbytes/dios: discard (TRIM) 바이트/작업 수
# 실시간 모니터링: docker stats와 cgroup 직접 비교
$ watch -n1 'cat '$CGPATH'/cpu.stat | grep usage_usec'
Rootless Docker 심화
Rootless Docker는 root 권한 없이 컨테이너를 실행합니다. 핵심은 user namespace + newuidmap/newgidmap을 통한 UID 매핑입니다.
# Rootless Docker 설정 확인
$ cat /etc/subuid
alice:100000:65536 # alice 사용자가 100000부터 65536개 UID 사용 가능
$ cat /etc/subgid
alice:100000:65536
# rootless dockerd 실행 (비root 사용자로)
$ dockerd-rootless.sh &
# 컨테이너 내 root가 실제로는 호스트 alice UID임을 확인
$ docker run --rm alpine id
uid=0(root) gid=0(root) # 컨테이너 내 뷰
$ ps aux | grep sh
alice 12345 ... # 호스트에서는 alice UID로 실행
# uid_map 직접 확인
$ cat /proc/12345/uid_map
0 1000 1 # 컨테이너 UID 0 = 호스트 UID 1000
1 100000 65535 # 컨테이너 UID 1-65535 = 호스트 100000-165534
# newuidmap suid 바이너리 확인
$ ls -la /usr/bin/newuidmap
-rwsr-xr-x 1 root root ... /usr/bin/newuidmap # SUID 비트 필수
Rootless Docker 네트워킹 — rootlesskit 슬레이브 프로세스
# Rootless Docker 네트워킹 구현 방식 확인
$ ps aux | grep -E 'rootlesskit|slirp|vpnkit'
alice 5000 /usr/bin/rootlesskit --port-driver=slirp4netns ...
alice 5001 /usr/bin/slirp4netns --configure --mtu=65520 ... 5000 tap0
# slirp4netns 방식 (기본): 사용자 공간 TCP/IP 스택
# - User NS 내부에서 tap0 인터페이스 생성
# - 호스트에서 slirp4netns가 user-mode networking 처리
# - 성능: 호스트 bridge 대비 낮음 (사용자 공간 TCP/IP 오버헤드)
# bypass4netns 방식 (고성능): eBPF + seccomp_unotify 활용
# - connect()/bind() syscall을 seccomp_unotify로 인터셉트
# - 컨테이너 내부 connect() → 호스트 NS의 실제 소켓으로 리다이렉트
# - 성능: 호스트 bridge와 동등 (커널 패스 유지)
$ docker run --annotation run.oci.keep_original_groups=1 \
--net host myapp # host 모드 + Rootless (bypass4netns 필요)
# 포트 포워딩 확인 (rootlesskit이 처리)
$ docker run -d -p 8080:80 nginx
$ ss -tlnp | grep 8080
LISTEN 0 128 0.0.0.0:8080 # rootlesskit이 호스트에서 바인드
# 내부: rootlesskit → slirp4netns tap → 컨테이너 eth0:80
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 기본값 |
|---|---|---|---|
| shared | MS_SHARED | 호스트↔컨테이너 양방향 전파 | 아니오 |
| slave | MS_SLAVE | 호스트→컨테이너 단방향 전파 | 아니오 |
| private | MS_PRIVATE | 전파 없음 (독립) | 네임드 볼륨 |
| rprivate | MS_PRIVATE + recursive | 하위 마운트도 전파 없음 | bind mount 기본 |
# bind mount 사용 예
$ docker run -v /host/data:/container/data:ro nginx
# 내부적으로: mount("/host/data", "/container/data", "", MS_BIND|MS_RDONLY, "")
# 네임드 볼륨 (Docker 관리)
$ docker volume create mydata
$ docker run -v mydata:/app/data nginx
# Docker가 /var/lib/docker/volumes/mydata/_data/ 생성
# 내부적으로: mount("/var/lib/docker/volumes/mydata/_data", "/app/data", "", MS_BIND, "")
# tmpfs 마운트
$ docker run --tmpfs /run:size=100m,exec nginx
# mount("tmpfs", "/run", "tmpfs", MS_NODEV, "size=104857600")
# 마운트 전파: 호스트에서 마운트한 것이 컨테이너에도 반영
$ docker run -v /host/data:/container/data:shared nginx
# 호스트에서 /host/data에 새 마운트 시 컨테이너에도 보임
pivot_root() 심화 — rootfs 교체의 커널 경로
pivot_root(new_root, put_old)는 현재 마운트 네임스페이스의 루트 파일시스템을 new_root로 교체하고, 이전 루트를 put_old로 이동시키는 syscall입니다. chroot()와 달리 마운트 네임스페이스와 함께 사용하면 이전 루트로 돌아갈 수 없어 완전한 격리가 가능합니다.
pivot_root() vs chroot() 보안 비교
| 항목 | chroot() | pivot_root() |
|---|---|---|
| 이전 루트 접근 | 가능 (파일 디스크립터로 탈출) | 불가 (umount 후 완전 차단) |
| 마운트 네임스페이스 | 불필요 | CLONE_NEWNS 필수 |
| 격리 수준 | 낮음 (CAP_SYS_CHROOT로 우회) | 높음 (마운트 구조 완전 변경) |
| 사용처 | 레거시 chroot 감옥 (취약) | Docker/OCI 컨테이너 (표준) |
| 커널 코드 | fs/open.c:ksys_chroot() | fs/namespace.c:sys_pivot_root() |
fs/namespace.c — sys_pivot_root() 코드
/* fs/namespace.c: pivot_root syscall 구현 */
int pivot_root(const char __user *new_root, const char __user *put_old)
{
struct path new, old, parent_path, root_parent;
/* 기본 유효성 검사 */
if (!may_mount())
return -EPERM; /* CAP_SYS_ADMIN 또는 마운트 NS 소유자 */
user_path_at(AT_FDCWD, new_root, ..., &new);
user_path_at(AT_FDCWD, put_old, ..., &old);
/* new_root는 마운트 포인트여야 함 */
if (!path_is_mountpoint(&new))
return -EINVAL;
/* put_old는 new_root 아래에 있어야 함 */
if (!is_path_reachable(real_mount(old.mnt), old.dentry, &new))
return -EINVAL;
/* 마운트 트리 교체: 현재 루트 → put_old, new_root → 현재 루트 */
detach_mnt(new_mnt, &parent_path);
detach_mnt(root_mnt, &root_parent);
attach_mnt(root_mnt, real_mount(old.mnt), old.dentry); /* 이전 루트 → put_old */
attach_mnt(new_mnt, real_mount(root_parent.mnt), root_parent.dentry); /* new → / */
return 0;
}
runc의 pivot_root 구현 (libcontainer/rootfs_linux.go)
// libcontainer/rootfs_linux.go
func pivotRoot(rootfs string) error {
// 1. rootfs를 스스로에게 bind mount (마운트 포인트로 만들기)
if err := mount(rootfs, rootfs, "", unix.MS_BIND|unix.MS_REC, ""); err != nil {
return err
}
// 2. put_old 디렉토리 생성
pivotDir := filepath.Join(rootfs, ".pivot_root")
os.Mkdir(pivotDir, 0700)
// 3. pivot_root syscall
if err := unix.PivotRoot(rootfs, pivotDir); err != nil {
return fmt.Errorf("pivot_root %s: %w", rootfs, err)
}
// 4. 새 루트로 이동
if err := unix.Chdir("/"); err != nil {
return err
}
// 5. 이전 루트 언마운트 (lazy: 이미 열린 파일은 유지)
pivotDir = filepath.Join("/", ".pivot_root")
if err := unix.Unmount(pivotDir, unix.MNT_DETACH); err != nil {
return err
}
return os.Remove(pivotDir)
}
Linux Capabilities 실전 — Docker 권한 모델
Linux capabilities는 전통적인 루트 권한(ALL-or-NOTHING)을 37개의 독립적인 권한으로 세분화한 시스템입니다. Docker는 컨테이너 시작 시 최소 권한 원칙에 따라 안전한 기본값을 설정합니다.
Docker 기본 허용 Capabilities (14개)
| Capability | 설명 | docker run 옵션 |
|---|---|---|
CAP_CHOWN | 파일 소유자/그룹 변경 | 기본 허용 |
CAP_DAC_OVERRIDE | 파일 읽기/쓰기/실행 권한 우회 | 기본 허용 |
CAP_FSETID | setuid/setgid 비트 설정 | 기본 허용 |
CAP_FOWNER | 소유자 확인 우회 | 기본 허용 |
CAP_MKNOD | 특수 파일(device) 생성 | 기본 허용 |
CAP_NET_RAW | RAW/PACKET 소켓 사용 (ping 등) | 기본 허용 |
CAP_SETGID | 프로세스 GID 변경 | 기본 허용 |
CAP_SETUID | 프로세스 UID 변경 | 기본 허용 |
CAP_SETFCAP | 파일 capabilities 설정 | 기본 허용 |
CAP_SETPCAP | 허용 집합에서 상속 집합으로 capability 이동 | 기본 허용 |
CAP_NET_BIND_SERVICE | 1024 미만 포트 바인드 | 기본 허용 |
CAP_SYS_CHROOT | chroot() syscall 사용 | 기본 허용 |
CAP_KILL | 다른 UID 프로세스에 시그널 전송 | 기본 허용 |
CAP_AUDIT_WRITE | 커널 audit 로그에 쓰기 | 기본 허용 |
Docker 기본 차단 Capabilities (위험 기능)
| Capability | 위험 이유 | 필요 시 추가 방법 |
|---|---|---|
CAP_SYS_ADMIN | mount/umount, ptrace, 네임스페이스 생성 등 광범위한 권한 | --cap-add SYS_ADMIN |
CAP_SYS_PTRACE | 다른 프로세스 메모리 읽기/쓰기 (컨테이너 탈출 위험) | --cap-add SYS_PTRACE |
CAP_NET_ADMIN | 네트워크 설정 변경, iptables 수정 | --cap-add NET_ADMIN |
CAP_SYS_MODULE | 커널 모듈 로드/언로드 (호스트 커널 조작) | 보안 위험 — 사용 자제 |
CAP_SYS_BOOT | reboot(), kexec_load() | 보안 위험 — 사용 자제 |
CAP_SYS_RAWIO | I/O 포트 직접 접근 (iopl/ioperm) | 보안 위험 — 사용 자제 |
kernel/capability.c — cap_bounding set 적용 경로
/* kernel/capability.c: capability 검사 경로 */
bool has_capability(struct task_struct *t, int cap)
{
return has_ns_capability(t, &init_user_ns, cap);
}
bool has_ns_capability(struct task_struct *t,
struct user_namespace *ns, int cap)
{
const struct cred *cred;
bool ret;
rcu_read_lock();
cred = __task_cred(t);
ret = security_capable(cred, ns, cap, CAP_OPT_NONE);
rcu_read_unlock();
return ret == 0;
}
/* cap_bounding: 프로세스가 가질 수 있는 최대 capability 집합 */
/* runc는 execve() 전에 prctl(PR_CAPBSET_DROP, cap)으로 차단 capability 제거 */
for (int i = 0; i < CAP_LAST_CAP; i++) {
if (!cap_raised(config.capabilities.bounding, i))
prctl(PR_CAPBSET_DROP, i); /* bounding set에서 제거 */
}
--cap-add / --cap-drop 사용법
# 기본 14개에서 CAP_SYS_PTRACE 추가 (디버깅용 strace 허용)
$ docker run --cap-add SYS_PTRACE --rm alpine strace ls
# 모든 capability 제거 후 필요한 것만 추가 (최소 권한)
$ docker run --cap-drop ALL --cap-add NET_BIND_SERVICE nginx
# --privileged: 모든 capability 허용 + 디바이스 접근 (위험)
$ docker run --privileged myapp # 절대 프로덕션 금지
# 실행 중 컨테이너의 capability 확인
$ cat /proc/$(docker inspect -f '{{.State.Pid}}' mycontainer)/status | grep Cap
CapInh: 0000000000000000
CapPrm: 00000000a80425fb # 허용 집합 (비트마스크)
CapEff: 00000000a80425fb # 유효 집합
CapBnd: 00000000a80425fb # bounding 집합
CapAmb: 0000000000000000
# capsh로 비트마스크 해석
$ capsh --decode=00000000a80425fb
0x00000000a80425fb=cap_chown,cap_dac_override,...,cap_net_bind_service,cap_net_raw
컨테이너 런타임 설정 심화
Docker 데몬 설정과 대안 OCI 런타임(crun, youki)을 다룹니다.
/etc/docker/daemon.json 주요 설정
{
"data-root": "/var/lib/docker",
"storage-driver": "overlay2",
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" },
"default-ulimits": { "nofile": { "Name": "nofile", "Hard": 64000, "Soft": 64000 }},
"runtimes": {
"crun": { "path": "/usr/bin/crun" },
"youki": { "path": "/usr/local/bin/youki" }
},
"default-runtime": "runc",
"live-restore": true, // dockerd 재시작 시 컨테이너 보존
"max-concurrent-downloads": 3,
"experimental": false,
"features": { "containerd-snapshotter": true }
}
runc vs crun vs youki 비교
| 런타임 | 언어 | 컨테이너 시작 지연 | 메모리 사용량 | 특징 |
|---|---|---|---|---|
| runc | Go | ~200ms | ~15MB | Docker/Kubernetes 기본값, 안정성 최우선 |
| crun | C | ~20ms (10x 빠름) | ~2MB | CRIU 통합, cgroup v2 완전 지원, RHEL 기본값 |
| youki | Rust | ~30ms | ~4MB | 안전성, crun과 유사 성능, 활발한 개발 |
# 대안 런타임 사용
$ docker run --runtime=crun --rm alpine echo "fast start"
# containerd 런타임 설정 (/etc/containerd/config.toml)
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes]
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = true # cgroup v2 systemd driver 필수
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.crun]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.crun.options]
BinaryName = "/usr/bin/crun"
Docker 런타임 성능 비교 실측
# 런타임별 컨테이너 시작 시간 측정
$ for rt in runc crun youki; do
echo -n "$rt: "
time docker run --runtime=$rt --rm alpine true
done
# runc: real 0m0.215s
# crun: real 0m0.022s ← 10배 빠름
# youki: real 0m0.031s
# 메모리 사용량 비교 (runc vs crun)
$ /usr/bin/time -v runc create --bundle /tmp/bundle mycontainer 2>&1 | grep "Maximum resident"
# runc: Maximum resident set size: 14592 kB
# crun: Maximum resident set size: 1856 kB
# crun 장점: cgroup v2 네이티브, CRIU 완전 통합, IoT/엣지 환경 적합
# youki 장점: Rust 메모리 안전성, 보안 감사 용이, crun과 성능 유사
CRIU 체크포인트/복원
CRIU(Checkpoint/Restore In Userspace)는 실행 중인 컨테이너의 전체 상태를 파일로 저장하고, 나중에 동일하거나 다른 호스트에서 복원할 수 있는 기술입니다. 라이브 마이그레이션, 빠른 시작, 컨테이너 스냅샷에 활용됩니다.
docker checkpoint 명령
# Docker experimental 기능 활성화 (daemon.json)
$ cat /etc/docker/daemon.json
{ "experimental": true }
# 체크포인트 생성
$ docker checkpoint create mycontainer cp1
# → /var/lib/docker/containers/<id>/checkpoints/cp1/ 에 이미지 파일 저장
# 체크포인트에서 재시작
$ docker start --checkpoint cp1 mycontainer
# 컨테이너가 체크포인트 시점부터 즉시 재개
# 체크포인트 목록
$ docker checkpoint ls mycontainer
CHECKPOINT NAME
cp1
# 체크포인트 이미지 파일 확인
$ ls /var/lib/docker/containers/<id>/checkpoints/cp1/
core-1.img fdinfo-2.img fs-1.img mm-1.img pagemap-1.img pages-1.img
CRIU 내부: parasite 코드 인젝션과 /proc/PID/mem
# crun + CRIU 통합 (crun이 runc보다 CRIU 지원 더 완전)
$ docker run --runtime=crun -d --name=stateful myapp
$ docker checkpoint create stateful snapshot1
# CRIU 직접 사용 (낮은 수준 제어)
$ CPID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
# dump: 실행 중인 프로세스 상태 저장
$ criu dump -t $CPID --images-dir /tmp/criu-dump \
--shell-job --tcp-established --ext-unix-sk -vv
# restore: 저장된 상태에서 재시작
$ criu restore --images-dir /tmp/criu-dump \
--shell-job --tcp-established --ext-unix-sk -vv &
# 이미지 파일 분석 (crit 도구)
$ crit decode -i /tmp/criu-dump/core-1.img | python3 -m json.tool | head -40
# → thread_info.gpregs: 레지스터 상태 확인
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 드라이버 — 커널 구현
# 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 운영 및 최적화에 필수적입니다.
- 네임스페이스 → cgroups v2 → Containers 심화 순으로 기초를 다진 후 이 문서로 돌아오세요.
- 보안 강화를 원한다면 커널 보안의 AppArmor + LSM 훅 경로를 학습하세요.
- 네트워크 성능 최적화를 원한다면 eBPF 기반 보안 정책의 XDP/eBPF 컨테이너 네트워킹을 참고하세요.