안동민 개발노트

본문 시작

컨테이너와 Docker

네임스페이스와 cgroups가 프로세스를 격리하는 원리를 익히고 Docker 이미지 레이어와 컨테이너 실행 흐름을 이해합니다.

가상 머신은 강한 격리를 제공하지만, 각 VM이 완전한 OS를 포함하므로 무겁습니다.

게스트 커널과 사용자 공간을 따로 준비하므로 그에 따른 시작·메모리·저장 비용이 있습니다. 구체적인 크기와 시간은 이미지와 환경에 따라 달라집니다.

컨테이너는 같은 커널을 공유하면서 프로세스를 격리하는 경량 가상화 기술입니다.

2013년 Docker의 등장 이후 컨테이너는 소프트웨어 배포의 표준이 되었습니다.


컨테이너 vs 가상 머신

VM과 컨테이너가 나누는 실행 경계

커널, 이미지, 공유 자원을 기준으로 비교합니다.

VM과 컨테이너가 나누는 실행 경계
비교 기준가상 머신리눅스 컨테이너
커널게스트마다 별도 커널을 실행일반적인 런타임은 호스트 커널을 공유
실행 환경게스트 OS와 가상 장치를 준비이미지의 사용자 공간을 격리된 프로세스로 실행
공유되는 자원호스트와 하이퍼바이저의 자원을 공유호스트 커널과 자원을 공유하며 정책으로 제한
커널
가상 머신: 게스트마다 별도 커널을 실행
리눅스 컨테이너: 일반적인 런타임은 호스트 커널을 공유
실행 환경
가상 머신: 게스트 OS와 가상 장치를 준비
리눅스 컨테이너: 이미지의 사용자 공간을 격리된 프로세스로 실행
공유되는 자원
가상 머신: 호스트와 하이퍼바이저의 자원을 공유
리눅스 컨테이너: 호스트 커널과 자원을 공유하며 정책으로 제한

시작 시간·이미지 크기·배치 밀도는 측정 조건에 따라 달라집니다. VM 위에 컨테이너를 실행하면 두 경계를 함께 사용할 수 있습니다.

컨테이너는 같은 커널을 공유하므로, 커널 취약점이 발견되면 같은 호스트의 컨테이너들이 함께 영향을 받을 수 있습니다.


리눅스 컨테이너의 핵심 기술

컨테이너는 완전히 새로운 가상화 기술이라기보다, 리눅스 커널에 이미 있는 격리와 자원 제어 기능을 런타임이 조합해서 제공하는 실행 환경입니다.

네임스페이스 (Namespaces)

프로세스에게 시스템 자원의 독립된 뷰를 제공합니다.

어떤 네임스페이스를 분리하거나 공유했는지에 따라 보이는 범위가 달라집니다.

  • PID: PID 번호 공간을 분리합니다. 상위 네임스페이스에서는 하위 프로세스를 볼 수 있습니다.
  • NET: 네트워크 장치·IP·포트·라우팅을 분리합니다.
  • MNT: 마운트 목록을 분리합니다. 이것만으로 새 루트 파일 시스템을 만들지는 않습니다.
  • UTS: 호스트 이름을 분리합니다.
  • IPC: System V IPC와 POSIX 메시지 큐 등을 분리합니다.
  • USER: 사용자·그룹 ID 매핑을 분리합니다. 컨테이너 root의 호스트 권한은 매핑과 설정에 달려 있습니다.
  • CGROUP: cgroup 경로의 뷰를 분리합니다. 자원 한도 자체는 컨트롤러가 정합니다.
namespace_demo.c
#define _GNU_SOURCE
#include <stdio.h>
#include <sched.h>
#include <unistd.h>
#include <sys/wait.h>

/* 새 네임스페이스에서 실행될 함수 */
int child_func(void *arg) {
    printf("자식 PID (네임스페이스 내부): %d\n", getpid());  /* 1 */

    char hostname[] = "container";
    sethostname(hostname, sizeof(hostname) - 1);

    /* 새 네임스페이스에서 셸 실행 */
    execlp("/bin/sh", "sh", NULL);
    return 0;
}

int main() {
    char stack[65536];

    /* 새 PID + UTS + NET 네임스페이스로 프로세스 생성 */
    pid_t pid = clone(
        child_func,
        stack + sizeof(stack),
        CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWNET | SIGCHLD,
        NULL
    );

    printf("호스트에서 본 자식 PID: %d\n", pid);  /* 실제 PID */
    waitpid(pid, NULL, 0);

    return 0;
}

위 C 코드는 PID·UTS·NET만 분리하는 축약 예제입니다. 필요한 권한과 시스템 콜 성공을 전제하며 실패 처리는 생략했습니다. MNT·USER 네임스페이스, 새 루트와 /proc 마운트, 네트워크 연결은 구성하지 않으므로 완성된 컨테이너 런타임은 아닙니다.

cgroups (Control Groups)

프로세스 그룹을 계층적으로 묶고 CPU, 메모리, I/O, PID 같은 자원 사용량을 제한하거나 분배합니다.

cgroup_demo.sh
# cgroup v2에서 메모리 제한 설정 (개념)
# /sys/fs/cgroup/my-container/

# 메모리 제한: 256MiB
echo $((256 * 1024 * 1024)) > /sys/fs/cgroup/my-container/memory.max

# CPU 제한: 그룹 합계로 100ms당 50ms (CPU 하나의 50% 상당)
echo "50000 100000" > /sys/fs/cgroup/my-container/cpu.max

# 태스크 수 제한: 최대 100개 (스레드 포함)
echo 100 > /sys/fs/cgroup/my-container/pids.max

# 프로세스를 cgroup에 추가
echo $$ > /sys/fs/cgroup/my-container/cgroup.procs
cgroup 예제의 값과 제한 방식

코드에 설정한 한도와 그 한도가 제한하는 대상을 대응합니다.

cgroup 예제의 값과 제한 방식
대상코드의 설정해석
메모리memory.max = 268435456256MiB. 회수로 해소되지 않는 압박은 cgroup OOM으로 이어질 수 있음
CPU 시간cpu.max = 50000 100000100ms 기간에 그룹 합계 50ms. 한도 소진 시 제한되며 특정 CPU에 고정하지 않음
태스크 수pids.max = 100스레드를 포함한 태스크 생성 한도. 한도 초과의 새 fork/clone이 실패할 수 있음
디스크 I/O이 예제는 설정하지 않음io.max는 장치별 처리량·IOPS를 제한할 때 별도로 구성
메모리
코드의 설정: memory.max = 268435456
해석: 256MiB. 회수로 해소되지 않는 압박은 cgroup OOM으로 이어질 수 있음
CPU 시간
코드의 설정: cpu.max = 50000 100000
해석: 100ms 기간에 그룹 합계 50ms. 한도 소진 시 제한되며 특정 CPU에 고정하지 않음
태스크 수
코드의 설정: pids.max = 100
해석: 스레드를 포함한 태스크 생성 한도. 한도 초과의 새 fork/clone이 실패할 수 있음
디스크 I/O
코드의 설정: 이 예제는 설정하지 않음
해석: io.max는 장치별 처리량·IOPS를 제한할 때 별도로 구성

기존 cgroup 경로, 부모의 컨트롤러 활성화와 쓰기 권한을 전제합니다. 마지막 echo는 현재 셸을 이동하며 이미 존재하는 자식 프로세스까지 자동 이동시키지는 않습니다.


Docker의 동작 원리

Docker는 컨테이너를 쉽게 빌드, 배포, 실행하기 위한 도구입니다.

이미지와 레이어

Docker 이미지(Image)는 파일 시스템 레이어와 실행 설정을 담습니다. 실행 중인 프로세스의 메모리 상태를 복원하는 스냅샷과는 다릅니다.

이미지는 레이어(Layer)로 구성됩니다.

이미지 레이어는 읽기 전용이며, 기본적인 컨테이너 실행에서는 그 위에 쓰기 가능한 레이어가 추가됩니다. overlay2의 OverlayFS처럼 레이어를 결합하는 방식은 스토리지 백엔드에 따라 달라집니다.

같은 베이스 이미지를 공유하는 컨테이너들은 중복 저장 없이 공통 레이어를 공유하므로 디스크를 절약합니다.

Dockerfile

Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# non-root 사용자로 실행 (보안)
RUN useradd -r appuser
USER appuser

EXPOSE 8000
CMD ["python", "app.py"]

FROM은 베이스 이미지를 선택하고, RUN·COPY 등은 파일 시스템 변경을 만듭니다. USER·EXPOSE·CMD처럼 실행 설정을 바꾸는 명령과 구분해야 합니다.

같은 RUN에서 임시 파일을 만들고 지우면 하위 레이어에 불필요한 파일을 남기지 않을 수 있습니다. 명령을 합치는 것만으로 크기가 항상 줄어드는 것은 아니며 캐시 재사용도 함께 고려합니다.

Docker 실행

docker_commands.sh
# 이미지 빌드
docker build -t myapp:1.0 .

# 컨테이너 실행 (리소스 제한 + 보안 옵션)
docker run -d \
  --name web \
  -p 8080:80 \
  --memory=512m \
  --cpus=1.0 \
  --read-only \
  --tmpfs /var/cache/nginx \
  --tmpfs /var/run \
  --security-opt=no-new-privileges \
  nginx:alpine

# 실행 중인 컨테이너 목록
docker ps

# 컨테이너 내부 접속
docker exec -it web /bin/sh

# 리소스 사용량 모니터링
docker stats web

# 컨테이너가 사용하는 네임스페이스 확인
docker inspect --format '{{.State.Pid}}' web
# ls -la /proc/<PID>/ns/

앞의 빌드는 myapp 예제이며 실행 대상은 별도의 nginx 이미지입니다. Nginx가 기본적으로 쓰는 경로에는 임시 쓰기 공간을 제공합니다. 추가 설정이 다른 경로에 기록한다면 그 경로도 구성해야 합니다.

--memory=512m은 메모리 한도를 512MiB로 정합니다. 회수로 한도를 지킬 수 없으면 cgroup OOM 처리가 일어날 수 있습니다. 스왑 정책은 별도이며 한도 도달이 항상 컨테이너 전체의 즉시 종료를 뜻하지는 않습니다.


컨테이너 격리의 수준과 한계

컨테이너가 같은 커널을 공유한다는 것은 커널 수준의 취약점에 모든 컨테이너가 노출된다는 뜻입니다.

격리 강화 기술

  • rootless: 엔진과 컨테이너를 비특권 사용자 권한으로 실행합니다.
  • seccomp: 허용할 시스템 콜을 제한합니다.
  • AppArmor/SELinux: 파일·자원 접근에 강제 접근 제어 정책을 적용합니다.
  • user namespace: 컨테이너 ID와 호스트 ID를 매핑합니다.
  • gVisor: 애플리케이션 시스템 콜을 사용자 공간 커널에서 처리합니다.
  • Kata Containers: VM을 컨테이너 실행 경계로 사용합니다.
  • Firecracker: KVM 기반 마이크로 VM을 제공합니다.
seccomp_profile.sh
# Docker 기본 seccomp 프로파일은 위험한 시스템 콜 일부를 차단
# 예: mount, reboot, syslog 등 운영체제 전체에 영향을 주는 호출

# 커스텀 seccomp 프로파일 적용
docker run --security-opt seccomp=custom-profile.json myapp

실무에서는 보안 요구사항에 따라 적절한 수준을 선택합니다.

rootless·seccomp만으로 모든 서비스의 보안 요구가 충족되지는 않습니다. 신뢰 경계와 위협 모델에 맞춰 권한·정책·VM 격리를 조합합니다. 커스텀 seccomp 예제는 별도로 작성한 custom-profile.json과 빌드한 이미지를 전제합니다.

다음 절에서는 컨테이너를 대규모로 관리하는 오케스트레이션과 실무 인프라 선택 기준을 다루겠습니다.