[Container] 컨테이너 이미지
docker pull nginx를 실행하면 어딘가에서 무언가가 다운로드된다. docker run을 하면 그 무언가로부터 컨테이너가 만들어진다. 이 “무언가”가 바로 컨테이너 이미지다. 그런데 이미지는 정확히 어떤 형태로 존재하는 걸까? 같은 이미지를 다시 빌드하면 정말 같은 이미지가 되는 걸까? 이미지의 크기가 수십 MB인데, 같은 이미지로 컨테이너를 100개 실행하면 디스크를 수십 GB나 차지하는 걸까?
항상 사용하면서도 제대로 정리해 본 적이 없어서, 이미지의 개념과 OCI 표준 기반의 내부 구조, 이미지 ID·태그·다이제스트를 통한 식별 방식, 레이어 공유와 Copy-on-Write 메커니즘, 그리고 빌드—배포—실행 워크플로우까지 정리해 본다.
TL;DR
- 이미지는 읽기 전용 템플릿: 여러 레이어가 쌓여 파일시스템을 구성. OCI 표준에 따라 manifest + config JSON + layers로 이루어짐
- 빌드 = Dockerfile → 레이어 → OCI 이미지:
FROM으로 베이스 이미지를 지정하고, 각 명령어가 레이어를 생성. Docker 외에도 BuildKit, kaniko, buildah 등 다양한 도구 존재 - 레이어 공유: 같은 베이스 이미지를 사용하면 레이어를 공유하여 다운로드·저장 효율 확보
- 읽기 전용 + 쓰기 레이어 = CoW: 이미지 레이어는 불변, 컨테이너마다 얇은 쓰기 레이어를 얹어 격리하면서 공유
- 이미지 ID로 동일성 판단: config JSON의 SHA256 해시. 같은 Dockerfile을 재빌드해도 같은 ID가 보장되지는 않지만, 한 번 빌드된 이미지의 push/pull은 동일성 보장
- 태그는 가변, 다이제스트는 불변: 태그는 레지스트리에서 이미지를 가리키는 이름(변경 가능), 다이제스트는 manifest 해시로 항상 같은 이미지를 보장
- 이식성 한계: 컨테이너는 호스트 커널을 공유하므로 커널 버전과 하드웨어 아키텍처에 의존
컨테이너 이미지란
컨테이너 이미지는 컨테이너를 실행하기 위한 환경(파일시스템과 메타데이터)을 패키징한 읽기 전용 템플릿이다. 애플리케이션 코드와 런타임, 라이브러리, 설정 등 실행에 필요한 모든 것을 하나로 묶은 것으로, 여기에는 애플리케이션에서 사용할 수 있는 파일시스템과 이미지가 실행될 때 실행돼야 하는 실행파일 경로와 같은 메타데이터가 포함돼 있다. 여러 컨테이너들이 공유할 수 있으며, 읽기 전용이기 때문에 컨테이너들이 항상 같은 환경에서 시작함을 보장한다.
이미지의 파일시스템은 하나의 덩어리가 아니라, 여러 개의 레이어가 순서대로 쌓여 구성된다. 각 레이어는 이전 레이어 대비 파일시스템의 변경 사항(파일 추가, 수정, 삭제)을 담고 있으며, 이 레이어들이 합쳐져 최종 컨테이너 파일시스템이 된다.
Layer 3 (config) ── 설정 파일 추가
Layer 2 (app) ── 애플리케이션 코드 추가
Layer 1 (base) ── OS 기본 파일 (ubuntu, alpine 등)
참고: 이미지에 대한 다양한 정의
- OCI 공식 문서: This specification defines an OCI Image, consisting of an image manifest, an image index (optional), a set of filesystem layers, and a configuration.
- Kubernetes 공식 문서: A container image represents binary data that encapsulates an application and all its software dependencies.
- Docker 공식 문서: An image is a read-only template with instructions for creating a Docker container.
OCI 표준(OCI Image Spec)에 따르면, 이미지는 세 가지 요소의 조합으로 구성된다.
Image: OCI Image Spec 준수
├── manifest (목차)
├── config.json (이미지 ID = sha256 해시)
└── layers (tar.gz)
├── layer1.tar.gz (sha256:aaa...)
├── layer2.tar.gz (sha256:bbb...)
└── layer3.tar.gz (sha256:ccc...)
- Manifest: 이미지 목차. 어떤 config와 layers로 구성되는가
- Config JSON: 이미지 실행 설정. 레이어 구성 정보와 환경변수, 실행 명령어, 진입점 등의 메타데이터를 담고 있다
- Layers: 파일시스템 변경 사항. 각 레이어가
tar.gz로 압축되어 저장된다
참고: OCI 표준이 정의하는 것
- 이미지 포맷 (Image Spec): manifest 구조, config JSON 구조, layer 저장 방식 등
- 런타임 스펙 (Runtime Spec): 컨테이너 실행 방법, 파일시스템 마운트, cgroups/namespaces 설정 등
이미지 빌드
이미지를 빌드한다는 것은, Dockerfile 등의 빌드 명세로부터 OCI Image Spec을 준수하는 이미지(manifest + config JSON + layers)를 생성하는 과정이다. 빌드 도구가 명세의 각 명령어를 순서대로 실행하면서 레이어를 만들고, 최종적으로 이를 하나의 이미지로 패키징한다.
가장 일반적인 빌드 명세는 Dockerfile이다. 모든 이미지는 다른 이미지 위에 빌드되는데, FROM 명령어가 이 베이스 이미지(base image)를 지정한다. 그 위에 RUN, COPY 등의 명령어가 실행되면서, 각 명령어가 하나의 레이어를 생성한다.
FROM ubuntu:22.04 # Layer 1: base image (ubuntu:22.04의 레이어들)
RUN apt-get update && \
apt-get install -y python3 # Layer 2: 패키지 설치
COPY app.py /app/ # Layer 3: 소스 코드 복사
CMD ["python3", "/app/app.py"] # 메타데이터만 (레이어 생성 X)
이미지 A
├── Layer 1: ubuntu:22.04 base (sha256:aaa...)
├── Layer 2: python3 설치 (sha256:bbb...)
└── Layer 3: app.py 복사 (sha256:ccc...)
빌드 시 각 레이어의 내용이 SHA256 해시로 식별되며, 변경되지 않은 레이어는 캐시에서 재사용된다. 예를 들어 app.py만 수정하면 Layer 1, 2는 캐시에서 가져오고 Layer 3만 새로 생성한다. 이 때문에 Dockerfile에서 자주 변경되는 명령어를 아래쪽에 배치하는 것이 빌드 효율에 유리하다.
참고:
CMD,ENTRYPOINT,ENV,EXPOSE등의 명령어는 이미지 config JSON에 메타데이터로만 기록되며, 별도의 파일시스템 레이어를 생성하지 않는다.
Docker뿐만 아니라 대부분의 빌드 도구가 Dockerfile 형식을 지원하므로, 사실상 표준 빌드 명세로 자리잡았다. 빌드 도구는 다양하며, 각각의 특성과 사용 환경이 다르다.
| 빌드 도구 | 특징 |
|---|---|
| Docker (docker build) | 가장 널리 사용되는 풀스택 도구. 내부적으로 BuildKit 사용 |
| BuildKit | Docker의 빌드 엔진. 병렬 빌드, 캐시 최적화 등 고성능 기능 제공 |
| nerdctl | containerd용 Docker 호환 CLI. nerdctl build로 Dockerfile 빌드 |
| kaniko | 데몬리스 빌드. 컨테이너 안에서 이미지를 빌드할 수 있어 CI/CD 환경에 적합 |
| buildah | Podman 생태계. Dockerfile 없이 스크립트로도 빌드 가능 |
어떤 도구를 사용하든, 최종 산출물은 OCI Image Spec을 준수하는 이미지다. 다만 도구마다 레이어 생성 방식, 캐시 전략, 메타데이터 처리 등에서 차이가 있어, 같은 Dockerfile로 빌드해도 동일한 이미지가 보장되지 않을 수 있다.
이미지 레이어
레이어 공유
컨테이너 이미지가 가상머신 이미지와 구별되는 가장 큰 특징은, 레이어가 여러 이미지에서 공유되고 재사용될 수 있다는 점이다. 가상머신 이미지는 전체 디스크를 하나의 파일로 저장하므로 공유가 불가능하지만, 컨테이너 이미지는 레이어 단위로 관리되기 때문에 동일한 레이어를 포함하는 이미지 간에 공유가 가능하다.
다운로드 효율
동일한 레이어를 포함하는 다른 이미지를 가져올 때, 이미 다운로드된 레이어는 다시 받을 필요가 없다. 해당 이미지에 고유한 레이어만 추가로 다운로드하면 된다.
이미지 A (이미 pull 완료)
├── Layer 1: ubuntu:22.04 (sha256:aaa...) ← 이미 있음
├── Layer 2: python3 설치 (sha256:bbb...) ← 이미 있음
└── Layer 3: app-A 코드 (sha256:ccc...)
이미지 B (새로 pull)
├── Layer 1: ubuntu:22.04 (sha256:aaa...) ← 이미 있으므로 스킵
├── Layer 2: python3 설치 (sha256:bbb...) ← 이미 있으므로 스킵
└── Layer 3: app-B 코드 (sha256:ddd...) ← 이것만 다운로드
저장 효율
각 레이어는 동일 호스트에 한 번만 저장된다. 같은 베이스 레이어를 기반으로 한 이미지가 여러 개 있어도, 공통 레이어는 디스크에 한 벌만 존재한다.
디스크 저장 (실제)
├── sha256:aaa... (ubuntu:22.04) ← 1회만 저장, 이미지 A·B 공유
├── sha256:bbb... (python3) ← 1회만 저장, 이미지 A·B 공유
├── sha256:ccc... (app-A) ← 이미지 A 전용
└── sha256:ddd... (app-B) ← 이미지 B 전용
실제 레이어 공유 사례
레이어 공유가 실제로 발생하려면, 레이어의 SHA256 해시가 완전히 동일해야 한다. 가장 흔한 사례는 같은 베이스 이미지를 사용하는 경우다. 예를 들어, FROM python:3.11-slim으로 시작하는 이미지 A와 이미지 B는 python:3.11-slim의 레이어들을 그대로 공유한다.
이미지 A: FROM python:3.11-slim
├── Layer 1~4: python:3.11-slim의 레이어들 (공유)
└── Layer 5: 이미지 A 고유 레이어
이미지 B: FROM python:3.11-slim
├── Layer 1~4: python:3.11-slim의 레이어들 (공유)
└── Layer 5: 이미지 B 고유 레이어
반면, 전혀 관련 없어 보이는 이미지(예: nginx와 redis) 사이에서도 동일한 베이스 이미지 버전(예: debian:bookworm-slim)을 사용한다면 하위 레이어가 공유될 수 있다. 핵심은 이미지 간의 “관련성”이 아니라, 동일한 베이스 이미지 버전에서 출발했는가이다.
다만, 베이스 이미지의 버전이 조금이라도 다르면 해시가 달라지므로 공유되지 않는다. ubuntu:22.04의 2024년 1월 빌드와 2024년 6월 빌드는 같은 태그지만 내부 패키지 버전이 다를 수 있어 레이어 해시가 다르다. 이것이 태그와 다이제스트의 차이와도 연결되는 부분이다.
읽기 전용 레이어와 쓰기 레이어
이미지 레이어는 읽기 전용이다. 컨테이너가 실행될 때, 이미지 레이어 위에 새로운 쓰기 가능한 레이어(writable layer)가 추가된다. 컨테이너의 프로세스가 이미지 레이어에 있는 파일을 수정하면, 해당 파일의 복사본이 쓰기 레이어에 만들어지고, 프로세스는 그 복사본에 쓴다. 이를 Copy-on-Write(CoW)라 한다.
[컨테이너]
┌──────────────────────────────┐
│ Writable Layer │ ← 컨테이너 전용, 변경 사항 기록
├──────────────────────────────┤
│ Image Layer N (Read-Only) │
│ ... │
│ Image Layer 2 (Read-Only) │ ← 공유 가능, 불변
│ Image Layer 1 (Read-Only) │
└──────────────────────────────┘
읽기 전용 레이어는 여러 컨테이너가 공유한다. 같은 이미지로 컨테이너를 100개 실행하더라도, 이미지 레이어는 한 벌만 존재하고, 각 컨테이너는 자신만의 얇은 쓰기 레이어만 추가하면 된다. 파일을 공유하지만 쓰기 레이어가 분리돼 있으므로, 한 컨테이너에서 파일을 수정해도 다른 컨테이너에는 영향을 주지 않는다.
Container A (writable layer) ──┐
├── Image layers (읽기 전용, 공유)
Container B (writable layer) ──┘
Copy-on-Write의 구체적인 동작(OverlayFS의 upper/lower 구조, whiteout 파일 등)과 이미지 레이어가 실제로 어떻게 파일시스템으로 합쳐지는지는 컨테이너 파일 시스템을 참고한다.
VM과 컨테이너 비교
가상머신에서는 두 애플리케이션이 모두 동일한 파일시스템(가상머신의 파일시스템)에서 실행되므로, 같은 파일에 접근할 수 있는 것이 자연스럽다. 반면 컨테이너는 각각 격리된 자체 파일시스템을 갖는다.
그런데도 컨테이너 A와 컨테이너 B가 같은 바이너리, 라이브러리에 접근할 수 있는 이유는 위에서 설명한 레이어 공유 구조 덕분이다. 이미지의 읽기 전용 레이어를 공유하되, 각 컨테이너는 자신만의 쓰기 레이어에서 변경 사항을 관리하므로, 파일을 공유하면서도 격리가 유지된다.
| 항목 | 가상머신 | 컨테이너 |
|---|---|---|
| 이미지 구조 | 전체 디스크 이미지 (하나의 파일) | 레이어 구조 (공유 가능) |
| 저장 효율 | VM마다 전체 복사 | 공통 레이어 1회만 저장 |
| 파일 공유 | 같은 VM 내 프로세스 간 공유 | 읽기 전용 레이어를 컨테이너 간 공유 |
| 격리 방식 | 별도 커널 + 하이퍼바이저 | 쓰기 레이어 분리 + namespace |
이미지 식별과 참조
이미지의 내용이 같은가를 판단하는 기준은 이미지 ID이고, 레지스트리에서 어떤 이미지를 가져올 것인가를 지정하는 방식은 태그와 다이제스트다.
이미지 동일성
이미지 ID
이미지를 식별하기 위한 해시값이다. 엄밀하게는, config JSON의 sha256 해시값을 의미한다.
이미지 ID = sha256(이미지 config JSON)
Manifest (목차)
↓
Config JSON → sha256(config) = 이미지 ID
↓
Layers (tar.gz들)
이미지 ID는 docker build 등 빌드 도구를 이용한 이미지 빌드 시점에 결정된다.
- 각 레이어 생성: 레이어별 sha256 해시 계산
- config JSON 생성: 위에서 생성한 레이어 해시 포함
- 이미지 ID 생성: config JSON의 sha256 해시
동일성 판단
이미지가 동일하다는 것은 이미지 ID가 동일하다는 것이다. 동일한 이미지 ID를 가진 이미지는 바이트 단위로 완전히 동일한 이미지임이 보장된다.
이것이 보장되는 이유는 SHA256 해시의 특성 때문이다:
- 결정론적 해시: 같은 입력 데이터는 항상 같은 해시값을 생성한다
- 충돌 저항성: 입력 데이터가 1비트라도 다르면 완전히 다른 해시값이 나온다. 해시 충돌(서로 다른 입력이 같은 해시를 생성) 확률이 극히 낮아 실질적으로 불가능하다
빌드 과정에서의 SHA256 해시 체인을 보자:
- 각 레이어(
tar.gz)의 내용이 SHA256 해시로 식별됨 - config JSON은 이 레이어 해시들을 포함하여 생성됨
- 이미지 ID는 config JSON의 SHA256 해시임
따라서 이미지 ID가 같다면 → config JSON이 같고 → config JSON이 같다면 포함된 레이어 해시들이 같고 → 레이어 해시가 같다면 레이어 내용이 바이트 단위로 동일함이 보장되는 것이다.
이미지 참조: 태그와 다이제스트
이미지 ID가 내용의 동일성을 판단하는 기준이라면, 태그와 다이제스트는 레지스트리에서 이미지를 가리키는 방식이다.
태그 (Tag)
사람이 읽기 쉬운 가변(mutable) 이름이다. :latest, :1.0, :stable 등이 태그에 해당한다.
docker pull nginx:1.25
docker pull python:3.11-slim
docker pull myapp:latest
태그는 다른 이미지를 가리킬 수 있다. 예를 들어 nginx:latest는 오늘과 한 달 뒤에 서로 다른 이미지를 가리킬 수 있다. 같은 태그로 새로운 이미지를 push하면 기존 태그가 새 이미지를 가리키게 되기 때문이다.
다이제스트 (Digest)
manifest의 SHA256 해시로, 불변(immutable) 식별자다. @sha256: 접두사로 표기한다.
docker pull nginx@sha256:abc123def456...
다이제스트는 content-addressable하다. 내용이 바뀌면 해시도 바뀌므로, 같은 다이제스트는 항상 같은 이미지를 가리킨다. 따라서 프로덕션 환경에서 이미지의 재현성을 보장하려면 태그 대신 다이제스트를 사용하는 것이 안전하다.
# 태그: 시간이 지나면 다른 이미지를 가리킬 수 있음
image: nginx:1.25
# 다이제스트: 항상 같은 이미지를 보장
image: nginx@sha256:abc123def456...
참고: 이미지 ID vs 다이제스트
- 이미지 ID: config JSON의 SHA256 해시. 로컬에서 이미지를 식별하는 데 사용
- 다이제스트: manifest의 SHA256 해시. 레지스트리에서 이미지를 식별하는 데 사용
- 같은 이미지라도 이미지 ID와 다이제스트의 값은 다르다 (해시 대상이 다르므로)
빌드 결정론
주의할 점은, 같은 Dockerfile을 가지고 만든 이미지여도, 항상 같은 이미지 ID를 갖는 것이 보장되지 않는다는 것이다.
- 빌드 시점마다 빌드 과정의 해시 체인에 입력되는 아래와 같은 데이터가 달라질 수 있다
- 빌드 시 포함되는 파일의 타임스탬프
- 빌드 시각(빌드 타임스탬프)
apt-get update등 매번 다른 결과를 가져올 수 있는 레이어- …
- 빌드 도구에 따라 레이어 생성 방식, 빌드 히스토리 기록, 타임스탬프 처리, 레이어 최적화 등에서 차이가 있을 수 있다
따라서, 아래와 같은 경우에는 이미지 ID가 같지 않을 수 있다.
- 같은 Dockerfile을 가지고 이미지를 재빌드하는 경우
- 같은 Dockerfile을 다른 빌드 도구를 이용해 빌드하는 경우
다만, 일단 한 번 빌드되어 결정된 이미지 ID는 불변이다. 따라서 한 번 빌드된 이미지를 어떤 레지스트리에 push/pull하는 것은 이미지 ID가 동일함을 보장하지만, 같은 도구 혹은 다른 도구로 Dockerfile을 빌드 혹은 재빌드하는 것은 동일한 이미지 ID를 보장하지 않는다.
레지스트리
레지스트리란
컨테이너 이미지를 저장하고, 다른 사람이나 컴퓨터 간에 공유할 수 있는 저장소다. 이미지를 빌드한 뒤 레지스트리로 푸시(push, 업로드)하면, 다른 컴퓨터에서 해당 이미지를 풀(pull, 다운로드)하여 실행할 수 있다.
- 공개 레지스트리: 누구나 이미지를 가져올 수 있다. 대표적으로 Docker Hub가 있다.
- 비공개 레지스트리: 특정 사람이나 컴퓨터만 액세스할 수 있다. 기업 내부용으로 Harbor, AWS ECR, GCR 등이 사용된다.
빌드, 배포, 실행
이미지의 생애주기는 빌드 → 푸시 → 풀 → 실행 순서로 이루어진다.
Developer Registry Production
┌──────────┐ ┌──────────┐ ┌──────────┐
│Dockerfile │ │ │ │ │
│ ↓ │ push │ │ pull │ │
│ Build │─────→│ Store │─────→│ Container│
│ Image │ │ │ │ Run │
└──────────┘ └──────────┘ └──────────┘
- 빌드: 개발자가 개발 머신에서 이미지를 빌드한다
- 푸시: 빌드된 이미지를 레지스트리에 업로드한다
- 풀: 프로덕션 머신(또는 다른 환경)에서 레지스트리로부터 이미지를 다운로드한다
- 실행: 다운로드한 이미지를 기반으로 격리된 컨테이너를 생성하고, 이미지에 지정된 실행파일을 실행한다
이미지 빌드 없이 빌드하는 머신에서 바로 실행할 수도 있고, 레지스트리를 거치지 않고 docker save/docker load로 이미지를 직접 전달할 수도 있다. 하지만 레지스트리를 통한 배포가 일반적인 워크플로우다.
컨테이너
컨테이너란, 이미지 위에 쓰기 레이어를 얹고, 격리된 환경에서 실행되는 프로세스를 의미한다.
Container = 이미지(읽기 전용) + 쓰기 가능 레이어 + 격리된 프로세스
실행 중인 컨테이너는 호스트에서 실행되는 프로세스이지만, 호스트와 호스트에서 실행 중인 다른 프로세스와 격리돼 있다. 또한 리소스 사용이 제한돼 있으므로, 할당된 리소스의 양(CPU, RAM 등)만 액세스하고 사용할 수 있다.
이미지는 읽기 전용이므로, 컨테이너 실행 중 발생하는 파일 변경은 쓰기 레이어에 기록된다. 컨테이너가 삭제되면 쓰기 레이어도 함께 삭제된다.
컨테이너의 파일시스템 격리가 실제로 어떻게 구현되는지(OverlayFS, mount namespace, pivot_root)는 컨테이너 파일 시스템을 참고한다.
이미지의 이식성 한계
컨테이너 이미지는 이론적으로 컨테이너 런타임이 설치된 모든 리눅스 시스템에서 실행될 수 있다. 그러나 호스트에서 실행되는 모든 컨테이너가 호스트의 리눅스 커널을 공유한다는 점에서 주의할 것이 있다.
커널 의존성
컨테이너는 가상머신과 달리 자체 커널을 실행하지 않는다. 따라서 컨테이너화된 애플리케이션이 특정 커널 버전이나 커널 모듈을 필요로 한다면, 해당 커널을 사용하지 않는 호스트에서는 실행되지 않을 수 있다.
가상머신: 각 VM이 자체 커널 실행 → 커널 버전 제약 없음
컨테이너: 호스트 커널 공유 → 호스트 커널에 의존
하드웨어 아키텍처
커널뿐만 아니라 하드웨어 아키텍처에도 제약이 있다. x86 아키텍처용으로 빌드된 컨테이너 이미지는 ARM 기반 시스템에서 실행할 수 없다. 컨테이너 런타임이 설치돼 있다고 해서 아키텍처가 다른 이미지를 실행할 수 있는 것은 아니다.
참고: 이 한계를 극복하기 위해 멀티 아키텍처 이미지(manifest list 또는 image index)가 사용된다. 하나의 이미지 태그 아래에 여러 아키텍처별 이미지를 묶어두고, 런타임이 호스트 아키텍처에 맞는 이미지를 자동으로 선택하는 방식이다.
정리
컨테이너 이미지는 OCI 표준에 따라 manifest, config JSON, layers로 구성된 읽기 전용 템플릿이다. Dockerfile 등의 빌드 명세로부터 레이어를 생성하고, 레이어들이 쌓여 하나의 파일시스템을 이룬다.
- 레이어 공유로 다운로드와 저장이 효율적이고, Copy-on-Write로 이미지를 불변으로 유지하면서 컨테이너별 격리를 제공한다.
- 이미지의 내용 동일성은 이미지 ID(config JSON 해시)로 판단하고, 레지스트리에서의 참조는 태그(가변)와 다이제스트(불변)로 한다.
- 빌드된 이미지는 레지스트리를 통해 배포되며, 레지스트리에서 pull한 이미지 위에 쓰기 레이어를 얹어 컨테이너를 실행한다.
컨테이너 런타임(dockerd, containerd)이 이미지를 어떻게 관리하는지는 Docker와 containerd 이미지 관리 비교 시리즈를, 이미지 레이어가 실제로 어떻게 파일시스템으로 합쳐지는지는 컨테이너 파일 시스템을 참고한다.
댓글남기기