Linux Containers 심화
Linux 컨테이너 기술 스택을 커널 관점에서 종합적으로 다룹니다. OCI 런타임(runc, containerd, Podman), seccomp/capabilities 보안, OverlayFS 스토리지, CNI 네트워킹, rootless 컨테이너, Kata Containers까지 컨테이너의 전체 아키텍처를 설명합니다.
컨테이너 개요
컨테이너는 운영체제 수준 가상화(OS-level virtualization) 기술로, 하나의 커널 위에서 프로세스를 격리된 환경에서 실행합니다. VM이 하이퍼바이저 위에 게스트 커널을 통째로 올리는 것과 달리, 컨테이너는 호스트 커널을 공유하면서 네임스페이스와 cgroups로 격리와 리소스 제한을 구현합니다.
핵심 커널 프리미티브
컨테이너 기술은 다음 4가지 커널 프리미티브의 조합으로 구현됩니다:
| 프리미티브 | 역할 | 예시 |
|---|---|---|
| Namespaces | 리소스 가시성 격리 | PID, NET, MNT, USER, UTS, IPC, cgroup, time |
| cgroups | 리소스 사용량 제한 | CPU, memory, I/O, PID 수 제한 |
| seccomp-bpf | 시스템 콜 필터링 | 허용/차단 syscall 목록 적용 |
| Capabilities | 권한 세분화 | CAP_NET_BIND_SERVICE, CAP_SYS_ADMIN 등 |
VM vs Container 아키텍처
컨테이너 기술의 역사
| 연도 | 기술 | 의미 |
|---|---|---|
| 1979 | chroot | 파일시스템 루트 격리의 시작 |
| 2002 | Linux namespaces | mount namespace (Linux 2.4.19) |
| 2006 | cgroups (Google) | 프로세스 그룹 리소스 제한 |
| 2008 | LXC | 최초의 완전한 Linux 컨테이너 구현 |
| 2013 | Docker | 이미지 레이어 + 사용자 경험 혁신 |
| 2015 | OCI 설립 | 런타임/이미지 스펙 표준화 |
| 2016 | runc 1.0 | OCI 참조 런타임 구현 |
| 2017 | containerd 1.0 | 산업 표준 고수준 런타임 |
| 2019 | cgroup v2 | 통합 계층 구조, PSI 지원 |
| 2021 | Podman 3.0+ | 데몬리스, rootless 네이티브 |
커널 프리미티브 조합
컨테이너 런타임은 단일 프리미티브가 아닌, 여러 커널 기능을 특정 순서로 조합하여 격리 환경을 구성합니다. 아래 다이어그램은 컨테이너 생성 시 커널 프리미티브가 적용되는 흐름을 보여줍니다.
seccomp-bpf: 시스템 콜 필터링
seccomp(Secure Computing Mode)은 프로세스가 호출할 수 있는 시스템 콜을 제한하는 커널 기능입니다. 컨테이너 런타임은 seccomp-bpf 필터를 사용하여 위험한 시스템 콜을 차단합니다.
/* 커널의 seccomp 필터 데이터 구조 (include/uapi/linux/seccomp.h) */
struct seccomp_data {
int nr; /* 시스템 콜 번호 */
__u32 arch; /* AUDIT_ARCH_* 값 */
__u64 instruction_pointer; /* 호출 위치 */
__u64 args[6]; /* 시스템 콜 인자 */
};
/* seccomp 필터 설치 */
struct sock_fprog prog = {
.len = ARRAY_SIZE(filter),
.filter = filter, /* BPF 필터 배열 */
};
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
/* 또는 seccomp() 시스템 콜 사용 (Linux 3.17+) */
seccomp(SECCOMP_SET_MODE_FILTER, 0, &prog);
kexec_load, mount, reboot, init_module, ptrace, keyctl 등 호스트에 영향을 줄 수 있는 시스템 콜이 차단 대상입니다. --security-opt seccomp=unconfined로 비활성화 가능하나 보안상 권장하지 않습니다.Linux Capabilities
전통적 Unix는 root(UID 0)와 non-root만 구분했습니다. Linux capabilities는 root 권한을 약 40개 이상의 세분화된 권한으로 분리하여, 컨테이너에 필요한 최소 권한만 부여할 수 있게 합니다.
/* 컨테이너 런타임의 기본 capability 목록 (Docker/runc 기준) */
/* 유지되는 capabilities */
CAP_CHOWN /* 파일 소유자 변경 */
CAP_DAC_OVERRIDE /* 파일 접근 권한 우회 */
CAP_FSETID /* setuid/setgid 비트 보존 */
CAP_FOWNER /* 파일 소유자 검사 우회 */
CAP_MKNOD /* 특수 파일 생성 */
CAP_NET_RAW /* RAW/PACKET 소켓 */
CAP_SETGID /* GID 조작 */
CAP_SETUID /* UID 조작 */
CAP_SETFCAP /* 파일 capability 설정 */
CAP_SETPCAP /* 프로세스 capability 전달 */
CAP_NET_BIND_SERVICE /* 1024 미만 포트 바인딩 */
CAP_SYS_CHROOT /* chroot() 호출 */
CAP_KILL /* 프로세스에 시그널 전송 */
CAP_AUDIT_WRITE /* 감사 로그 작성 */
/* 주요 DROP 대상 capabilities */
CAP_SYS_ADMIN /* mount, namespace 생성 등 — 가장 위험 */
CAP_SYS_PTRACE /* ptrace, /proc 접근 */
CAP_SYS_MODULE /* 커널 모듈 로드/언로드 */
CAP_SYS_RAWIO /* iopl, ioperm */
CAP_SYS_BOOT /* reboot() */
CAP_NET_ADMIN /* 네트워크 구성 변경 */
# capability 확인 도구
$ capsh --print # 현재 프로세스의 capabilities 출력
$ getpcaps <PID> # 특정 프로세스의 capabilities 확인
$ cat /proc/<PID>/status | grep Cap # 커널이 보고하는 capability 비트마스크
# 출력 예시 (Docker 컨테이너 내부)
CapInh: 0000000000000000
CapPrm: 00000000a80425fb # 허용된 capability 비트마스크
CapEff: 00000000a80425fb # 유효 capability 비트마스크
CapBnd: 00000000a80425fb # bounding set
CapAmb: 0000000000000000 # ambient capabilities
OCI (Open Container Initiative)
OCI는 컨테이너 포맷과 런타임의 개방 표준을 정의하는 Linux Foundation 프로젝트입니다. Docker가 2015년에 기증한 libcontainer를 기반으로, 모든 컨테이너 런타임이 따르는 표준 인터페이스를 제공합니다.
OCI Runtime Specification
런타임 스펙은 컨테이너의 설정, 실행 환경, 라이프사이클을 정의합니다. config.json이 핵심 설정 파일입니다.
// OCI config.json 핵심 구조 (간략화)
{
"ociVersion": "1.0.2",
"process": {
"terminal": true,
"user": { "uid": 0, "gid": 0 },
"args": ["/bin/sh"],
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"cwd": "/",
"capabilities": {
"bounding": ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"],
"effective": ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"]
},
"rlimits": [{ "type": "RLIMIT_NOFILE", "hard": 1024, "soft": 1024 }]
},
"root": { "path": "rootfs", "readonly": true },
"mounts": [
{ "destination": "/proc", "type": "proc", "source": "proc" },
{ "destination": "/dev", "type": "tmpfs", "source": "tmpfs" }
],
"linux": {
"namespaces": [
{ "type": "pid" }, { "type": "network" },
{ "type": "ipc" }, { "type": "uts" },
{ "type": "mount" }, { "type": "cgroup" }
],
"seccomp": { "defaultAction": "SCMP_ACT_ERRNO", "architectures": ["SCMP_ARCH_X86_64"] },
"resources": {
"memory": { "limit": 536870912 },
"cpu": { "shares": 1024, "quota": 100000, "period": 100000 }
}
}
}
OCI 컨테이너 라이프사이클
| 상태 | 설명 | 전이 |
|---|---|---|
| creating | 런타임이 config.json을 파싱하고 환경 준비 중 | create 명령 |
| created | 환경 준비 완료, 프로세스 미실행 | prestart 훅 실행됨 |
| running | 컨테이너 프로세스 실행 중 | start 명령 |
| stopped | 프로세스 종료됨, 리소스 미해제 | 프로세스 exit |
| (삭제됨) | 모든 리소스 해제 | delete 명령 |
OCI Image Specification
OCI 이미지는 content-addressable 스토리지 모델을 사용합니다. 각 레이어는 SHA256 다이제스트로 식별됩니다.
- Image Manifest — 이미지의 레이어 목록과 config를 가리키는 디스크립터
- Image Config — 실행 파라미터, 환경변수, 레이어 히스토리
- Filesystem Layers — tar+gzip으로 압축된 파일시스템 변경분 (diff)
- Image Index — 멀티 아키텍처(amd64, arm64) 이미지의 매니페스트 목록
컨테이너 런타임
컨테이너 런타임은 3계층으로 분류됩니다:
| 계층 | 역할 | 예시 |
|---|---|---|
| CRI (인터페이스) | 오케스트레이터와 런타임 간 gRPC API | Kubernetes CRI |
| High-level | 이미지 관리, 스냅샷, 네트워킹 | containerd, CRI-O |
| Low-level (OCI) | 실제 컨테이너 생성/실행 | runc, crun, youki |
runc (OCI 참조 구현)
runc은 Docker가 기증한 libcontainer를 기반으로 한 Go 언어 구현의 OCI 참조 런타임입니다. 컨테이너 생성의 핵심 코드 흐름:
/* runc의 컨테이너 생성 흐름 (Go → 커널 호출) */
// 1. config.json 파싱 → Container 구조체 생성
container := libcontainer.Create(id, config)
// 2. 부모 프로세스에서 clone() + CLONE_NEW* 플래그로 자식 생성
// (실제로는 /proc/self/exe를 재실행하는 "init" 방식)
cmd := &exec.Cmd{
Path: "/proc/self/exe", // runc 바이너리 자체를 다시 실행
Args: []string{"runc", "init"},
}
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS | syscall.CLONE_NEWNET |
syscall.CLONE_NEWIPC | syscall.CLONE_NEWUSER,
}
// 3. 자식 프로세스(runc init)에서:
pivot_root(rootfs, put_old) // rootfs 전환
mount("proc", "/proc", "proc", 0, "") // /proc 마운트
prctl(PR_SET_NO_NEW_PRIVS, 1) // 권한 상승 차단
seccomp(SET_MODE_FILTER, &prog) // seccomp 필터 적용
execve(entrypoint, args, envp) // 컨테이너 프로세스 실행
# runc 직접 사용 예시
$ mkdir -p bundle/rootfs
$ docker export $(docker create alpine) | tar -C bundle/rootfs -xf -
$ cd bundle
$ runc spec # 기본 config.json 생성
$ runc create mycontainer # 컨테이너 생성 (created 상태)
$ runc start mycontainer # 프로세스 시작 (running 상태)
$ runc list # 컨테이너 목록 확인
$ runc state mycontainer # 상태 JSON 출력
$ runc delete mycontainer # 정리
crun (C 구현)
crun은 runc의 C 언어 대안 구현으로, Red Hat이 개발합니다.
| 비교 항목 | runc (Go) | crun (C) |
|---|---|---|
| 메모리 사용 | ~10-15 MB (Go 런타임) | ~1-2 MB |
| 시작 시간 | 기준 | ~40-50% 빠름 |
| cgroup v2 | 지원 | 네이티브 우선 지원 |
| WASM | 미지원 | wasmedge/wasmtime 지원 |
| 채택 | Docker 기본 | Podman/Fedora 기본 |
containerd
containerd는 CNCF 졸업 프로젝트로, Docker와 Kubernetes의 핵심 컨테이너 런타임입니다.
- gRPC API — 클라이언트(Docker/kubelet)가 API로 컨테이너 라이프사이클 관리
- Snapshotter — 이미지 레이어를 파일시스템 스냅샷으로 관리 (overlayfs, native, devmapper 등)
- Content Store — content-addressable 스토리지로 이미지 블롭 저장
- Shim 프로세스 —
containerd-shim-runc-v2가 각 컨테이너와 1:1로 존재하여, containerd 재시작과 컨테이너를 분리 - CRI 플러그인 — Kubernetes CRI(Container Runtime Interface) 네이티브 구현
# containerd 디버깅 도구
$ ctr images pull docker.io/library/alpine:latest # 이미지 pull
$ ctr run --rm docker.io/library/alpine:latest test /bin/sh # 실행
$ ctr containers list # 컨테이너 목록
$ ctr tasks list # 실행 중인 태스크
# nerdctl (containerd용 Docker-호환 CLI)
$ nerdctl run --rm -it alpine sh # Docker와 동일한 UX
Podman
Podman은 Red Hat이 개발한 데몬리스(daemonless) 컨테이너 엔진입니다. Docker와 달리 중앙 데몬이 없어 각 컨테이너가 독립적인 프로세스 트리를 가집니다.
- Fork/exec 모델 — podman 명령이 직접 conmon(container monitor)을 생성하고, conmon이 runc/crun을 호출
- Rootless 네이티브 — User namespace 기반으로 일반 사용자가 컨테이너 실행 가능
- systemd 통합 —
podman generate systemd로 컨테이너를 systemd 서비스로 관리 - Pod 지원 — Kubernetes Pod 개념을 로컬에서 구현 (infra 컨테이너로 네임스페이스 공유)
# Podman rootless 사용 예
$ podman run --rm -it alpine sh # root 권한 불필요
$ podman pod create --name mypod -p 8080:80 # Pod 생성
$ podman run --pod mypod nginx # Pod에 컨테이너 추가
# systemd 서비스로 관리
$ podman generate systemd --name mycontainer --new > mycontainer.service
$ systemctl --user enable --now mycontainer.service
컨테이너 스토리지 (OverlayFS)
OverlayFS는 컨테이너의 기본 스토리지 드라이버로, 읽기 전용 이미지 레이어 위에 쓰기 가능한 컨테이너 레이어를 오버레이합니다. 커널 3.18에서 mainline에 통합되었습니다.
OverlayFS 레이어 구조
| 디렉토리 | 역할 | 설명 |
|---|---|---|
lowerdir | 읽기 전용 | 이미지 레이어 (여러 개 콜론으로 연결) |
upperdir | 읽기/쓰기 | 컨테이너의 변경사항이 기록되는 레이어 |
workdir | 작업용 | atomic 연산을 위한 임시 디렉토리 |
merged | 통합 뷰 | lower + upper를 합친 최종 마운트 포인트 |
Copy-up 메커니즘
lower 레이어의 파일을 수정할 때, OverlayFS는 해당 파일을 upper 레이어로 복사한 뒤 수정합니다. 이를 copy-up이라 합니다.
/* OverlayFS copy-up 핵심 (fs/overlayfs/copy_up.c) */
static int ovl_copy_up_one(struct dentry *parent,
struct dentry *dentry,
struct path *lowerpath,
struct kstat *stat)
{
/* 1. upper에 임시 파일 생성 (workdir에서) */
temp = ovl_create_temp(workdir, ...);
/* 2. lower에서 데이터 복사 */
ovl_copy_up_data(lowerpath, temp, stat->size);
/* 3. xattr, 권한, 타임스탬프 복사 */
ovl_copy_xattr(lowerpath->dentry, temp);
ovl_set_attr(temp, stat);
/* 4. atomic rename으로 upper에 배치 */
vfs_rename(workdir, temp, upperdir, dentry);
return 0;
}
-v)를 사용하여 OverlayFS를 우회하세요.대안 스토리지 드라이버
| 드라이버 | 특징 | 사용 사례 |
|---|---|---|
| overlay2 | 다중 lower 레이어, 커널 4.0+ | Docker/containerd 기본 |
| devicemapper | 블록 수준 thin provisioning | RHEL 7 레거시 |
| btrfs | 스냅샷 기반, CoW 파일시스템 | SUSE 계열 |
| ZFS | 스냅샷/클론, 압축, 체크섬 | Ubuntu (실험적) |
| fuse-overlayfs | FUSE 기반 overlay | rootless (커널 5.11 이전) |
Whiteout / Opaque 디렉토리
OverlayFS에서 lower 레이어는 읽기 전용이므로, 파일을 "삭제"할 때 실제 lower의 파일을 제거하는 것이 아니라 upper 레이어에 whiteout 파일을 생성하여 해당 파일을 가립니다. Whiteout은 character device(major=0, minor=0)로 표현됩니다.
/* fs/overlayfs/overlayfs.h — whiteout 관련 상수 */
#define OVL_WHITEOUT_DEV MKDEV(0, 0)
/* whiteout 생성 (fs/overlayfs/dir.c) */
static int ovl_whiteout(struct ovl_fs *ofs,
struct dentry *upperdir,
struct dentry *dentry)
{
struct dentry *whiteout;
/* workdir에서 whiteout 장치 파일(0,0) 생성 */
whiteout = ovl_lookup_temp(ofs, ofs->workdir);
ovl_do_whiteout(ofs, whiteout); /* mknod(S_IFCHR, 0/0) */
/* atomic rename으로 upper에 배치 */
return ovl_do_rename(ofs, ofs->workdir, whiteout,
upperdir, dentry, 0);
}
디렉토리 전체를 삭제한 뒤 같은 이름으로 재생성하는 경우, OverlayFS는 opaque 디렉토리를 사용합니다. upper에 새 디렉토리를 만들고 trusted.overlay.opaque="y" xattr을 설정하면, 이 디렉토리 하위에서는 lower의 동명 디렉토리를 참조하지 않습니다.
# Whiteout 확인 예시
$ ls -la upper/
c--------- 1 root root 0, 0 ... config.txt # whiteout: char device 0/0
# Opaque 디렉토리 확인
$ getfattr -n trusted.overlay.opaque upper/data/
trusted.overlay.opaque="y"
# merged에서 확인
$ ls merged/
data/ README # config.txt는 whiteout에 의해 숨겨짐
tar --xattrs --xattrs-include='trusted.*' 옵션을 사용하거나, OverlayFS 전용 도구를 활용하세요.Redirect Dir (디렉토리 Rename)
OverlayFS에서 디렉토리를 rename하면 upper에만 새 디렉토리가 생기고, lower의 원본 디렉토리는 여전히 존재합니다. 이를 올바르게 처리하기 위해 커널 4.10+에서 redirect dir 기능이 도입되었습니다. 새 디렉토리의 trusted.overlay.redirect xattr에 원본 경로를 저장하여, lower의 원본 디렉토리 내용을 새 위치에서 투명하게 참조합니다.
| 옵션 값 | 설명 | 기본값 |
|---|---|---|
redirect_dir=on | 디렉토리 rename 시 redirect xattr 생성 | - |
redirect_dir=off | 디렉토리 rename 실패 (EXDEV 반환) | - |
redirect_dir=follow | 기존 redirect xattr 해석만, 새로 생성 안 함 | 커널 기본값 |
redirect_dir=nofollow | redirect xattr 무시 | - |
/* 디렉토리 rename 시 redirect lookup 의사코드 */
static int ovl_rename(struct inode *olddir, struct dentry *old,
struct inode *newdir, struct dentry *new)
{
/* 1. old가 디렉토리이고 lower에 존재하면 redirect 설정 필요 */
if (ovl_type_merge_or_lower(old)) {
/* 2. new 디렉토리에 redirect xattr 설정 */
ovl_set_redirect(old, ovl_dentry_get_redirect(old));
/* trusted.overlay.redirect="/original/path" */
}
/* 3. 이후 lookup 시 redirect xattr을 읽어 lower 검색에 사용 */
return ovl_do_rename(...);
}
redirect_dir=on은 lower 레이어의 임의 디렉토리를 upper에서 참조할 수 있게 하므로, 신뢰할 수 없는 upper 레이어를 마운트할 때 보안 위험이 있습니다. 커널 기본값이 follow인 이유입니다. 다중 사용자 환경에서는 redirect_dir=nofollow를 고려하세요.Metacopy 최적화
커널 4.19+에서 도입된 metacopy 기능은 copy-up 시 파일 데이터 대신 메타데이터(소유권, 권한, xattr)만 upper에 복사합니다. 실제 데이터 읽기/쓰기가 발생할 때까지 데이터 복사를 지연(lazy copy-up)하여 성능을 대폭 개선합니다.
| 시나리오 | 기존 copy-up | metacopy | 개선 |
|---|---|---|---|
chmod 1GB 파일 | ~1GB 복사 + chmod | 메타데이터만 복사 | ~1000x 빠름 |
chown 대량 파일 | 전체 데이터 복사 | xattr만 기록 | 디스크 I/O 극소 |
touch (타임스탬프) | 전체 데이터 복사 | 메타데이터만 | 즉시 완료 |
파일 내용 수정 (write) | 전체 데이터 복사 | 지연 후 데이터 복사 | 동일 (최초 write 시) |
/* metacopy 분기 로직 (fs/overlayfs/copy_up.c) */
static int ovl_copy_up_meta_inode_data(struct ovl_copy_up_ctx *c)
{
/* metacopy 상태 확인: 메타데이터만 복사된 파일인지 */
if (ovl_is_metacopy_dentry(c->dentry)) {
/* 데이터 접근 발생 → 이제 실제 데이터 복사 */
err = ovl_copy_up_data(&c->lowerpath, &datapath,
c->stat.size);
if (err)
return err;
/* metacopy xattr 제거 (이제 전체 복사 완료) */
ovl_do_removexattr(ofs, upperpath.dentry,
OVL_XATTR_METACOPY);
}
return 0;
}
# metacopy 활성화 마운트
$ mount -t overlay overlay \
-o lowerdir=/lower,upperdir=/upper,workdir=/work,metacopy=on \
/merged
# chmod 후 metacopy xattr 확인
$ chmod 644 /merged/large_file.dat
$ getfattr -n trusted.overlay.metacopy /upper/large_file.dat
trusted.overlay.metacopy # 값 존재 = 데이터 미복사
# 파일 내용을 수정하면 데이터 copy-up 발생
$ echo "data" >> /merged/large_file.dat
$ getfattr -n trusted.overlay.metacopy /upper/large_file.dat
# xattr 없음 = 전체 데이터 복사 완료
OverlayFS 커널 내부 구조
OverlayFS의 커널 구현은 fs/overlayfs/ 디렉토리에 위치하며, VFS 레이어와 통합되어 upper/lower 레이어를 투명하게 합성합니다.
| 소스 파일 | 역할 |
|---|---|
super.c | 파일시스템 등록, 마운트 옵션 파싱, superblock 초기화 |
inode.c | inode operations (getattr, permission, get_acl 등) |
dir.c | 디렉토리 operations (lookup, mkdir, rmdir, rename, whiteout) |
file.c | 파일 operations (open, read_iter, write_iter, mmap, fsync) |
copy_up.c | copy-up 핵심 로직, metacopy 처리 |
readdir.c | 디렉토리 읽기, whiteout 필터링, merge sort |
namei.c | 경로 탐색 (redirect, metacopy lookup) |
util.c | 유틸리티 함수 (xattr 조작, dentry 헬퍼) |
export.c | NFS export / file handle 지원 |
params.c | 마운트 파라미터 파싱 (커널 6.5+, 기존 super.c에서 분리) |
/* 핵심 자료구조: OverlayFS 파일시스템 인스턴스 (fs/overlayfs/ovl_entry.h) */
struct ovl_fs {
unsigned int numlayer; /* 전체 레이어 수 */
struct ovl_layer *layers; /* 레이어 배열 (upper + lowers) */
struct ovl_sb *fs; /* 레이어별 superblock 정보 */
struct dentry *workdir; /* workdir dentry */
long namelen; /* 파일명 최대 길이 */
struct ovl_config config; /* 마운트 옵션 구조체 */
const struct cred *creator_cred; /* 마운트 수행자 credential */
};
/* OverlayFS inode 확장 */
struct ovl_inode {
struct ovl_dir_cache *cache; /* readdir 캐시 */
const char *redirect; /* redirect 경로 */
struct inode *upper; /* upper inode (있으면) */
struct ovl_path lowerpath; /* lower 경로 */
struct inode vfs_inode; /* VFS inode (임베딩) */
unsigned long flags; /* OVL_UPPERDATA, OVL_METACOPY 등 */
};
/* dentry 개인 데이터: upper/lower 매핑 */
struct ovl_entry {
unsigned numlower; /* lower 경로 수 */
struct ovl_path lowerstack[]; /* flexible array: lower dentry 배열 */
};
/* 파일 수정 시 VFS → OverlayFS 함수 호출 흐름 */
vfs_open(merged_dentry)
└─ ovl_open()
├─ ovl_maybe_copy_up(dentry, O_WRONLY)
│ └─ ovl_copy_up_flags()
│ └─ ovl_copy_up_one() /* copy-up 수행 */
│ ├─ ovl_create_temp() /* workdir에 임시파일 */
│ ├─ ovl_copy_up_data() /* 데이터 복사 (또는 metacopy 스킵) */
│ ├─ ovl_copy_xattr() /* xattr 복사 */
│ └─ vfs_rename() /* atomic으로 upper에 배치 */
└─ ovl_open_realfile() /* upper의 실제 파일 open */
└─ open_with_fake_path(upper_file) /* 실제 파일시스템에 위임 */
xattr 기반 메타데이터
OverlayFS는 확장 속성(xattr)을 통해 레이어 간 관계와 파일 상태를 관리합니다. 모든 xattr은 trusted.overlay.* 네임스페이스에 저장됩니다.
| xattr | 용도 | 값 예시 |
|---|---|---|
trusted.overlay.opaque | opaque 디렉토리 표시 | "y" |
trusted.overlay.redirect | 디렉토리 rename 원본 경로 | "/original/dir" |
trusted.overlay.metacopy | 메타데이터만 copy-up됨 표시 | (빈 값) |
trusted.overlay.impure | 디렉토리에 copy-up된 항목 존재 | "y" |
trusted.overlay.origin | copy-up 원본의 file handle | (바이너리 fh) |
trusted.overlay.upper | upper에서 생성된 파일 표시 | (빈 값) |
trusted.overlay.nlink | hardlink 보정 nlink 값 | "U+3/L+2" |
trusted.* 네임스페이스는 root만 읽기/쓰기 가능하여 일반 사용자의 조작을 방지합니다. 커널 5.11+ rootless 환경에서는 userxattr 마운트 옵션을 사용하여 user.overlay.* 네임스페이스로 매핑합니다.
# xattr 전체 조회
$ getfattr -d -m "trusted.overlay.*" upper/mydir/
# file: upper/mydir/
trusted.overlay.impure="y"
trusted.overlay.opaque="y"
# origin file handle 확인 (바이너리)
$ getfattr -n trusted.overlay.origin -e hex upper/copied_file
trusted.overlay.origin=0x00fb010020...
# userxattr 환경 (rootless, 커널 5.11+)
$ getfattr -d -m "user.overlay.*" upper/mydir/
user.overlay.opaque="y"
trusted.overlay.* xattr을 수동으로 추가/제거/수정하면 OverlayFS 파일시스템이 손상될 수 있습니다. 특히 origin이나 nlink를 잘못 편집하면 파일이 사라지거나 데이터가 불일치합니다. 디버깅 용도의 읽기만 권장합니다.마운트 옵션 상세
| 옵션 | 값 | 커널 | 설명 |
|---|---|---|---|
lowerdir | 경로(:경로...) | 3.18+ | 읽기 전용 레이어 (우측이 최하위, 최대 500개) |
upperdir | 경로 | 3.18+ | 읽기/쓰기 레이어 (생략 시 read-only 마운트) |
workdir | 경로 | 3.18+ | atomic 연산 임시 디렉토리 (upperdir와 같은 FS) |
redirect_dir | on/off/follow/nofollow | 4.10+ | 디렉토리 rename redirect 처리 방식 |
metacopy | on/off | 4.19+ | 메타데이터 전용 copy-up (데이터 지연 복사) |
index | on/off | 4.13+ | inode index (hardlink copy-up, NFS export 지원) |
nfs_export | on/off | 4.16+ | NFS file handle 지원 (index=on 필요) |
xino | on/off/auto | 4.17+ | inode 번호에 레이어 비트 추가 (st_ino 충돌 방지) |
volatile | (플래그) | 5.6+ | sync/fsync 무시 (비정상 종료 시 데이터 손실 위험) |
userxattr | (플래그) | 5.11+ | user.overlay.* 네임스페이스 사용 (rootless용) |
uuid | on/off/null | 6.6+ | 레이어 UUID 검증 방식 제어 |
# 기본 마운트
$ mount -t overlay overlay \
-o lowerdir=/lower,upperdir=/upper,workdir=/work \
/merged
# 다중 lower 레이어 (왼쪽이 최상위)
$ mount -t overlay overlay \
-o lowerdir=/app:/runtime:/base,upperdir=/upper,workdir=/work \
/merged
# read-only 마운트 (upperdir/workdir 생략)
$ mount -t overlay overlay \
-o lowerdir=/layer3:/layer2:/layer1 \
/merged
# CI/CD 최적화: volatile + metacopy
$ mount -t overlay overlay \
-o lowerdir=/lower,upperdir=/upper,workdir=/work,volatile,metacopy=on \
/merged
# xino 활성화 (크로스-레이어 inode 번호 일관성)
$ mount -t overlay overlay \
-o lowerdir=/lower,upperdir=/upper,workdir=/work,xino=on \
/merged
주요 옵션 상세 설명:
volatile—sync(2),syncfs(2),fsync(2)를 upper 파일시스템에 전파하지 않습니다. 비정상 종료 시 upper 레이어가 손상될 수 있으나, CI/CD 빌드 캐시처럼 일시적 데이터에 사용하면 쓰기 성능이 크게 향상됩니다. Docker BuildKit에서--mount=type=tmpfs대신 활용할 수 있습니다.xino— 각 레이어의 inode 번호 상위 비트에 레이어 인덱스를 인코딩합니다. 이를 통해 서로 다른 레이어에서 동일한 inode 번호를 가진 파일들이 merged 마운트에서 고유한st_ino를 갖습니다.find,rsync등 inode 번호 기반 도구가 정상 동작하려면 활성화가 권장됩니다.index— upper 레이어의work/index/디렉토리에 lower 파일의 file handle을 저장합니다. 이를 통해 hardlink가 copy-up 후에도 올바르게 유지되고, NFS export 시 reboot 후에도 file handle이 유효합니다.
성능 튜닝 및 모범 사례
OverlayFS 성능의 핵심은 copy-up 최소화입니다. 컨테이너 이미지 빌드와 런타임 양쪽에서 최적화할 수 있습니다.
# Bad: 불필요한 copy-up 유발 Dockerfile
FROM ubuntu:22.04
COPY large_dataset.tar.gz /data/ # 레이어 1: 5GB
RUN tar xzf /data/large_dataset.tar.gz # 레이어 2: 5GB 풀림 + 5GB tar 잔존
RUN rm /data/large_dataset.tar.gz # 레이어 3: whiteout만 (5GB는 여전히 레이어 1에)
RUN chmod -R 755 /data/extracted/ # 레이어 4: 전체 copy-up 발생!
# Good: copy-up 최소화 Dockerfile
FROM ubuntu:22.04
COPY large_dataset.tar.gz /tmp/
RUN tar xzf /tmp/large_dataset.tar.gz -C /data/ \
&& rm /tmp/large_dataset.tar.gz \
&& chmod -R 755 /data/extracted/ # 단일 레이어에서 모두 처리
Copy-up 최소화 전략:
- Dockerfile 레이어 병합 — 권한 변경, 파일 삭제를 같은
RUN명령에서 처리 - 볼륨 마운트 — 빈번히 수정되는 데이터(DB, 로그)는
-v로 OverlayFS 우회 - tmpfs 활용 — 임시 파일은
--tmpfs /tmp으로 메모리에 배치 - multi-stage 빌드 — 빌드 도구/중간 산물을 최종 이미지에서 제외
| 모니터링 메트릭 | 도구 | 경로/명령 | 해석 |
|---|---|---|---|
| upper 디스크 사용량 | du -sh | upperdir 경로 | copy-up 누적량 파악 |
| inode 사용량 | df -i | upper 파일시스템 | whiteout + copy-up 파일 수 |
| copy-up 이벤트 | ftrace | ovl_copy_up_one | copy-up 빈도 추적 |
| overlay 마운트 정보 | cat | /proc/mounts | 마운트 옵션 확인 |
| 레이어 구성 | mount -l | type overlay | lower/upper/work 경로 확인 |
# ftrace로 copy-up 이벤트 실시간 추적
$ echo 1 > /sys/kernel/debug/tracing/events/overlayfs/ovl_copy_up/enable
$ cat /sys/kernel/debug/tracing/trace_pipe
# TASK-PID CPU# TIMESTAMP FUNCTION
# | | | | |
bash-1234 [002] 1234.567: ovl_copy_up: path=/etc/config.conf size=4096
node-5678 [001] 1235.890: ovl_copy_up: path=/app/node_modules/.cache size=52428800
# 추적 중지
$ echo 0 > /sys/kernel/debug/tracing/events/overlayfs/ovl_copy_up/enable
| 문제 | 원인 | 해결 |
|---|---|---|
| ENOSPC (공간 부족) | copy-up으로 upper 파일시스템 가득 | 불필요한 컨테이너 정리 (docker system prune), upper 볼륨 확장 |
| inode 고갈 | 대량 whiteout/small 파일로 inode 소진 | df -i로 확인, 이미지 레이어 최적화, mkfs -N으로 inode 수 증가 |
| 느린 첫 쓰기 | 대용량 파일 copy-up | metacopy=on 활성화, 볼륨 마운트 사용 |
| stat 불일치 | cross-layer inode 번호 충돌 | xino=on 활성화 |
| NFS export 실패 | file handle 미지원 | index=on,nfs_export=on 마운트 옵션 |
| rename EXDEV | cross-device rename 시도 | redirect_dir=on 활성화 |
workdir 디렉토리의 내용을 수동으로 편집하거나 삭제하면 안 됩니다. OverlayFS는 workdir을 atomic 연산(rename, whiteout 생성)의 임시 공간으로 사용하며, 수동 조작 시 진행 중인 연산이 실패하고 파일시스템이 불일치 상태에 빠질 수 있습니다. 마운트 해제 후에도 work/incompat/volatile 등의 내부 파일이 존재할 수 있으며, 이는 정상입니다.컨테이너 네트워킹
컨테이너 네트워킹은 network namespace로 격리된 네트워크 스택을 veth 페어와 브릿지로 연결하는 구조가 기본입니다.
veth + bridge 모델 (Docker 기본)
# Docker 브릿지 네트워크 확인
$ ip link show type bridge # docker0 확인
$ bridge link show # bridge에 연결된 veth 목록
$ ip netns list # 네트워크 네임스페이스 목록
# 수동으로 veth + bridge 구성 (교육 목적)
$ ip link add veth0 type veth peer name veth1
$ ip link set veth0 master docker0 # 호스트 측을 bridge에 연결
$ ip link set veth1 netns <container_pid> # 컨테이너 측을 NS로 이동
$ nsenter -t <pid> -n ip addr add 172.17.0.10/16 dev veth1
$ nsenter -t <pid> -n ip link set veth1 up
CNI (Container Network Interface)
CNI는 CNCF 표준으로, 컨테이너 네트워크 설정을 플러그인 체인으로 구성합니다. Kubernetes, Podman, CRI-O가 CNI를 사용합니다.
// CNI 네트워크 설정 예시 (/etc/cni/net.d/10-bridge.conflist)
{
"cniVersion": "1.0.0",
"name": "mynet",
"plugins": [
{
"type": "bridge",
"bridge": "cni0",
"isGateway": true,
"ipam": {
"type": "host-local",
"subnet": "10.22.0.0/16",
"routes": [{ "dst": "0.0.0.0/0" }]
}
},
{ "type": "loopback" },
{
"type": "portmap",
"capabilities": { "portMappings": true }
}
]
}
macvlan / ipvlan 모드
| 모드 | 동작 | 사용 사례 |
|---|---|---|
| macvlan bridge | 각 컨테이너에 고유 MAC, 물리 NIC 서브인터페이스 | 외부 네트워크 직접 연결 |
| ipvlan L2 | MAC 공유, L2 수준 분리 | MAC 수 제한 환경 |
| ipvlan L3 | MAC 공유, L3 라우팅 기반 분리 | 대규모 컨테이너 환경 |
Rootless 컨테이너
Rootless 컨테이너는 User namespace를 활용하여 일반 사용자(non-root)가 컨테이너를 실행하는 기술입니다. 호스트의 root 권한 없이 컨테이너 내부에서 "가짜 root"로 동작합니다.
UID/GID 매핑
# /etc/subuid — 사용자별 subordinate UID 범위
user1:100000:65536 # user1에게 UID 100000-165535 할당
# /etc/subgid — 사용자별 subordinate GID 범위
user1:100000:65536 # user1에게 GID 100000-165535 할당
# 매핑: 컨테이너 내 root(UID 0) → 호스트 user1의 subuid(100000)
# 컨테이너 내 UID 1 → 호스트 100001
# ...
# 컨테이너 내 UID 65535 → 호스트 165535
/* 커널 User namespace UID 매핑 (/proc//uid_map 형식) */
/* container_uid host_uid count */
0 100000 65536
/* newuidmap/newgidmap 명령으로 설정 */
$ newuidmap <pid> 0 100000 65536
$ newgidmap <pid> 0 100000 65536
Rootless 네트워킹
rootless 환경에서는 veth 페어를 생성할 수 없으므로(CAP_NET_ADMIN 부재), 유저스페이스 네트워크 스택을 사용합니다:
| 구현 | 방식 | 성능 |
|---|---|---|
| slirp4netns | libslirp 기반 TAP 디바이스 에뮬레이션 | 보통 (유저스페이스 TCP/IP 스택) |
| pasta | 호스트와 NS 간 패킷 복사 (splice) | 빠름 (Linux 5.7+) |
| RootlessKit | slirp4netns/pasta 래퍼 | 구현에 따라 다름 |
Rootless OverlayFS
Linux 5.11 이전에는 OverlayFS 마운트에 CAP_SYS_ADMIN이 필요했습니다. 커널 5.11부터 User namespace 내부에서 OverlayFS 마운트가 가능해져, FUSE 기반 fuse-overlayfs 없이도 rootless 컨테이너의 이미지 레이어를 직접 사용할 수 있습니다.
네이티브 rootless OverlayFS는 userxattr 마운트 옵션을 자동으로 사용합니다. 이 옵션은 trusted.overlay.* 대신 user.overlay.* 네임스페이스에 메타데이터를 저장하여, 권한 없는 사용자도 xattr을 관리할 수 있게 합니다.
# unshare로 User namespace + Mount namespace 생성 후 OverlayFS 마운트
$ unshare -Urm
# (이제 User NS 내부에서 root 권한)
$ mkdir -p /tmp/lower /tmp/upper /tmp/work /tmp/merged
$ mount -t overlay overlay \
-o lowerdir=/tmp/lower,upperdir=/tmp/upper,workdir=/tmp/work,userxattr \
/tmp/merged
# 커널 5.11+ 확인
$ uname -r
5.11.0-generic
$ cat /proc/mounts | grep overlay
overlay /tmp/merged overlay rw,lowerdir=/tmp/lower,upperdir=/tmp/upper,workdir=/tmp/work,userxattr 0 0
커널 5.11 미만이거나 특수한 파일시스템(NFS, FUSE 자체)에서는 fuse-overlayfs가 폴백으로 사용됩니다:
| 구현 | 커널 요구 | 성능 (상대) | 제약 |
|---|---|---|---|
| Native OverlayFS | 5.11+ | 100% (커널 레벨) | upperdir/lowerdir가 같은 FS 타입 권장 |
| fuse-overlayfs | 4.18+ (FUSE) | ~60-80% | 유저스페이스 오버헤드, context switch 비용 |
Podman과 Buildah는 런타임에 커널 버전을 감지하여 자동으로 적합한 드라이버를 선택합니다:
# Podman 스토리지 드라이버 확인
$ podman info --format '{{.Store.GraphDriverName}}'
overlay # 커널 5.11+ → native overlay
# 설정 파일에서 직접 제어 (~/.config/containers/storage.conf)
[storage]
driver = "overlay"
[storage.options.overlay]
mount_program = "/usr/bin/fuse-overlayfs" # 강제 FUSE 사용 시
/proc/sys/kernel/unprivileged_userns_clone과 실제 mount(2) 시도를 통해 native overlay 지원 여부를 판별합니다. 별도 설정 없이 최적의 드라이버가 자동 선택되므로, 대부분의 경우 수동 설정은 불필요합니다.Rootless 제한사항
- 특권 포트 — 1024 미만 포트 바인딩 불가 (
sysctl net.ipv4.ip_unprivileged_port_start로 조정 가능) - ping — CAP_NET_RAW 부재로 ICMP 소켓 생성 불가 (
sysctl net.ipv4.ping_group_range로 허용 가능) - cgroup — cgroup v2 + systemd 기반 위임(delegation)이 필요
- NFS — NFS 위의 rootless overlayfs 미지원 (대부분의 환경)
컨테이너 보안 심화
보안 계층 종합
| 계층 | 기술 | 보호 대상 |
|---|---|---|
| 프로세스 격리 | Namespaces | PID/네트워크/마운트/사용자 가시성 |
| 리소스 제한 | cgroups | CPU, 메모리, I/O, PID 수 |
| 시스템 콜 필터 | seccomp-bpf | 커널 공격 표면 축소 |
| 권한 최소화 | Capabilities | root 권한 세분화 |
| MAC | AppArmor / SELinux | 파일/네트워크/프로세스 접근 제어 |
| 읽기 전용 rootfs | OverlayFS + readonly | 파일시스템 무결성 |
| No New Privileges | PR_SET_NO_NEW_PRIVS | setuid 바이너리를 통한 권한 상승 |
AppArmor 컨테이너 프로파일
# Docker 기본 AppArmor 프로파일 (docker-default) 핵심 규칙
profile docker-default flags=(attach_disconnected,mediate_deleted) {
# 기본적으로 대부분의 파일 접근 허용
file,
network,
capability,
# 명시적 deny 규칙
deny mount, # 마운트 차단
deny /sys/firmware/** rwklx, # 펌웨어 접근 차단
deny /sys/kernel/security/** rwklx, # LSM 인터페이스 접근 차단
deny /proc/sys/** wklx, # 커널 파라미터 쓰기 차단
deny /proc/sysrq-trigger rwklx, # SysRq 차단
deny /proc/kcore rwklx, # 커널 메모리 접근 차단
}
SELinux 컨테이너 정책
| SELinux 타입 | 설명 |
|---|---|
container_t | 일반 컨테이너 프로세스 타입 |
container_file_t | 컨테이너 파일시스템 타입 |
container_var_lib_t | /var/lib/containers 하위 |
spc_t | 슈퍼 특권 컨테이너 (--privileged) |
# SELinux 컨테이너 컨텍스트 확인
$ ps -eZ | grep container
system_u:system_r:container_t:s0:c123,c456 ... nginx
# 볼륨 마운트 시 레이블 지정
$ podman run -v /data:/data:Z alpine # :Z = private 레이블 재지정
$ podman run -v /data:/data:z alpine # :z = shared 레이블 재지정
컨테이너 탈출 공격 벡터와 방어
| 공격 벡터 | 설명 | 방어 |
|---|---|---|
| /proc, /sys 남용 | 호스트 정보 유출, 커널 파라미터 조작 | read-only bind mount, masked paths |
| CAP_SYS_ADMIN | mount, BPF, namespace 생성 등 광범위 권한 | --cap-drop=ALL, 필요 cap만 추가 |
| 커널 취약점 | CVE를 통한 호스트 커널 직접 공격 | seccomp 필터, 커널 업데이트, gVisor/Kata |
| Docker 소켓 마운트 | /var/run/docker.sock으로 호스트 컨테이너 제어 | 소켓 마운트 금지, rootless 사용 |
| --privileged 플래그 | 모든 보안 장치 비활성화 | 절대 사용 금지, 대안 capability 사용 |
컨테이너 보안 주요 CVE 사례
컨테이너 탈출과 권한 상승 취약점의 실제 사례를 분석합니다. 컨테이너 런타임, OverlayFS, cgroup 등 다양한 계층에서 발견된 취약점들이 컨테이너 보안 아키텍처의 발전을 이끌어왔습니다.
runc의 /proc/self/exe 처리에서 컨테이너 내부의 악성 프로세스가 호스트의 runc 바이너리를 덮어쓸 수 있는 취약점입니다. 컨테이너가 runc exec을 통해 진입될 때, 컨테이너 내 악성 /bin/sh가 /proc/self/exe 심볼릭 링크를 통해 호스트의 runc 바이너리에 접근하여 덮어씁니다. 이후 호스트에서 runc가 실행될 때마다 공격자의 코드가 root 권한으로 실행됩니다.
/* CVE-2019-5736: runc /proc/self/exe 악용 흐름 */
/*
* 공격 조건: 컨테이너 내부에서 코드 실행 가능 (이미지 포이즌 또는 RCE)
*
* 1. 컨테이너 entrypoint를 악성 바이너리로 교체
* 2. 관리자가 "docker exec /bin/sh" 실행
* 3. runc가 컨테이너의 네임스페이스에 진입
* → /proc/self/exe가 호스트의 runc 바이너리를 가리킴
* 4. 악성 프로세스가 /proc/self/exe를 O_WRONLY로 열기 시도
* → runc 바이너리가 실행 중이므로 ETXTBSY 반환
* 5. runc가 execve()로 /bin/sh를 실행한 후,
* /proc//exe → 호스트 runc 바이너리
* → 이제 ETXTBSY 없이 쓰기 가능
* 6. 호스트의 runc 바이너리가 악성 코드로 덮어써짐
*
* 수정: runc가 자신의 바이너리를 memfd_create()로
* 메모리에 복사 후 실행 (호스트 파일 직접 참조 방지)
*/
cgroup v1의 release_agent 파일을 통해 컨테이너 내부에서 호스트의 root 권한으로 임의 명령을 실행할 수 있습니다. 컨테이너가 CAP_SYS_ADMIN + cgroup 마운트 권한을 가진 경우, release_agent에 호스트 경로의 스크립트를 지정하고 cgroup을 비우면 해당 스크립트가 호스트에서 실행됩니다.
/* CVE-2022-0492 공격 흐름 (CAP_SYS_ADMIN 필요) */
# 1. 컨테이너 내부에서 cgroup v1 마운트
mkdir /tmp/cgrp && mount -t cgroup -o rdma cgroup /tmp/cgrp
# 2. release_agent에 호스트에서 실행할 명령 지정
echo /path/on/host/exploit.sh > /tmp/cgrp/release_agent
# 3. notify_on_release 활성화
mkdir /tmp/cgrp/x && echo 1 > /tmp/cgrp/x/notify_on_release
# 4. cgroup을 비우면 release_agent가 호스트에서 실행됨
sh -c "echo \$\$ > /tmp/cgrp/x/cgroup.procs" && sleep 1
/*
* 방어:
* 1. --cap-drop=ALL (CAP_SYS_ADMIN 제거)
* 2. seccomp 프로파일에서 mount, unshare 시스콜 차단
* 3. AppArmor/SELinux로 cgroupfs 마운트 거부
* 4. cgroup v2 사용 (release_agent 메커니즘 없음)
* 5. 커널 5.17+에서 cgroup namespace 내 release_agent 쓰기 차단
*/
OverlayFS의 lower layer에 FUSE 파일시스템을 마운트하면, FUSE에서 setuid 비트가 설정된 파일을 제공할 수 있습니다. OverlayFS가 이 파일을 upper layer로 copy-up할 때 setuid 비트가 유지되어, 일반 사용자가 user namespace 내에서 root 소유의 setuid 바이너리를 생성할 수 있습니다.
runc 1.1.11 이전 버전에서 WORKDIR 지시문이 /proc/self/fd/<fd>와 같은 경로로 설정된 경우, 내부 파일 디스크립터 누수를 통해 호스트 파일시스템에 접근할 수 있습니다. 컨테이너 이미지 빌드 시 또는 runc exec --cwd 실행 시 트리거됩니다.
/* CVE-2024-21626: 파일 디스크립터 누수를 통한 탈출 */
/*
* runc가 컨테이너 초기화 중 일부 fd를 닫지 않아
* 호스트 파일시스템의 fd가 컨테이너 내부로 누수
*
* 공격: WORKDIR=/proc/self/fd/7 (호스트 / 를 가리키는 fd)
* → 컨테이너 내부에서 호스트 루트 파일시스템 접근 가능
*
* 수정: runc 초기화 시 불필요한 fd를 O_CLOEXEC으로 닫고,
* /proc/self/fd 순회하여 예상치 못한 fd 정리
*/
/* 컨테이너 보안 점검 체크리스트 */
# 1. 런타임 버전 확인
$ runc --version # 1.1.12+ 필수
$ containerd --version
# 2. 컨테이너 capability 확인
$ docker inspect --format '{{.HostConfig.CapAdd}}' <container>
# SYS_ADMIN, SYS_PTRACE, NET_ADMIN 등 불필요 cap 제거
# 3. seccomp 프로파일 확인
$ docker inspect --format '{{.HostConfig.SecurityOpt}}' <container>
# 4. AppArmor/SELinux 상태 확인
$ aa-status # AppArmor
$ getenforce # SELinux
2019: CVE-2019-5736 (runc 탈출) → memfd 기반 self-clone 도입
2020: CVE-2020-15257 (containerd shim) → abstract socket → filesystem socket 전환
2022: CVE-2022-0185 (fsconfig 힙 오버플로우), CVE-2022-0492 (cgroup v1 release_agent) → cgroup v2 전환 가속화
2023: CVE-2023-0386 (OverlayFS setuid) → OverlayFS inode 검증 강화
2024: CVE-2024-21626 (runc fd 누수) → fd 정리 로직 강화
핵심 교훈: 컨테이너 격리의 각 계층(런타임, 커널 서브시스템, 파일시스템)에서 독립적인 취약점이 발견될 수 있으므로, 심층 방어(defense-in-depth) 원칙 적용이 필수적입니다.
gVisor / Kata Containers: 강화 격리
기본 컨테이너는 호스트 커널을 공유하므로 커널 취약점에 노출됩니다. 이를 해결하는 두 가지 접근법이 있습니다:
| 기술 | 접근법 | 오버헤드 | 호환성 |
|---|---|---|---|
| gVisor (runsc) | 유저스페이스 커널 — syscall interception | 중간 (syscall 오버헤드) | 일부 syscall 미구현 |
| Kata Containers | 경량 VM 내부에서 컨테이너 실행 | 높음 (VM 부팅 오버헤드) | 매우 높음 |
Kata Containers 심화
Kata Containers는 경량 가상 머신(VM) 내부에서 컨테이너 워크로드를 실행하여, VM 수준의 격리와 컨테이너 수준의 사용자 경험을 결합합니다.
containerd shimv2 연동
Kata는 containerd의 shimv2 인터페이스를 구현하여, 기존 containerd 워크플로에 투명하게 통합됩니다.
# containerd 설정에서 Kata 런타임 등록
# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata]
runtime_type = "io.containerd.kata.v2"
# Kubernetes에서 RuntimeClass로 사용
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: kata
handler: kata
# Pod에서 Kata 런타임 지정
apiVersion: v1
kind: Pod
spec:
runtimeClassName: kata
containers:
- name: app
image: nginx
주요 설정 옵션 (configuration.toml)
| 옵션 | 설명 | 기본값 |
|---|---|---|
hypervisor | QEMU, Cloud Hypervisor, Firecracker | QEMU |
default_memory | VM 메모리 (MB) | 256 |
default_vcpus | VM vCPU 수 | 1 |
shared_fs | 파일시스템 공유 방식 | virtiofs |
kernel_path | 게스트 커널 경로 | /usr/share/kata-containers/vmlinux |
enable_debug | 디버그 모드 | false |
최소 컨테이너 직접 구현
컨테이너의 내부 동작을 이해하기 위해, 셸 스크립트와 커널 API만으로 최소 컨테이너를 직접 구현해 봅니다.
셸 스크립트로 컨테이너 구현 (교육 목적)
#!/bin/bash
# mini-container.sh — 교육용 최소 컨테이너
# root 권한 필요 (rootless는 unshare --user 추가)
set -e
ROOTFS="./rootfs" # alpine rootfs 등 준비 필요
HOSTNAME="mini-container"
## 1단계: 네임스페이스 생성 + 새 프로세스 시작
unshare --mount --uts --ipc --pid --net --fork \
--mount-proc=$ROOTFS/proc \
/bin/bash -c "
# 2단계: 호스트명 설정 (UTS namespace)
hostname $HOSTNAME
# 3단계: 루트 파일시스템 전환 (pivot_root)
mount --bind $ROOTFS $ROOTFS
cd $ROOTFS
mkdir -p .put_old
pivot_root . .put_old
umount -l /.put_old
rmdir /.put_old
# 4단계: 필수 파일시스템 마운트
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t tmpfs tmpfs /dev
# 5단계: cgroup 리소스 제한 (v2)
# (호스트에서 미리 cgroup 디렉토리를 생성해야 함)
# echo 50000 100000 > /sys/fs/cgroup/mini/cpu.max
# echo 67108864 > /sys/fs/cgroup/mini/memory.max # 64MB
# echo \$\$ > /sys/fs/cgroup/mini/cgroup.procs
# 6단계: 컨테이너 셸 실행
exec /bin/sh
"
runc bundle 생성 및 실행 예제
# OCI Bundle 생성 (rootfs + config.json)
$ mkdir -p mycontainer/rootfs
# Alpine rootfs 준비
$ docker export $(docker create alpine:latest) | \
tar -C mycontainer/rootfs -xf -
# OCI 기본 config.json 생성
$ cd mycontainer
$ runc spec
# config.json 수정 (예: 메모리 256MB 제한 추가)
# "resources": { "memory": { "limit": 268435456 } }
# 컨테이너 실행
$ runc run my-test-container
# 다른 터미널에서 상태 확인
$ runc list
ID PID STATUS BUNDLE CREATED
my-test-container 12345 running /home/user/mycontainer 2024-...
# 컨테이너 내부에서 격리 확인
/ # hostname
runc
/ # ps aux
PID USER TIME COMMAND
1 root 0:00 sh
7 root 0:00 ps aux
/ # cat /proc/1/cgroup
0::/
디버깅과 모니터링
네임스페이스 진입 및 확인
# nsenter — 실행 중인 컨테이너의 네임스페이스에 진입
$ nsenter -t <PID> --mount --uts --ipc --net --pid -- /bin/sh
# -t: 대상 프로세스 PID
# 각 플래그로 원하는 네임스페이스만 선택적으로 진입 가능
# lsns — 시스템의 모든 네임스페이스 나열
$ lsns
NS TYPE NPROCS PID USER COMMAND
4026531836 pid 1 1234 root nginx: master process
4026531840 mnt 1 1234 root nginx: master process
4026531992 net 1 1234 root nginx: master process
# 특정 프로세스의 네임스페이스 ID 확인
$ ls -la /proc/<PID>/ns/
lrwxrwxrwx 1 root root 0 ... cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 ... mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 root root 0 ... net -> 'net:[4026531992]'
lrwxrwxrwx 1 root root 0 ... pid -> 'pid:[4026531836]'
프로세스 네임스페이스 정보
# /proc//status에서 네임스페이스별 PID 확인
$ grep NS /proc/<PID>/status
NSpid: 1234 1 # 호스트 PID 1234, 컨테이너 내 PID 1
NStgid: 1234 1
NSsid: 1234 1
NSpgid: 1234 1
# 컨테이너 프로세스의 cgroup 경로
$ cat /proc/<PID>/cgroup
0::/system.slice/docker-abc123.scope
cgroup 모니터링
# systemd-cgtop — cgroup별 리소스 사용량 실시간 모니터링
$ systemd-cgtop
Control Group Tasks %CPU Memory Input/s Output/s
/ 85 12.3 1.2G - -
/system.slice/docker-abc.. 3 5.1 128.0M 4.0K 12.0K
# cgroup v2 직접 확인
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.current
134217728 # 128MB 사용 중
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.stat
usage_usec 5234567
user_usec 3123456
system_usec 2111111
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/pids.current
3 # 3개 프로세스
디버깅 도구 비교
| 도구 | 대상 런타임 | 용도 |
|---|---|---|
| docker inspect | Docker | 컨테이너 상세 정보 (설정, 네트워크, 마운트) |
| crictl | CRI 런타임 | Kubernetes 노드의 컨테이너/파드 디버깅 |
| ctr | containerd | containerd 직접 제어 (이미지, 컨테이너, 태스크) |
| nerdctl | containerd | Docker-호환 CLI (compose 지원) |
| podman inspect | Podman | Docker와 동일한 inspect 인터페이스 |
| runc list/state | runc | OCI 런타임 수준 상태 확인 |
# crictl — Kubernetes 노드 디버깅
$ crictl pods # 파드 목록
$ crictl ps # 컨테이너 목록
$ crictl inspect <container-id> # 컨테이너 상세
$ crictl logs <container-id> # 로그 확인
$ crictl exec -it <container-id> /bin/sh # 컨테이너 내부 셸
일반적인 문제
| 문제 | 증상 | 해결 |
|---|---|---|
| OOM Kill | 컨테이너 갑자기 종료, exit code 137 | memory.max 확인, dmesg에서 OOM 로그, 메모리 제한 조정 |
| PID 1 좀비 | 좀비 프로세스 누적, PID 고갈 | init 프로세스 사용 (tini, dumb-init), --init 플래그 |
| 마운트 누수 | 컨테이너 삭제 후 마운트 포인트 잔존 | MNT_DETACH로 lazy unmount, findmnt로 확인 |
| DNS 실패 | 컨테이너에서 이름 해석 불가 | /etc/resolv.conf 확인, --dns 옵션, bridge 설정 검증 |
| 퍼미션 에러 | rootless에서 파일 접근 거부 | subuid/subgid 매핑 확인, 볼륨 소유권 조정 |