Docker — 컨테이너가 뭔지부터 실무 활용까지
"컨테이너는 가상 머신이 아니다"라는 말을 많이 듣는다. 그렇다면 컨테이너는 정확히 어떻게 격리되는 걸까? 커널을 공유하는데 어떻게 서로 영향을 주지 않을 수 있을까?
Docker의 핵심 개념부터 실무에서 어떻게 쓰이는지까지 한 번에 정리했다.
컨테이너 vs VM
둘 다 애플리케이션을 격리된 환경에서 실행한다는 점은 같은데, 격리하는 방식이 완전히 다릅니다.
VM (가상 머신)
VM은 하이퍼바이저 위에 ** 게스트 OS 전체 **를 올립니다. 하드웨어를 가상화해서 각 VM마다 독립적인 커널을 갖는 구조입니다.
┌──────────────┐ ┌──────────────┐
│ App A │ │ App B │
│ Guest OS │ │ Guest OS │
└──────────────┘ └──────────────┘
┌─────────────────────────────────┐
│ Hypervisor │
├─────────────────────────────────┤
│ Host OS │
└─────────────────────────────────┘
부팅 시간이 분 단위이고, 이미지 크기가 GB 단위입니다. 그 대신 완전한 격리를 보장하니까 보안이 중요한 멀티테넌트 환경에서는 여전히 VM을 쓰는 경우가 많습니다.
컨테이너
컨테이너는 ** 호스트 OS의 커널을 공유 **합니다. 별도의 게스트 OS가 없고, ** 컨테이너 런타임 **(Docker Engine, containerd 등)이 프로세스 단위로 격리를 해줍니다.
┌──────────┐ ┌──────────┐ ┌──────────┐
│ App A │ │ App B │ │ App C │
└──────────┘ └──────────┘ └──────────┘
┌─────────────────────────────────────┐
│ Container Runtime │
├─────────────────────────────────────┤
│ Host OS (커널 공유) │
└─────────────────────────────────────┘
커널을 공유하니까 시작이 밀리초 단위로 빠르고, 이미지도 MB 단위로 가볍습니다. 이게 컨테이너가 가벼운 핵심 이유인데, 여기까지만 알면 좀 아쉽습니다. 뒤에서 namespace/cgroup 얘기를 할 때 더 깊게 다루겠습니다.
Docker 아키텍처
Docker를 쓸 때 docker run만 치면 되지만, 내부적으로는 여러 컴포넌트가 협력합니다.
Docker Engine (dockerd)
Docker의 데몬 프로세스입니다. REST API를 통해 CLI 명령을 받아서 처리합니다. 이미지 관리, 네트워크 설정, 볼륨 관리 등 대부분의 작업을 여기서 조율합니다.
containerd
컨테이너의 라이프사이클을 실제로 관리하는 고수준 런타임입니다. 이미지 pull, 컨테이너 생성/삭제, 스토리지 관리를 담당합니다. 원래 Docker 안에 포함돼 있었는데 CNCF에 기증되면서 독립 프로젝트가 됐습니다. Kubernetes도 containerd를 직접 사용하죠.
runc
OCI(Open Container Initiative) 표준을 따르는 저수준 런타임입니다. 실제로 리눅스 namespace, cgroup을 세팅해서 컨테이너 프로세스를 생성하는 놈이 바로 runc입니다.
흐름을 정리하면 이렇게 됩니다.
docker CLI → dockerd (REST API) → containerd → runc → 컨테이너 프로세스
Docker 내부 구조가 뭔가요? 핵심만 정리하면 이 세 계층만 짚어주면 충분합니다.
이미지 vs 컨테이너
처음 Docker 배울 때 제일 헷갈리는 부분입니다. 비유하자면 이미지는 클래스, 컨테이너는 인스턴스. 이미지 하나로 컨테이너 여러 개를 띄울 수 있습니다.
레이어 구조
Docker 이미지는 ** 여러 개의 읽기 전용 레이어 **가 쌓인 구조입니다. Dockerfile의 각 명령어(FROM, COPY, RUN 등)가 하나의 레이어를 만듭니다.
┌─────────────────────────┐
│ Layer 4: CMD │ ← 읽기 전용
├─────────────────────────┤
│ Layer 3: RUN npm build │ ← 읽기 전용
├─────────────────────────┤
│ Layer 2: COPY . . │ ← 읽기 전용
├─────────────────────────┤
│ Layer 1: FROM node:18 │ ← 읽기 전용
└─────────────────────────┘
컨테이너를 실행하면 이 레이어 스택 맨 위에 ** 쓰기 가능한 레이어 **(Container Layer)가 하나 추가됩니다. 컨테이너 안에서 파일을 수정하면 이 레이어에만 기록되고, 원본 이미지 레이어는 건드리지 않습니다.
Union File System (UnionFS)
여러 레이어를 하나의 파일 시스템처럼 보이게 해주는 기술입니다. Docker는 overlay2를 기본으로 사용합니다. 상위 레이어가 하위 레이어의 같은 경로 파일을 덮어쓰는 방식이라, 수정이 발생하면 해당 파일만 상위 레이어로 복사해서 쓰는 Copy-on-Write 전략을 씁니다.
이 구조 덕분에 같은 베이스 이미지를 쓰는 컨테이너 100개를 띄워도 베이스 레이어는 디스크에 딱 한 벌만 존재합니다. 저장 공간이 엄청 절약되는 거죠.
Dockerfile 작성법
Dockerfile은 이미지를 빌드하는 레시피입니다. 자주 쓰는 명령어부터 짚어보겠습니다.
주요 명령어
# 베이스 이미지 지정
FROM node:18-alpine
# 작업 디렉토리 설정
WORKDIR /app
# 파일 복사 (호스트 → 이미지)
COPY package*.json ./
# 명령어 실행 (빌드 타임)
RUN npm ci --only=production
# 나머지 소스 복사
COPY . .
# 포트 문서화 (실제로 열지는 않음)
EXPOSE 3000
# 컨테이너 실행 시 기본 명령
CMD ["node", "server.js"]
CMD vs ENTRYPOINT
이거 자주 헷갈리는 부분입니다.
| 구분 | CMD | ENTRYPOINT |
|---|---|---|
| 역할 | 기본 실행 명령어 | 고정 실행 명령어 |
| 오버라이드 | docker run 뒤에 명령 넣으면 대체됨 | --entrypoint 플래그 써야 대체 가능 |
| 조합 | 단독 사용 가능 | CMD와 조합하면 CMD가 인자 역할 |
실무에서 흔히 쓰는 패턴은 ENTRYPOINT로 실행 파일을 지정하고, CMD로 기본 인자를 넘기는 겁니다.
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"]
이러면 docker run myapp --port 9090으로 인자만 바꿀 수 있습니다.
멀티스테이지 빌드
빌드 도구는 빌드할 때만 필요하지, 실행할 때는 필요 없습니다. 그래서 빌드 단계와 실행 단계를 분리하는 게 멀티스테이지 빌드입니다.
# 1단계: 빌드
FROM node:18 AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
# 2단계: 실행
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
빌드 스테이지의 node_modules 중 devDependencies, 소스 파일, 빌드 캐시 같은 것들이 최종 이미지에 안 들어가니까 이미지 크기가 확 줄어듭니다. Go 같은 컴파일 언어에서 효과가 극대화되는데, 빌드 이미지는 1GB인데 실행 이미지는 scratch 기반 10MB짜리로 만들 수 있습니다.
이미지 최적화
프로덕션에서 Docker 쓰려면 이미지 크기를 줄이는 게 중요합니다. 레지스트리 push/pull 시간, 디스크 사용량, 보안 공격 표면 전부 이미지 크기와 직결되니까요.
레이어 캐시 활용
Docker는 빌드할 때 각 레이어를 캐시합니다. 변경이 감지되면 그 레이어부터 아래 캐시를 전부 무효화 하기 때문에, 변경 빈도가 낮은 것을 위에 놓는 게 중요합니다.
# 좋은 예: 의존성 먼저 복사
COPY package*.json ./
RUN npm ci
COPY . .
# 나쁜 예: 소스 변경될 때마다 npm ci 다시 실행
COPY . .
RUN npm ci
소스 코드는 자주 바뀌지만 package.json은 잘 안 바뀝니다. 의존성 설치를 먼저 하면 소스만 바뀌었을 때 npm ci 레이어 캐시를 그대로 쓸 수 있어서 빌드가 훨씬 빨라집니다.
.dockerignore
.gitignore처럼 빌드 컨텍스트에서 제외할 파일을 지정합니다. node_modules, .git, 로그 파일 같은 것들을 빼면 빌드 속도와 이미지 크기 둘 다 개선됩니다.
node_modules
.git
*.log
dist
.env
Alpine 베이스 이미지
node:18 이미지가 약 900MB인 반면, node:18-alpine은 약 170MB입니다. Alpine Linux는 musl libc를 쓰기 때문에 가끔 네이티브 모듈 호환 문제가 생기긴 하는데, 대부분의 Node.js 애플리케이션에서는 문제없이 돌아갑니다. 요즘은 node:18-slim (Debian 기반, ~240MB)도 좋은 선택지입니다.
Docker 네트워크
컨테이너끼리, 또는 컨테이너와 외부 사이의 통신을 관리하는 부분입니다.
bridge (기본)
Docker가 설치되면 docker0이라는 가상 브리지가 생깁니다. 따로 네트워크를 지정하지 않으면 모든 컨테이너가 이 브리지에 붙습니다. 같은 브리지 안의 컨테이너끼리는 IP로 통신 가능한데, 사용자 정의 브리지 네트워크를 만들면 컨테이너 이름으로 DNS 해석 이 됩니다. 실무에서는 거의 항상 사용자 정의 네트워크를 만들어 씁니다.
docker network create my-net
docker run --network my-net --name api my-api
docker run --network my-net --name db postgres
# api 컨테이너에서 "db"라는 이름으로 postgres에 접근 가능
host
컨테이너가 호스트의 네트워크 스택을 그대로 사용합니다. 포트 매핑이 필요 없어서 네트워크 성능이 약간 좋아지지만, 포트 충돌 가능성이 있고 격리가 깨집니다. 성능이 극도로 중요한 경우에만 씁니다.
none
네트워크 인터페이스가 아예 없습니다. 외부와 완전히 격리돼야 하는 배치 작업 같은 데서 쓸 수 있는데, 솔직히 실무에서 거의 안 씁니다.
overlay
여러 Docker 호스트에 걸친 네트워크를 만들 때 씁니다. Docker Swarm이나 Kubernetes 환경에서 노드 간 컨테이너 통신에 사용됩니다. VXLAN으로 L2 네트워크를 터널링하는 방식입니다.
볼륨
컨테이너는 기본적으로 휘발성 입니다. 컨테이너를 지우면 안에 있던 데이터도 같이 날아갑니다. 데이터를 살리려면 볼륨을 써야 합니다.
bind mount vs volume
| 구분 | bind mount | volume |
|---|---|---|
| 경로 지정 | 호스트의 절대 경로 직접 지정 | Docker가 관리하는 경로 |
| 이식성 | 호스트 경로에 의존적 | 호스트와 무관 |
| 관리 | Docker가 관리 안 함 | docker volume 명령으로 관리 |
| 용도 | 개발 환경에서 소스 코드 마운트 | 프로덕션 데이터 영속화 |
# bind mount — 개발할 때 소스 실시간 반영
docker run -v $(pwd)/src:/app/src my-app
# volume — DB 데이터 영속화
docker volume create pg-data
docker run -v pg-data:/var/lib/postgresql/data postgres
개발할 때는 bind mount로 소스를 마운트해서 핫리로드를 쓰고, 프로덕션에서 DB 데이터는 named volume으로 관리하는 게 일반적인 패턴입니다.
Docker Compose
컨테이너 하나만 띄울 때는 docker run이면 충분한데, 웹 서버 + DB + 캐시처럼 여러 컨테이너를 함께 관리해야 할 때 Compose가 필요합니다.
docker-compose.yml 예시
version: '3.8'
services:
api:
build: ./api
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/mydb
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:15-alpine
volumes:
- pg-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pg-data:
depends_on 주의사항
depends_on은 컨테이너 시작 순서 만 보장하지, 애플리케이션이 준비됐는지는 보장하지 않습니다. 위 예시처럼 condition: service_healthy를 써서 healthcheck가 통과해야 다음 서비스가 시작되게 해야 안전합니다. 이거 모르고 그냥 depends_on: [db]만 쓰면 DB가 아직 커넥션을 안 받는 상태에서 API가 뜨면서 터지는 경우가 생깁니다.
환경 변수
Compose에서 환경 변수를 주입하는 방법이 여러 가지입니다.
# 1. 직접 지정
environment:
- NODE_ENV=production
# 2. .env 파일 참조
env_file:
- .env.production
# 3. 호스트 환경 변수 전달 (값 생략하면 호스트 값 사용)
environment:
- API_KEY
시크릿을 docker-compose.yml에 하드코딩하면 안 됩니다. .env 파일을 .gitignore에 넣거나, Docker Secrets(Swarm) 또는 외부 비밀 관리 도구를 쓰는 게 맞습니다.
실무 활용
개발 환경 통일
"내 컴퓨터에서는 되는데?"를 없애는 게 Docker의 원래 존재 이유입니다. 팀원 모두가 같은 Compose 파일로 동일한 개발 환경을 띄우면 OS, 런타임 버전, 의존성 차이로 생기는 문제가 사라집니다. 신규 입사자 온보딩도 docker compose up -d 한 줄이면 끝.
CI/CD 파이프라인
# GitHub Actions 예시
steps:
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/myorg/api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
이미지 태그에 커밋 SHA를 쓰면 어떤 코드 상태로 빌드된 이미지인지 추적할 수 있습니다. latest 태그만 쓰면 롤백할 때 어떤 버전으로 돌아가야 하는지 알 수가 없으니까, 프로덕션에서는 반드시 구체적인 태그를 붙여야 합니다.
마이크로서비스
각 서비스를 독립적인 컨테이너로 패키징하면 서비스별로 다른 언어, 다른 런타임을 쓸 수 있습니다. 배포 단위가 서비스 단위가 되니까 하나 고쳐서 하나만 배포하면 됩니다. 다만 컨테이너가 늘어나면 오케스트레이션이 필요해지고, 그때 Kubernetes가 등장합니다.
주의할 점
컨테이너가 가벼운 진짜 이유 — namespace와 cgroup
앞에서 "커널을 공유해서 가볍다"고 했는데, 구체적으로는 리눅스의 두 가지 커널 기능이 핵심입니다.
namespace: 프로세스가 볼 수 있는 시스템 자원의 범위를 제한합니다.
| namespace | 격리 대상 |
|---|---|
| PID | 프로세스 ID |
| NET | 네트워크 인터페이스, 포트 |
| MNT | 파일 시스템 마운트 포인트 |
| UTS | 호스트명 |
| IPC | 프로세스 간 통신 |
| USER | UID/GID |
컨테이너 안에서 ps aux를 치면 자기 프로세스만 보이는 게 PID namespace 덕분이고, 컨테이너마다 같은 포트를 쓸 수 있는 게 NET namespace 덕분입니다.
cgroup (Control Group): CPU, 메모리, 디스크 I/O, 네트워크 대역폭 등 자원의 ** 사용량을 제한 **합니다. docker run --memory=512m --cpus=1.5 같은 옵션이 내부적으로 cgroup 설정을 하는 겁니다.
VM은 하드웨어 레벨에서 가상화를 하지만, 컨테이너는 이 커널 기능만으로 격리를 구현하니까 오버헤드가 거의 없습니다. 별도의 커널 부팅이 필요 없으니 시작 시간도 비교가 안 될 만큼 빠르고요.
Docker vs Podman
Podman은 Red Hat이 만든 컨테이너 도구인데, 가장 큰 차이는 ** 데몬이 없다 **는 겁니다.
| 구분 | Docker | Podman |
|---|---|---|
| 아키텍처 | 클라이언트-서버 (dockerd 데몬) | 데몬리스 (fork/exec) |
| 루트 권한 | 기본적으로 root 필요 | rootless가 기본 |
| OCI 호환 | O | O |
| CLI | docker | podman (docker 호환) |
| Pod 개념 | X | O (Kubernetes Pod과 유사) |
alias docker=podman 해놓으면 기존 명령어가 거의 그대로 동작합니다. 보안 측면에서 Podman이 유리한 점이 많아서 RHEL 계열에서는 Docker 대신 Podman을 밀고 있습니다.
보안 — rootless 컨테이너
Docker는 기본적으로 root로 돌아갑니다. 컨테이너 탈출(container escape) 취약점이 생기면 호스트의 root 권한이 노출될 수 있어서 꽤 위험합니다.
대응 방법은 몇 가지가 있습니다.
- **rootless 모드 **: Docker 20.10부터 지원. 데몬 자체를 일반 사용자 권한으로 실행
- **USER 지시어 **: Dockerfile에서
USER node같이 비root 사용자로 전환 - **read-only 파일시스템 **:
docker run --read-only로 컨테이너 파일시스템 쓰기 금지 - seccomp/AppArmor: 시스템 콜 수준에서 권한 제한
# Dockerfile에서 비root 사용자 사용 예시
FROM node:18-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["node", "server.js"]
Docker 보안은 어떻게 신경 쓸 수 있나요? 핵심만 정리하면 rootless 모드와 USER 지시어 정도만 알아도 충분합니다.
파생 개념
Docker를 이해했다면 자연스럽게 이어지는 개념들이 있습니다.
- Kubernetes (K8s): 컨테이너 오케스트레이션 도구. 컨테이너가 수십~수천 개로 늘어나면 배포, 스케일링, 자가 복구를 자동화해야 하는데, 그걸 해주는 플랫폼입니다. Docker가 컨테이너 하나를 관리하는 도구라면, K8s는 컨테이너 클러스터를 관리하는 도구.
- CI/CD: Continuous Integration / Continuous Delivery. Docker 이미지를 빌드하고 레지스트리에 푸시하고 프로덕션에 배포하는 파이프라인 전체를 말합니다. GitHub Actions, GitLab CI, Jenkins 등이 이 파이프라인을 실행하는 도구들.
- ** 리눅스 namespace/cgroup**: Docker의 격리 메커니즘 그 자체. 위에서 다뤘지만, 이건 Docker에만 쓰이는 게 아니라 리눅스 컨테이너 기술의 근간입니다. LXC, systemd-nspawn 등도 같은 커널 기능을 사용합니다.