Image
컨테이너를 만들기 위한 읽기 전용 템플릿이다. 앱 코드, 런타임, OS 패키지, 기본 실행 명령이 들어 있다.
빌드하고, 태그를 붙이고, 레지스트리에 푸시/풀하는 대상이다.분류: Layer 5 - 플랫폼 엔지니어링 & 자동화 | 작성일: 2026-03-21
Docker는 애플리케이션을 실행 환경째 이미지로 패키징하고, 그 이미지를 격리된 프로세스 묶음인 컨테이너로 실행하게 해주는 도구다.
처음에는 Docker를 “가벼운 VM”으로 외우기 쉽지만, 정확히는 VM이 아니다. Docker 컨테이너는 호스트의 Linux 커널을 공유하고, Namespace, cgroups, OverlayFS 같은 커널 기능을 조합해 “마치 독립된 서버처럼 보이는 실행 공간”을 만든다.
이 문서에서 잡아야 할 중심축은 하나다.
Docker는 새 운영체제를 띄우는 기술이 아니라, 이미 있는 Linux 격리 기능과 파일시스템 레이어를 개발자가 반복 가능한 배포 단위로 쓰게 만든 기술이다.
“내 컴퓨터에서는 되는데 서버에서는 안 돼요”는 단순한 농담이 아니다. 애플리케이션은 코드만으로 실행되지 않는다. Node.js 버전, OS 패키지, 환경변수, 시작 명령, 포트, 파일 경로, 권한, 네트워크 위치가 함께 맞아야 한다.
Docker 이전의 전형적인 배포는 다음과 같았다.
이 방식은 서버가 1대일 때는 버틸 수 있지만, 서버와 서비스 수가 늘면 세 가지 문제가 생긴다.
| 문제 | Docker 이전에 깨지던 지점 | Docker가 제공한 단위 |
|---|---|---|
| 환경 재현성 | 서버마다 설치된 패키지와 런타임 버전이 다름 | Dockerfile과 이미지 |
| 격리와 자원 제한 | 한 프로세스가 포트, 파일, 메모리, CPU를 과하게 사용해 다른 앱에 영향 | Namespace와 cgroups 기반 컨테이너 |
| 배포 자동화 | 사람이 SSH로 들어가 명령을 치면 현재 상태를 추적하기 어려움 | 레지스트리에 저장된 이미지와 실행 스펙 |
BackOps나 플랫폼 엔지니어링 관점에서 Docker는 로컬 개발 도구보다 더 큰 의미를 가진다. CI/CD는 이미지를 빌드하고, ECR 같은 레지스트리에 푸시하고, ECS나 Kubernetes는 그 이미지를 원하는 개수만큼 실행한다. Docker를 이해하면 “빌드 산출물이 어떻게 배포 단위가 되는가”를 읽을 수 있다.
Docker 이전에도 격리와 배포를 해결하려는 도구는 있었다.
chroot는 프로세스가 보는 루트 디렉터리를 바꾼다. 파일 경로 격리에는 도움이 되지만, Linux man-pages가 설명하듯 보안 샌드박스로는 충분하지 않다. 열린 파일 디스크립터, 작업 디렉터리, 권한 문제로 바깥 세계와 완전히 끊어지지 않는다.Docker의 등장은 새로운 커널을 만든 사건이 아니다. 이미 Linux에 있던 격리 원시 기능을 개발자가 사용할 수 있는 이미지, 컨테이너, Dockerfile, 레지스트리라는 작업 단위로 묶은 사건이다.
flowchart LR App["앱 코드와 런타임"] --> Image["Image: 재현 가능한 실행 템플릿"] Image --> Container["Container: 격리된 실행 인스턴스"] Kernel["Linux Kernel"] --> NS["Namespace: 보이는 세계 분리"] Kernel --> CG["cgroups: 쓸 수 있는 자원 제한"] Kernel --> OFS["OverlayFS: 이미지 레이어 공유"] NS --> Container CG --> Container OFS --> Image Registry["Registry: 이미지 공유 저장소"] --> Image
출처: Docker Docs - What is a container?, Linux man-pages - chroot(2), Linux man-pages - namespaces(7), Linux man-pages - cgroups(7)
Docker 문서를 읽다 막히는 이유는 대개 “이미지”와 “컨테이너”를 섞어서 생각하기 때문이다.
컨테이너를 만들기 위한 읽기 전용 템플릿이다. 앱 코드, 런타임, OS 패키지, 기본 실행 명령이 들어 있다.
빌드하고, 태그를 붙이고, 레지스트리에 푸시/풀하는 대상이다.이미지를 실행한 인스턴스다. 프로세스, 네트워크, 파일시스템 뷰, 자원 제한을 가진다.
시작, 중지, 로그 확인, 헬스체크, OOM 같은 런타임 상태를 볼 때 대상이 된다.이미지를 만드는 레시피다. `FROM`, `COPY`, `RUN`, `CMD` 같은 명령이 레이어와 실행 방식을 결정한다.
빌드가 느리거나 이미지가 크거나 배포 중 종료가 이상할 때 먼저 읽는다.이미지를 저장하고 공유하는 저장소다. Docker Hub, ECR, 사내 레지스트리가 여기에 해당한다.
CI가 만든 이미지를 배포 시스템이 가져가게 하려면 레지스트리가 필요하다.작은 예로 보면 관계가 더 분명하다.
FROM node:20-alpineWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .EXPOSE 3000CMD ["node", "server.js"]docker build는 이 Dockerfile을 읽어 이미지를 만든다.docker run은 그 이미지를 컨테이너로 실행한다.CMD ["node", "server.js"]는 컨테이너의 메인 프로세스가 된다.EXPOSE 3000은 “이 앱은 3000번 포트를 쓴다”는 문서화에 가깝고, 호스트에서 접근하려면 -p 3000:3000 같은 포트 바인딩이 별도로 필요하다.여기서 중요한 반례가 있다. 이미 실행 중인 컨테이너는 이미지를 복사해서 독립된 상태로 실행 중인 것이 아니다. 같은 이미지에서 만든 컨테이너라도 각 컨테이너는 자기 쓰기 레이어, 네트워크 주소, 프로세스 상태를 따로 가진다. 이미지를 다시 빌드해도 실행 중인 컨테이너가 자동으로 새 이미지로 바뀌지 않는다.
VM은 하이퍼바이저 위에 각자 완전한 OS와 커널을 띄운다. 컨테이너는 호스트 OS의 Linux 커널을 공유하고, 프로세스 주변의 “보이는 세계”와 “쓸 수 있는 자원”만 분리한다.
| 비교 축 | VM | Docker 컨테이너 |
|---|---|---|
| 커널 | VM마다 별도 커널 | 호스트 커널 공유 |
| 시작 모델 | OS 부팅 | 격리된 프로세스 시작 |
| 격리 강도 | 커널까지 분리 | 커널은 공유하고 Namespace/cgroups로 분리 |
| 적합한 경우 | 커널 버전, 드라이버, 강한 격리 단위가 필요 | 같은 커널 위에서 앱 단위 배포와 자원 효율이 중요 |
컨테이너가 가볍다는 말은 “보안이 약하다”와 같은 말이 아니다. 다만 격리의 경계가 다르다. 커널 취약점, 커널 모듈, 디바이스 드라이버, 실시간 커널 패치처럼 커널 자체가 요구사항이면 Docker보다 VM이나 베어메탈이 맞다.
처음 읽을 때는 아래 정도만 기억하면 된다.
컨테이너가 호스트 커널을 공유하는지 확인하려면 같은 Linux 호스트에서 아래 값을 비교할 수 있다. Docker Desktop은 내부에 Linux VM이 있으므로, macOS 호스트 커널이 아니라 Docker Desktop VM의 Linux 커널을 보게 된다.
uname -rdocker run --rm alpine uname -r두 값이 같으면 같은 Linux 커널을 공유한다. 값이 다르면 Docker Desktop VM, 원격 Docker host, 또는 다른 실행 환경을 보고 있는지 먼저 확인한다.
Docker 이미지가 빠르게 공유되고 컨테이너가 빨리 시작되는 이유는 파일을 매번 통째로 복사하지 않기 때문이다. Docker의 기본 저장 드라이버인 overlay2는 Linux OverlayFS를 사용해 여러 레이어를 하나의 파일시스템처럼 보여준다.
OverlayFS는 네 가지 디렉터리 역할로 이해할 수 있다.
읽기 전용 이미지 레이어다. 베이스 이미지와 Dockerfile 명령으로 만들어진 공유 기반이다.
여러 컨테이너가 같은 이미지 레이어를 재사용해 디스크를 절약한다.컨테이너별 쓰기 가능한 레이어다. 파일 생성, 수정, 삭제 표시가 여기에 남는다.
컨테이너를 삭제하면 이 레이어의 변경분도 함께 사라진다.OverlayFS 내부 작업 공간이다. 파일 이동 같은 원자적 작업에 쓰인다.
운영자가 직접 만지는 디렉터리가 아니라 구현 세부사항으로 보면 된다.lowerdir와 upperdir를 합쳐 컨테이너 안에서 보이는 최종 파일시스템 뷰다.
컨테이너 내부에서 `ls`로 보는 경로가 이 합쳐진 결과다.Copy-on-Write(CoW)는 “쓰기 전까지 공유하고, 처음 쓰는 순간에만 복사한다”는 설계다. 컨테이너가 이미지 안의 파일을 읽을 때는 lowerdir의 원본을 그대로 본다. 파일을 처음 수정하면 그 파일이 upperdir로 copy-up되고, 이후 수정은 upperdir의 복사본에만 적용된다.
flowchart TD Read["파일 읽기"] --> Lower["lowerdir 원본 제공"] Lower --> Shared["여러 컨테이너가 복사 없이 공유"] Write["최초 쓰기"] --> CopyUp["lowerdir 파일을 upperdir로 copy-up"] CopyUp --> Modify["upperdir 복사본 수정"] Modify --> Keep["lowerdir 원본 유지"] Delete["파일 삭제"] --> Whiteout["upperdir에 whiteout 기록"] Whiteout --> Hide["merged 뷰에서 원본 숨김"]
이 메커니즘이 주는 실용적 이점은 세 가지다.
node:20-alpine을 10개 서비스가 써도 공통 lowerdir는 공유된다.하지만 CoW는 공짜가 아니다. 큰 파일을 컨테이너 안에서 처음 수정하면 copy-up 비용이 발생한다. 컨테이너 내부 쓰기 레이어에 로그, 업로드 파일, DB 데이터가 계속 쌓이면 컨테이너 삭제와 함께 사라질 뿐 아니라 upperdir 용량이 예측하기 어렵게 커진다. 유지해야 하는 데이터는 컨테이너 쓰기 레이어가 아니라 volume이나 외부 스토리지에 둔다.
CoW는 Docker 전용 개념이 아니다. “원본은 공유하고 변경분만 분리한다”는 설계는 Git, DB MVCC, OS fork()에서도 반복된다.
이미지 레이어를 공유하고, 파일을 처음 쓸 때 컨테이너 upperdir로 copy-up한다.
컨테이너 쓰기 레이어가 커지는 이유와 이미지 공유 효과를 같이 볼 때.기존 커밋 객체를 공유하고, 변경된 blob/tree만 새 커밋에 연결한다.
브랜치 생성이 가볍고 커밋이 스냅샷처럼 보이는 이유를 이해할 때.기존 행 버전을 보존하고 새 행 버전을 만들어 읽기와 쓰기를 분리한다.
읽기가 쓰기를 바로 막지 않는 이유를 파일 레이어 직관으로 옮겨갈 때.부모 메모리 페이지를 공유하다가 쓰기 시점에 자식 페이지를 분리한다.
fork가 처음에는 빠르지만 쓰기 부하가 생기면 비용이 드는 이유를 볼 때.낯선 스냅샷 기술을 만났을 때는 세 가지 질문을 던지면 된다.
ZFS 스냅샷도 이 질문에 모두 “예”라고 답할 수 있다. 원본 데이터셋과 클론이 분리되고, 스냅샷 생성은 블록 전체 복사가 아니라 참조 공유에 가깝고, 클론에서 블록을 수정할 때 새 블록이 할당된다. 그러면 Docker에서 배운 “공유는 싸지만 쓰기 폭증은 비용이 된다”는 직관을 ZFS 클론에도 옮겨 적용할 수 있다.
첫 회독에서는 lowerdir, upperdir, merged의 역할만 잡으면 충분하다. 실제 경로는 Docker가 관리하므로 직접 수정하지 않는다.
docker inspect my-app --format='{{json .GraphDriver.Data}}'docker history my-app --no-trunc --format '{{.ID}}' | wc -l관찰 포인트:
LowerDir가 길수록 여러 이미지 레이어를 합쳐 보고 있다는 뜻이다.UpperDir는 컨테이너별 변경분이다. 컨테이너를 삭제하면 이 변경분도 사라진다.overlay2는 최대 128개 lower layer를 지원한다. 레이어가 과하게 많으면 인접한 RUN 명령을 묶고, 빌드 산출물 정리를 같은 레이어에서 처리한다.출처: OverlayFS storage driver - Docker Docs, Overlay Filesystem - Linux Kernel Docs
Dockerfile은 이미지를 만드는 레시피이면서 빌드 캐시 전략이다. 각 명령은 레이어를 만들고, 이전 레이어 입력이 바뀌면 그 뒤 레이어 캐시도 무효화된다.
# 좋은 순서: 변경이 적은 의존성 파일을 먼저 복사한다FROM node:20-alpineWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .CMD ["node", "server.js"]# 나쁜 순서: 소스 코드 변경만으로 npm ci 캐시가 매번 깨진다FROM node:20-alpineWORKDIR /appCOPY . .RUN npm ciCMD ["node", "server.js"]판단 기준은 단순하다.
자주 안 바뀌는 것을 앞에 두면 코드만 바뀐 빌드에서 의존성 설치를 재사용할 수 있다. CI에서 npm ci가 2~5분 걸리는 프로젝트라면 레이어 순서 하나가 배포 대기 시간을 바로 줄인다.
.dockerignore는 성능과 보안 장치다Docker 빌드는 현재 디렉터리의 빌드 컨텍스트를 Docker 데몬에 보낸다. .dockerignore가 없으면 .git, node_modules, .env, 로그 파일이 빌드 컨텍스트에 들어갈 수 있다. 이는 빌드가 느려지는 문제이자 시크릿 유출 위험이다.
node_modules.env.env.local.gitdist*.log.dockerignore는 “이미지 안에 복사하지 않는다” 이상의 의미가 있다. 빌드 컨텍스트 자체에서 제외되므로, 실수로 COPY . .를 하더라도 제외된 파일은 이미지 레이어에 들어가지 않는다.
멀티스테이지 빌드는 빌드 환경과 실행 환경을 분리한다. TypeScript 컴파일러, 테스트 도구, 개발 의존성은 빌드 단계에만 필요하다. 프로덕션 이미지에는 컴파일 결과와 프로덕션 의존성만 있으면 된다.
# Stage 1: 빌드FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
# Stage 2: 실행FROM node:20-alpine AS productionRUN addgroup -S appgroup && adduser -S appuser -G appgroupWORKDIR /appCOPY package*.json ./RUN npm ci --only=productionCOPY --from=builder /app/dist ./distUSER appuserEXPOSE 3000CMD ["node", "dist/main.js"]COPY --from=builder는 빌드 단계의 파일시스템에서 필요한 산출물만 가져온다. Stage 1에 있던 TypeScript 컴파일러와 개발 도구는 Stage 2에 포함되지 않는다.
숫자로 감을 잡으면 이렇다.
| 이미지 형태 | 포함되는 것 | 흔한 크기 감각 | 의심할 지점 |
|---|---|---|---|
| 단일 스테이지 | 소스, devDependencies, 빌드 도구, 실행 산출물 | Node 앱에서 수백 MB~1GB 이상 | 취약점 스캔 결과가 빌드 도구에서 많이 나옴 |
| 멀티스테이지 | dist, production dependencies, 런타임 | 100~300MB대까지 감소 가능 | prod 의존성과 dist 외 파일이 남았는지 확인 |
| Distroless 실행부 | 쉘과 패키지 매니저 없는 최소 런타임 | Alpine보다 더 작을 수 있음 | 디버깅 쉘이 없어 운영 진입 방식이 달라짐 |
예를 들어 NestJS 단일 스테이지 이미지가 node_modules, TypeScript 컴파일러, 테스트 도구까지 포함해 987MB라면, 멀티스테이지로 dist와 production dependencies만 남겨 178MB 수준으로 줄어드는 식이다. 정확한 숫자는 프로젝트마다 다르지만, “1GB 안팎에서 100~300MB대로 줄어드는가”는 좋은 감각 기준이다. 팀 기준으로 프로덕션 이미지가 500MB를 넘거나, 취약점 스캔 결과가 대부분 devDependencies와 빌드 도구에서 나온다면 베이스 이미지를 바꾸기 전에 멀티스테이지 분리를 먼저 의심한다.
BuildKit은 현대 Docker 빌더다. 기존 빌더보다 캐시와 병렬 실행이 개선됐고, 빌드 중에만 필요한 시크릿을 이미지 레이어에 남기지 않는 --mount=type=secret 같은 기능을 제공한다.
처음에는 이름과 용도만 잡으면 된다.
ARG/ENV로 넣지 말고 BuildKit secret을 검토한다.docker history, 레지스트리에 남지 않아야 한다.# syntax=docker/dockerfile:1FROM node:20-alpine AS builderRUN --mount=type=secret,id=npm_token \ NPM_TOKEN=$(cat /run/secrets/npm_token) \ npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN \ && npm ci컨테이너를 실행하면 네 가지가 동시에 생긴다.
EXPOSE 3000은 이미지 작성자가 “이 앱은 3000번 포트를 사용한다”고 남기는 메타데이터다. 호스트에서 접근하려면 실행 시 포트 바인딩이 필요하다.
docker run -p 3000:3000 my-app왼쪽 3000은 호스트 포트, 오른쪽 3000은 컨테이너 포트다. -p 8080:3000이면 호스트의 8080번으로 들어온 요청을 컨테이너의 3000번으로 보낸다.
반례: Dockerfile에 EXPOSE 3000이 있어도 -p 없이 실행하면 호스트 브라우저에서 localhost:3000으로 접근할 수 없다.
처음에는 “컨테이너마다 자기 Network Namespace가 있고, Docker가 가상 네트워크로 연결한다”까지만 기억해도 된다. 내부에서는 veth pair와 bridge가 등장한다.
eth0, 다른 한쪽은 호스트의 bridge에 붙는다.Compose에서는 보통 기본 bridge가 아니라 프로젝트 전용 사용자 정의 bridge 네트워크가 만들어진다. 이 네트워크에서는 Docker 내장 DNS가 서비스명을 컨테이너 IP로 해석한다. 그래서 아래처럼 db라는 호스트명이 동작한다.
services: app: build: . ports: - "3000:3000" environment: DATABASE_URL: postgresql://user:pass@db:5432/mydb depends_on: db: condition: service_healthy mem_limit: 512m
db: image: postgres:15 environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: mydb volumes: - db-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U user -d mydb"] interval: 10s timeout: 5s retries: 5
volumes: db-data:여기서 db는 IP가 아니라 서비스명이다. 컨테이너를 재생성하면 IP는 바뀔 수 있으므로, 애플리케이션 설정에 172.17.x.x 같은 컨테이너 IP를 박아두면 조용히 깨진다. Compose에서는 서비스명을 사용한다.
컨테이너 내부 파일시스템에 쓴 데이터는 컨테이너 삭제와 함께 사라진다. DB 데이터, 업로드 파일, 장기 보존 로그처럼 유지해야 하는 데이터는 volume이나 외부 스토리지에 둔다.
docker run -v /host/data:/app/data my-app학습 관점의 핵심은 명령이 아니라 경계다.
docker ps에서 컨테이너가 Up이라고 해서 앱이 정상이라는 뜻은 아니다. 컨테이너의 메인 프로세스가 살아 있다는 뜻일 뿐이다. 웹 앱이 DB 연결에 실패했거나 /health가 500을 반환해도 프로세스가 살아 있으면 docker ps는 Up으로 보일 수 있다.
HEALTHCHECK는 “프로세스가 살아 있는가”가 아니라 “앱이 응답 가능한가”를 주기적으로 확인하는 신호다. ECS나 Kubernetes 같은 오케스트레이터는 이 신호를 보고 재시작이나 트래픽 제외를 결정할 수 있다.
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ CMD wget --spider -q http://localhost:3000/health || exit 1NestJS 같은 앱에는 실제 /health 엔드포인트가 있어야 한다.
import { Controller, Get } from "@nestjs/common";
@Controller("health")export class HealthController { @Get() check() { return { status: "ok", timestamp: new Date().toISOString() }; }}반례: Alpine 기반 이미지에 curl이 없는데 HEALTHCHECK CMD curl -f ...를 쓰면 앱 문제가 아니라 curl: not found로 unhealthy가 된다. 헬스체크 도구가 이미지에 실제로 있는지 확인한다.
컨테이너 종료는 배포 안정성과 직접 연결된다. ECS가 컨테이너를 교체할 때는 먼저 SIGTERM을 보내고, stopTimeout 안에 종료되지 않으면 SIGKILL로 강제 종료한다. ECS 기본값은 30초이고 Fargate는 2~120초 범위에서 조정할 수 있다. 로컬 docker stop은 Docker CLI 기준 Linux 컨테이너 기본 timeout이 10초다.
앱은 SIGTERM을 받으면 새 요청을 받지 않고, 처리 중인 요청과 DB 트랜잭션을 정리한 뒤 종료해야 한다. 이를 Graceful Shutdown이라고 한다.
import { NestFactory } from "@nestjs/core";import { AppModule } from "./app.module";
async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableShutdownHooks(); await app.listen(3000);}
bootstrap();Dockerfile의 CMD 형식도 중요하다.
# 위험: shell 형식. PID 1이 sh가 되어 SIGTERM 전달이 꼬일 수 있다.CMD node dist/main.js
# 권장: exec 형식. Node.js가 직접 SIGTERM을 받는다.CMD ["node", "dist/main.js"]silent failure는 여기서 자주 나온다. 로컬에서는 앱이 잘 떠 보이지만, 배포 중에는 shell이 PID 1이 되어 신호를 제대로 전달하지 않고 stop timeout을 꽉 채운 뒤 SIGKILL로 죽는다. 결제, 주문, 배치 작업처럼 중간에 끊기면 안 되는 요청에서 데이터 불일치가 드러난다.
Docker는 격리를 제공하지만, 컨테이너 하나만으로 완전한 보안 경계를 제공한다고 생각하면 안 된다. 실무에서는 “탈취되더라도 피해 범위를 줄이는 설계”가 중요하다.
첫 회독에서는 non-root 실행, 시크릿이 이미지에 남지 않게 하는 것, .dockerignore의 목적만 먼저 잡아도 된다.
Linux Capability, Distroless, BuildKit secret은 보안 표면을 더 줄이는 선택 심화로 보고,
이름과 실패 신호를 익힌 뒤 실제 운영 문서에서 다시 확인한다.
기본 Docker 컨테이너는 root로 실행될 수 있다. 컨테이너 탈출 취약점이나 과도한 volume 마운트가 있을 때 root 실행은 피해 범위를 키운다.
FROM node:20-alpineRUN addgroup -S appgroup && adduser -S appuser -G appgroupUSER appuserCMD ["node", "dist/main.js"]컨테이너 내부의 root는 호스트 root와 완전히 같다고 단정할 수는 없지만, 보안 설계에서는 root 권한을 필요 최소로 줄이는 것이 기본이다.
Linux Capability는 root 권한을 여러 조각으로 나눈 것이다. 대부분의 웹 앱은 네트워크 패킷 스니핑, 마운트, 커널 모듈 로드 같은 권한이 필요 없다. 필요 없는 capability는 제거한다.
services: app: cap_drop: - ALL cap_add: - NET_BIND_SERVICE security_opt: - no-new-privileges:trueNET_BIND_SERVICE는 1024 이하 포트 바인딩이 필요한 경우에만 추가한다. 앱이 3000번 포트에서 뜨고 앞단 로드밸런서가 80/443을 받는 구조라면 이 capability도 필요 없을 수 있다.
Distroless 이미지는 쉘과 패키지 매니저를 제거한 최소 실행 이미지다. 공격자가 컨테이너 안에서 sh를 실행하거나 apt로 도구를 설치하기 어렵다.
FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12 AS productionWORKDIR /appCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesEXPOSE 3000CMD ["dist/main.js"]경계도 있다. 쉘이 없으므로 docker exec -it <id> sh로 들어가 디버깅할 수 없다. 로컬 개발은 Alpine, 프로덕션은 Distroless처럼 환경별 목적을 분리하는 방식이 흔하다.
Docker는 웹 서비스, 배치 워커, API 서버, 로컬 개발환경, CI/CD 실행 단위에 잘 맞는다. 그러나 모든 실행 환경의 정답은 아니다.
| 상황 | 왜 Docker가 애매한가 | 더 맞는 선택지 |
|---|---|---|
| 커널 버전이나 드라이버가 워크로드 요구사항 | 컨테이너는 호스트 커널을 공유하므로 컨테이너 안에서 다른 커널을 쓸 수 없다 | VM, 베어메탈, 전용 커널 이미지 |
| 마이크로초 단위 실시간 응답 | veth, NAT, 스케줄링 계층이 패킷당 수 us 수준의 지연과 jitter를 만들 수 있다 | RT Linux 베어메탈, host network를 신중히 사용 |
| HPC/공유 슈퍼컴퓨터 | Docker 데몬 root 권한과 클러스터 보안 정책이 충돌할 수 있다 | Singularity/Apptainer, Charliecloud, Shifter |
| 극단적 저전력/초소형 엣지 | Docker 데몬과 이미지 관리 오버헤드가 수십~100MB급 메모리와 시작 시간 부담이 될 수 있다 | 경량 런타임, unikernel, 전용 임베디드 배포 방식 |
| 보안 경계가 커널까지 필요 | 컨테이너는 커널을 공유하므로 커널 격리 단위가 아니다 | VM, microVM, sandboxed runtime |
처음에는 이렇게 판단하면 된다. “앱 단위로 실행 환경을 재현하고 배포 자동화를 하고 싶은가?”라면 Docker가 유력하다. “커널, 디바이스, 실시간성, rootless HPC 정책이 핵심 요구사항인가?”라면 Docker만으로 충분한지 의심한다.
트러블슈팅은 명령어 암기가 아니라 증상을 개념으로 연결하는 연습이다. 아래 표는 “무엇을 먼저 의심할지”를 정리한 것이다.
| 실패 신호 | 의미 | 먼저 의심할 개념 |
|---|---|---|
port is already allocated | 호스트 포트를 이미 다른 프로세스나 컨테이너가 점유 | 포트 바인딩, 호스트 포트와 컨테이너 포트 구분 |
docker run 직후 컨테이너가 사라짐 | 메인 프로세스가 종료됐거나 CMD가 잘못됨 | 컨테이너 생명주기는 PID 1 메인 프로세스에 묶임 |
docker logs가 비어 있음 | 앱까지 도달하기 전에 entrypoint/CMD가 실패했을 수 있음 | 이미지 실행 명령, working directory, 권한 |
| 코드를 바꿨는데 이전 코드가 실행됨 | 새 이미지를 빌드하지 않았거나 기존 컨테이너를 재사용 | 이미지와 컨테이너의 차이, 태그, Compose --build |
OOMKilled: true | cgroups 메모리 제한을 넘어서 커널이 컨테이너를 죽임 | 메모리 제한, Node.js 힙, 누수, batch 크기 |
unhealthy지만 프로세스는 Up | 프로세스 생존과 앱 준비 상태가 다름 | HEALTHCHECK, /health, 이미지에 있는 검사 도구 |
Compose에서 db에 연결 실패 | DNS 해석 실패인지, 포트 도달 실패인지 분리 필요 | 사용자 정의 bridge, Compose DNS, DB healthcheck |
| 컨테이너 삭제 후 DB 데이터가 사라짐 | 데이터를 컨테이너 쓰기 레이어에 저장 | volume, 외부 스토리지, OverlayFS upperdir |
docker stop이 timeout을 꽉 채움 | SIGTERM이 앱에 전달되지 않았거나 graceful shutdown 부재 | PID 1, exec 형식 CMD, shutdown hook |
| 이미지가 너무 크고 취약점 스캔 결과가 많음 | 빌드 도구와 devDependencies가 최종 이미지에 남음 | 멀티스테이지 빌드, .dockerignore, 베이스 이미지 |
Node.js 앱을 512MB 컨테이너에서 실행한다고 하자. Node.js 기본 old space 한도는 환경과 버전에 따라 다르지만, 컨테이너 제한보다 크게 잡히면 GC가 늦게 돌거나 커널 OOM killer가 먼저 개입할 수 있다. 이때 힙을 400MB 정도로 제한하면 앱 힙, native memory, 스레드 스택, 런타임 오버헤드를 위한 여유를 남길 수 있다.
CMD ["node", "--max-old-space-size=400", "dist/main.js"]이 숫자는 정답이 아니라 시작점이다. 컨테이너 제한이 512MB라면 앱 힙을 512MB로 꽉 채우면 안 된다는 감각이 중요하다. 메모리 제한을 올릴지, 힙을 줄일지, 배치 크기를 줄일지, 누수를 찾을지는 관찰 지표를 보고 결정한다.
현재 업무와 연결하면 다음을 읽을 수 있어야 한다.
docker logs, docker inspect, healthcheck 상태로 장애 원인을 어느 계층까지 좁힐 수 있는가아래 명령은 외워야 할 런북이 아니라 개념을 확인하는 관찰 도구다. 본문 개념을 이해한 뒤 필요할 때만 실행한다.
docker psdocker ps -adocker logs <container_id>docker inspect --format='{{.State.OOMKilled}}' <container_id>docker inspect --format='{{json .State.Health}}' <container_id>관찰 포인트:
docker ps에 없고 docker ps -a에 있으면 이미 종료된 컨테이너다.State.OOMKilled가 true이면 앱 예외가 아니라 커널 OOM kill을 먼저 본다.docker network ls --format '{{.Name}}' | grep '_default$'docker run --rm --network my-project_default busybox nslookup dbdocker run --rm --network my-project_default busybox nc -vz db 5432관찰 포인트:
nslookup db가 실패하면 app과 db가 같은 Compose 네트워크에 있는지 본다.nc가 실패하면 DB 프로세스, 포트, healthcheck를 본다.time docker stop <container_id>docker exec <container_id> ps -o pid,comm관찰 포인트:
docker stop이 timeout까지 꽉 차면 SIGTERM 처리 문제를 의심한다.sh이면 Dockerfile의 shell 형식 CMD나 wrapper 스크립트를 본다.dumb-init 같은 init 프로세스가 신호를 전달해야 한다.docker image inspect my-app:latest --format='{{.Size}}'docker history my-app:latestdocker scout cves my-app:latest관찰 포인트:
.dockerignore, devDependencies 포함 여부를 본다.docker history에 시크릿처럼 보이는 값이 남으면 빌드 시크릿 전달 방식이 잘못된 것이다.| 개념 A | 개념 B | 차이점 |
|---|---|---|
| 이미지 | 컨테이너 | 이미지 = 읽기 전용 실행 템플릿, 컨테이너 = 실행 중인 인스턴스와 쓰기 레이어 |
| Docker | Docker Compose | Docker = 이미지/컨테이너 기본 조작, Compose = 여러 서비스와 네트워크/볼륨을 한 파일로 묶는 로컬 오케스트레이션 |
| 컨테이너 | VM | 컨테이너는 커널 공유, VM은 커널까지 분리 |
| EXPOSE | 포트 바인딩 | EXPOSE는 메타데이터, 실제 외부 접근은 -p host:container가 필요 |
| HEALTHCHECK | docker ps | HEALTHCHECK는 앱 응답 가능성, docker ps는 프로세스 생존 상태 |
| volume | upperdir | volume은 컨테이너 교체 후에도 보존할 데이터, upperdir는 컨테이너별 임시 변경분 |
| shell CMD | exec CMD | shell 형식은 PID 1이 shell이 될 수 있고, exec 형식은 앱이 직접 신호를 받는다 |
퀴즈
힌트: Copy-on-Write는 '쓰기 전까지 공유, 쓰는 순간 분리'가 핵심이다.
원본은 lowerdir에 그대로 남고, upperdir로 복사된 사본만 수정된다. 삭제도 원본을 실제로 지우는 것이 아니라 upperdir의 whiteout 기록으로 merged 뷰에서 숨기는 방식이다.
퀴즈
힌트: 프로세스 생존과 서비스 준비 상태는 같은 신호가 아니다.
컨테이너 메인 프로세스가 살아 있는 상태와 앱이 실제 요청에 응답 가능한 상태를 구분해야 한다. `/health` 엔드포인트, HEALTHCHECK 명령, DB 연결, 검사 도구 존재 여부를 각각 확인한다.
Docker 네트워크, Docker 볼륨, .dockerignore, Alpine vs Debian vs Distroless, Docker 보안, ECR 이미지 스캔, BuildKit, Docker Scout, containerd, runc, rootless Docker
FROM, COPY, RUN, CMD, USER, HEALTHCHECK가 각각 어떤 학습 개념과 연결되는지 주석으로 적어본다.COPY package*.json과 COPY . . 순서를 바꾸면 어떤 레이어 캐시가 깨질지 설명해본다..dockerignore로 줄일 수 있는 항목을 분류해본다.docker stop 종료 시간이 timeout에 가까운지 확인하고, Dockerfile의 CMD 형식을 읽어본다..dockerignore, 멀티스테이지 빌드는 빌드 속도와 이미지 크기를 결정한다.docker ps의 Up, HEALTHCHECK의 healthy, 앱의 graceful shutdown은 서로 다른 상태 신호다.