Skip to content
ioob.dev
Go back

Docker 입문 2편 — 이미지와 레이어

· 8분 읽기
Docker 시리즈 (2/13)
  1. Docker 입문 1편 — Docker란
  2. Docker 입문 2편 — 이미지와 레이어
  3. Docker 입문 3편 — Dockerfile 작성법
  4. Docker 입문 4편 — 컨테이너 생명주기
  5. Docker 입문 5편 — 볼륨과 데이터 영속성
  6. Docker 입문 6편 — 네트워크
  7. Docker 7편 — Docker Compose로 다중 컨테이너 오케스트레이션
  8. Docker 8편 — 멀티스테이지 빌드로 이미지 다이어트
  9. Docker 9편 — 레지스트리, 이미지는 어디에 두는가
  10. Docker 10편 — 컨테이너 보안, 터지기 전에 막는 것들
  11. Docker 11편 — BuildKit과 고급 빌드
  12. Docker 12편 — 프로덕션 모범 사례
  13. Docker 13편 — 트러블슈팅과 대안
Table of contents

Table of contents

이미지는 덩어리가 아니라 쌓임이다

Docker 이미지를 처음 만져보면 한 덩어리의 파일처럼 느껴진다. docker pull nginx를 치면 뭔가를 받고, docker run을 치면 그게 실행된다. 간단해 보인다.

그런데 실제로 받는 과정을 자세히 보면 이상한 일이 일어난다.

docker pull node:22
# 22: Pulling from library/node
# 4f4fb700ef54: Pull complete
# 6b9d8d31e2b4: Pull complete
# a75df4d1b5b5: Pull complete
# ...

ID가 여러 개 등장한다. Pull complete도 하나씩 따로 찍힌다. 이게 레이어(Layer)다. 이미지는 한 덩어리가 아니라 여러 층으로 쌓여 있다. 그리고 이 구조가 Docker 생태계의 많은 편리함 — 빠른 pull, 캐시를 이용한 빠른 빌드, 공간 절약 — 을 만들어낸다.

레이어가 쌓이는 구조

이미지 하나는 읽기 전용 레이어들의 스택이다. 각 레이어는 이전 레이어로부터의 변경분(diff)이다.

flowchart TB
    L0["Layer 0: Debian 베이스<br/>(기본 파일시스템)"] --> L1
    L1["Layer 1: apt 패키지 설치"] --> L2
    L2["Layer 2: Node.js 설치"] --> L3
    L3["Layer 3: /app 디렉토리 복사"] --> L4
    L4["Layer 4: npm install 결과"] --> RW

    RW["컨테이너 실행 시 추가되는<br/>쓰기 가능한 레이어 (RW Layer)"]

이미지가 실제 컨테이너로 실행될 때, 위에 쓰기 가능한 레이어(Writable Layer, RW Layer) 하나가 더 얹힌다. 컨테이너 안에서 파일을 만들거나 수정하면 모두 이 RW 레이어에 쌓인다. 컨테이너를 삭제하면 이 RW 레이어도 같이 사라진다.

이미지의 모든 실제 내용(읽기 전용 레이어)은 컨테이너들 사이에서 공유된다. 같은 이미지로 100개 컨테이너를 띄워도, 디스크에는 읽기 전용 레이어가 딱 한 벌씩만 존재한다. 각 컨테이너는 자기만의 RW 레이어만 따로 갖는다. 그래서 컨테이너가 가볍다.

Union Filesystem — 쌓인 것을 하나처럼 보이게

그런데 레이어가 여러 장이어도 컨테이너 안에서는 하나의 파일시스템처럼 보인다. 이걸 가능하게 하는 게 Union Filesystem이다. 대표적인 구현이 Linux 4.x 이후 표준으로 자리잡은 OverlayFS다.

OverlayFS는 아래쪽에 여러 “lowerdir”를 쌓고, 맨 위에 하나의 “upperdir”를 얹는다. 컨테이너 프로세스가 파일을 읽으면 위에서부터 탐색해서 맨 처음 만난 버전을 보여준다. 파일을 수정하면 원본을 upperdir로 복사해서 거기에 수정본을 만든다(Copy-on-Write, CoW).

호스트에서 /var/lib/docker/overlay2/ 아래를 들여다보면 실제 레이어 디렉토리들이 보인다.

sudo ls /var/lib/docker/overlay2/ | head
# 3b4c8f2a1d.../
# 7a9f3b2e5c.../
# ...
# 각 디렉토리가 하나의 레이어

이 구조가 중요한 이유는 두 가지다.

  1. 저장 공간 절약: 같은 베이스 이미지를 쓰는 서로 다른 이미지들이 하위 레이어를 공유한다
  2. 네트워크 절약: 이미 받은 레이어는 다시 받지 않는다. pull이 엄청나게 빨라진다

같은 레이어는 두 번 받지 않는다

이 성질을 실제로 확인해보자. Python과 Python 기반 앱 이미지를 받아본다.

docker pull python:3.12-slim
# 3.12-slim: Pulling from library/python
# 4f4fb700ef54: Pull complete   <- debian:slim 기반 레이어
# 1e1a1a1b1c1d: Pull complete   <- python 런타임 레이어
# ...

docker pull my-registry/my-app:latest
# latest: Pulling from my-app
# 4f4fb700ef54: Already exists   <- 같은 debian:slim 레이어 재사용!
# 1e1a1a1b1c1d: Already exists
# 3c3d3e3f...: Pull complete    <- 앱 고유 레이어만 새로 받음

두 번째 pull에서 Already exists라는 문구가 나오는 게 핵심이다. 레이어 해시가 같으면 “이미 있으니까 안 받아도 된다”고 판단한다. 팀에서 수십 개 서비스가 같은 베이스 이미지를 쓴다면, 서버마다 디스크와 네트워크가 엄청나게 절약된다.

레이어 해시와 이미지 식별

각 레이어에는 내용을 기반으로 한 SHA256 해시가 붙는다. 내용이 같으면 해시가 같다. 바이트 하나만 달라도 해시가 달라진다.

이걸 본인 눈으로 확인할 수 있다.

docker image inspect nginx:1.27 --format '{{json .RootFS.Layers}}' | jq
# [
#   "sha256:3b43a6502abb5...",
#   "sha256:f4df7a8b2c...",
#   ...
# ]

이 해시 리스트가 이미지의 정체성이다. 태그(nginx:1.27)는 그저 이 해시 묶음을 가리키는 별명에 불과하다. 태그를 바꿔도 내용물이 같으면 디스크에는 그대로다. 반대로 nginx:latest처럼 같은 태그가 시간이 지나면서 다른 이미지 해시를 가리킬 수도 있다.

이래서 프로덕션에서 :latest 태그를 쓰지 말라는 말이 나온다. 같은 태그가 어제와 오늘 다른 내용을 가리킬 수 있기 때문이다. 버전을 고정하고 싶다면 구체적 태그(1.27)나 digest(@sha256:...)로 박아둔다.

# digest로 이미지 고정 — 절대 바뀌지 않음
docker pull nginx@sha256:3b43a6502abb5...

pull과 push의 흐름

이미지를 주고받을 때 정확히 무슨 일이 일어나는지 보자.

sequenceDiagram
    participant C as docker CLI
    participant D as Docker Daemon
    participant R as Registry

    Note over C,R: docker pull nginx:1.27
    C->>D: /images/create
    D->>R: GET /v2/library/nginx/manifests/1.27
    R-->>D: manifest (레이어 해시 리스트)
    D->>D: 로컬에 있는 레이어 확인
    par 병렬 다운로드
        D->>R: GET /v2/library/nginx/blobs/sha256:aaa...
        D->>R: GET /v2/library/nginx/blobs/sha256:bbb...
        D->>R: GET /v2/library/nginx/blobs/sha256:ccc...
    end
    R-->>D: 각 레이어 tar.gz
    D->>D: 로컬 스토리지에 해시별로 저장
    D-->>C: Pull complete

순서를 정리하면 이렇다.

  1. Daemon이 레지스트리에서 manifest를 받는다. manifest는 “이 이미지는 이런 레이어들로 구성돼 있다”는 메타데이터다
  2. 각 레이어 해시를 로컬 저장소에서 찾아본다. 이미 있으면 스킵
  3. 없는 레이어들만 병렬로 다운로드한다. 각 레이어는 독립적이라 순서가 안 중요하다
  4. 받아온 레이어를 해시를 키로 저장한다. 다음에 누가 같은 해시를 요청하면 바로 쓸 수 있도록

push는 반대 방향이지만 로직이 같다. 레지스트리가 “이 해시의 레이어 이미 있어?”라고 물으면, 있는 건 건너뛰고 없는 것만 업로드한다. 그래서 앱 한 줄만 바꿔서 배포해도 대부분의 레이어는 재사용되고, 변경된 상위 레이어만 전송된다.

이미지 용량을 줄이는 감각

처음 Docker를 쓰면 이미지가 GB 단위로 불어난다. node:22가 1GB, 앱 얹으면 2GB 찍히는 일이 흔하다. 이 숫자는 줄일 수 있다.

1. 베이스 이미지를 작은 걸 고른다

같은 Node.js라도 어떤 베이스를 쓰느냐에 따라 용량이 다르다.

이미지대략 크기
node:221.1GB
node:22-slim240MB
node:22-alpine180MB
gcr.io/distroless/nodejs22170MB

대부분의 경우 slim이 현실적인 기본값이다. 익숙해지면 alpine이나 distroless로 진화해가면 된다.

2. 레이어를 “자주 바뀌는 것”과 “잘 안 바뀌는 것”으로 나눈다

Dockerfile에서 위쪽 레이어는 잘 안 바뀌고, 아래쪽 레이어는 자주 바뀌도록 순서를 잡는다. 그러면 빌드할 때 상위 레이어가 캐시돼서 재사용된다.

나쁜 예.

FROM node:22-slim
COPY . /app           # 코드 바뀔 때마다 캐시 무효
WORKDIR /app
RUN npm install       # 매번 다시 실행됨
CMD ["node", "index.js"]

좋은 예.

FROM node:22-slim
WORKDIR /app
COPY package*.json ./ # 의존성 파일만 먼저
RUN npm ci            # 의존성 레이어는 package.json이 그대로면 캐시됨
COPY . .              # 코드 변경은 마지막 레이어에만 영향
CMD ["node", "index.js"]

package.json이 안 바뀌면 npm ci는 다시 실행되지 않는다. 코드 한 줄만 바뀌어도 1분 걸리던 빌드가 몇 초로 끝난다. Dockerfile 지시어 순서는 성능에 직접적으로 영향을 준다. 이건 3편에서 더 본격적으로 다룬다.

3. 한 레이어에 여러 명령을 묶되, 정리도 같이 한다

RUN은 새 레이어를 만든다. 그래서 아래와 같이 쓰면 “파일 생성” 레이어와 “파일 삭제” 레이어가 따로 생겨서, 최종 이미지에 이미 삭제된 파일도 포함된다.

RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# 두 번째 RUN이 파일을 지우지만, 첫 번째 레이어엔 여전히 존재한다

RUN으로 묶어서 같은 레이어 안에서 설치하고 정리해야 한다.

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

--no-install-recommends는 권장 패키지를 설치하지 않는 옵션이다. rm -rf /var/lib/apt/lists/*는 apt 캐시를 지운다. 이 정리까지 같은 RUN 안에서 이뤄져야 최종 이미지가 가벼워진다.

4. 멀티스테이지 빌드로 빌드 도구를 빼낸다

Go나 Java 같은 언어는 빌드 시점엔 컴파일러가 필요하지만, 실행 시점엔 결과물만 있으면 된다. 멀티스테이지 빌드는 이걸 깔끔하게 분리한다.

# 1단계: 빌드
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/server

# 2단계: 실행
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/app /app
ENTRYPOINT ["/app"]

FROM ... AS builder 로 빌드 전용 스테이지를 만들고, 최종 스테이지는 결과물만 복사한다. 최종 이미지엔 Go 컴파일러가 없다. 수백MB짜리 이미지가 수MB로 줄어드는 게 보통이다.

dangling 이미지와 정리

이미지를 새로 빌드하면 같은 태그를 물려받으면서 이전 이미지는 태그가 떨어진 상태(<none>:<none>)로 남는다. 이걸 dangling image라 부른다. 쌓이면 디스크를 꽤 먹는다.

docker images --filter "dangling=true"
# REPOSITORY   TAG       IMAGE ID       CREATED
# <none>       <none>    1a2b3c4d...    10 minutes ago

docker image prune              # dangling만 제거
docker image prune -a           # 사용 안 하는 모든 이미지 제거
docker system prune -a --volumes  # 이미지/컨테이너/네트워크/볼륨 싹 정리

docker system prune은 강력하니 신중히 쓰자. CI 환경에선 주기적으로 돌려주면 디스크 고갈을 막을 수 있다.

실전에서 기억해둘 감각 세 가지

이미지와 레이어를 한번에 꿰는 감각을 세 가지로 요약하면.

  1. 이미지는 stacked diff다. 각 레이어는 이전 상태로부터의 변경분이고, 해시로 식별되며, 공유된다
  2. 레이어 캐시는 Dockerfile 지시어 순서로 결정된다. 잘 안 바뀌는 것부터 위에, 자주 바뀌는 것을 아래에 둔다
  3. 최종 이미지의 크기는 베이스 선택, 레이어 묶기, 멀티스테이지로 결정된다. 이 세 가지로 대부분의 용량 문제를 해결할 수 있다

다음 편에서는 이 레이어가 실제로 만들어지는 곳, Dockerfile을 파고든다. FROM, RUN, COPY, CMD, ENTRYPOINT의 차이부터, ARGENV를 어떻게 쓰는지, .dockerignore로 빌드 컨텍스트를 어떻게 줄이는지까지.

3편: Dockerfile 작성법


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Docker 입문 1편 — Docker란
Next Post
Docker 입문 3편 — Dockerfile 작성법