Docker 입문

목표: “Docker 이미지로 받아서 돌리세요”, “컨테이너 안에서 실행됩니다”, “Dockerfile 올려 두었습니다” 같은 말을 들었을 때 무엇이 어디에서 돌아가는지 그림으로 떠올리고, 기본 명령어로 직접 써 볼 수 있기.

컴퓨터 기본 도구(터미널, 셸, 패키지 매니저, 가상환경)가 낯설다면 컴퓨터 기본 도구와 개발 환경 입문을 먼저 읽는 편이 좋다.


한 줄로 먼저

Docker는 프로그램을 그 프로그램이 돌아가는 데 필요한 환경(운영체제 파일, 언어 버전, 라이브러리, 설정)과 함께 하나의 상자로 묶어서, 어느 컴퓨터에서든 똑같이 실행하게 해 주는 도구다.

그 “상자의 설계도”가 이미지(image) 이고, 이미지를 실제로 실행한 것이 컨테이너(container) 다.

Dockerfile (설계 문서, 텍스트 파일)
  ↓ docker build
이미지 (실행에 필요한 모든 파일을 얼려 둔 읽기 전용 묶음)
  ↓ docker run
컨테이너 (그 이미지를 실제로 돌리고 있는 격리된 프로세스)

이미지는 레지스트리(Docker Hub 같은 저장소)에 올리고 내려받을 수 있다. 그래서 “내 컴퓨터의 환경”을 다른 사람에게 파일 하나처럼 넘겨줄 수 있다.


0. 왜 필요한가: “제 컴퓨터에서는 되는데요”

코드를 남에게 넘기면 자주 이런 일이 생긴다.

  • 내 노트북은 Python 3.11인데 서버는 3.8이다.
  • 나는 numpy 1.26을 썼는데 상대는 numpy 2.0이 깔려 있어서 함수가 사라졌다.
  • 내 코드는 ffmpeg 같은 시스템 프로그램이 필요한데, 상대 컴퓨터에는 없다.
  • CUDA 12.1용으로 빌드한 PyTorch를 CUDA 11.8 환경에서 돌리려 한다.

문제의 본질은 코드만 옮기고 환경은 안 옮겼다는 데 있다. 코드가 실제로 의존하는 것은 코드 바깥에 훨씬 많다.

층예보통 누가 관리하나
내 코드train.py나
언어 패키지torch, numpypip, conda, uv
언어 실행기Python 3.11pyenv, conda
시스템 라이브러리·프로그램libgl, ffmpeg, gccapt, brew
운영체제 사용자 공간Ubuntu 22.04의 파일들OS 설치
커널Linux 커널OS 설치 (Docker가 옮기지 않는 층)

Python 가상환경(venv, conda)은 위에서 두세 번째 층까지만 격리한다. Docker는 커널을 뺀 나머지 전부를 묶는다. 그래서 “Ubuntu 22.04 + Python 3.10 + ffmpeg + 내 패키지”라는 환경 전체를 하나의 이미지로 고정할 수 있다.


1. 가상머신과 컨테이너는 무엇이 다른가

둘 다 “한 컴퓨터 안에 격리된 컴퓨터처럼 보이는 공간”을 만든다. 차이는 운영체제 커널을 통째로 하나 더 띄우느냐다.

가상머신(VM)                           컨테이너
┌──────────┐ ┌──────────┐            ┌──────────┐ ┌──────────┐
│  앱 A     │ │  앱 B     │            │  앱 A     │ │  앱 B     │
│ 라이브러리 │ │ 라이브러리 │            │ 라이브러리 │ │ 라이브러리 │
│ 게스트 OS │ │ 게스트 OS │            └──────────┘ └──────────┘
│ (커널 포함)│ │ (커널 포함)│            ┌────────────────────────┐
└──────────┘ └──────────┘            │  Docker Engine          │
┌────────────────────────┐            ├────────────────────────┤
│  하이퍼바이저             │            │  호스트 OS 커널 (공유)    │
├────────────────────────┤            ├────────────────────────┤
│  하드웨어                 │            │  하드웨어                 │
└────────────────────────┘            └────────────────────────┘
구분가상머신컨테이너
커널VM마다 자기 커널호스트 커널을 함께 씀
시작 시간수십 초~분 (OS 부팅)대개 1초 안팎 (프로세스 시작)
크기수 GB~수십 GB수십 MB~수 GB
격리 강도강함 (하드웨어 수준 가상화)상대적으로 약함 (커널을 공유)
다른 OS 실행Linux 위에서 Windows도 가능Linux 컨테이너는 Linux 커널이 필요
대표 도구VirtualBox, VMware, Parallels, UTMDocker, Podman

컨테이너는 “격리된 프로세스”일 뿐이다

컨테이너는 작은 컴퓨터가 아니다. 호스트에서 돌아가는 평범한 프로세스에 Linux 커널의 두 기능을 씌운 것이다.

  • namespace: 이 프로세스가 볼 수 있는 것을 제한한다. 자기만의 파일 시스템, 프로세스 목록, 네트워크, 호스트 이름을 가진 것처럼 보이게 한다.
  • cgroup (control group): 이 프로세스가 쓸 수 있는 양을 제한한다. CPU, 메모리, I/O 사용량에 상한을 건다.

그래서 컨테이너는 빠르고 가볍다. 반대로 커널을 공유하므로 VM보다 격리가 약하다. 신뢰할 수 없는 코드를 돌릴 때는 이 차이를 의식해야 한다.

macOS·Windows에서는?

macOS와 Windows에는 Linux 커널이 없다. 그래서 Docker Desktop은 보이지 않는 작은 Linux VM을 하나 띄우고, 그 안에서 컨테이너를 돌린다. Mac에서 docker run을 치면 실제로는 그 VM 속 Linux에서 실행된다. Mac의 파일을 컨테이너에 연결하면 VM 경계를 넘어야 하므로 Linux 서버보다 파일 접근이 느릴 수 있다.


2. 꼭 알아야 할 네 단어

단어한 줄 정의비슷한 것
이미지 (image)실행에 필요한 파일과 설정을 얼려 둔 읽기 전용 묶음프로그램 설치 파일 + 미리 깔린 환경
컨테이너 (container)이미지를 실행한 것. 이미지 위에 쓰기 가능한 얇은 층이 붙은 상태실행 중인 프로그램
Dockerfile이미지를 어떻게 만들지 적은 텍스트 레시피설치 매뉴얼을 코드로 쓴 것
레지스트리 (registry)이미지를 올리고 내려받는 저장소앱스토어, GitHub

이미지와 컨테이너의 관계는 프로그래밍의 클래스와 인스턴스와 같다. 이미지 하나로 컨테이너를 여러 개 동시에 띄울 수 있고, 각 컨테이너는 서로 독립적이다. 컨테이너 안에서 파일을 바꿔도 이미지는 바뀌지 않는다.

이미지 이름 읽는 법

docker.io/library/python:3.11-slim
└─레지스트리─┘ └저장소┘ └이름┘ └─태그─┘
  • 레지스트리: 생략하면 Docker Hub(docker.io). 그 밖에 GitHub Container Registry(ghcr.io), NVIDIA NGC(nvcr.io) 등이 있다.
  • 태그(tag): 버전 표시. 생략하면 latest가 붙는다. latest는 “최신”이라는 보장이 아니라 그냥 기본 이름표다.
  • slim, alpine 같은 꼬리표는 크기를 줄인 변형이다. alpine은 작지만 일반 Linux와 C 라이브러리(musl)가 달라서 Python 과학 패키지 설치가 오히려 번거로울 수 있다.

같은 태그에 나중에 다른 내용이 덮어씌워질 수 있다. 내용을 정확히 고정하려면 python@sha256:... 같은 다이제스트(digest) 를 쓴다.


3. 이미지는 레이어로 쌓인다

이미지는 한 덩어리가 아니라 층(layer)을 여러 장 겹친 것이다. Dockerfile의 명령 하나하나가 대체로 층 하나가 된다.

[층 4] COPY . /app                ← 내 코드 (자주 바뀜)
[층 3] RUN pip install -r ...     ← 패키지 설치
[층 2] COPY requirements.txt .    ← 의존성 목록
[층 1] FROM python:3.11-slim      ← 기반 이미지 (거의 안 바뀜)

이 구조 때문에 두 가지가 좋아진다.

  1. 공유: 여러 이미지가 같은 python:3.11-slim 층을 쓰면 디스크에는 한 번만 저장된다.
  2. 캐시: 다시 빌드할 때 바뀌지 않은 층은 재사용한다.

캐시에는 중요한 규칙이 하나 있다. 어떤 층이 바뀌면 그 위의 모든 층을 다시 만든다. 그래서 잘 안 바뀌는 것을 아래에, 자주 바뀌는 것을 위에 둔다. 위 예시처럼 requirements.txt만 먼저 복사해 패키지를 설치하고, 코드 전체는 마지막에 복사하는 이유다. 순서를 거꾸로 하면 코드 한 줄을 고칠 때마다 pip install을 처음부터 다시 한다.

컨테이너를 띄우면 이 읽기 전용 층들 위에 쓰기 가능한 층이 하나 얹힌다. 컨테이너 안에서 만든 파일은 전부 이 층에 들어가고, 컨테이너를 지우면 같이 사라진다.


4. Dockerfile 읽고 쓰기

간단한 Python 프로그램을 담는 Dockerfile이다.

# 1. 기반 이미지: Python 3.11이 깔린 가벼운 Debian
FROM python:3.11-slim
 
# 2. 시스템 프로그램 설치 (apt는 Debian/Ubuntu의 패키지 매니저)
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
    && rm -rf /var/lib/apt/lists/*
 
# 3. 이후 명령이 실행될 작업 폴더
WORKDIR /app
 
# 4. 의존성 목록을 먼저 복사하고 설치 (캐시 활용)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
 
# 5. 나머지 코드 복사
COPY . .
 
# 6. 환경 변수
ENV PYTHONUNBUFFERED=1
 
# 7. 컨테이너가 시작될 때 기본으로 실행할 명령
CMD ["python", "main.py"]
명령뜻
FROM어떤 이미지 위에 쌓을지. 모든 Dockerfile의 시작
RUN빌드할 때 실행할 명령. 결과가 이미지에 저장됨
COPY내 컴퓨터의 파일을 이미지 안으로 복사
WORKDIR이후 명령의 현재 폴더
ENV환경 변수 설정
EXPOSE이 컨테이너가 어느 포트를 쓰는지 문서로 표시만 함. 실제로 포트를 열지는 않음
CMD컨테이너를 시작할 때 실행할 기본 명령. docker run 뒤에 다른 명령을 주면 대체됨
ENTRYPOINT컨테이너를 시작할 때 항상 실행할 프로그램. CMD는 그 기본 인자가 됨

가장 많이 헷갈리는 것은 RUN과 CMD다. RUN은 이미지를 만드는 순간 한 번 실행되고, CMD는 컨테이너를 켤 때마다 실행된다.

.dockerignore

COPY . .는 폴더 전체를 복사한다. .git, 데이터셋, 체크포인트, .env(비밀번호 파일)까지 이미지에 들어가지 않도록 .dockerignore 파일에 제외 목록을 적는다. 문법은 .gitignore와 비슷하다.

.git
__pycache__/
data/
checkpoints/
.env

빌드

docker build -t my-app:0.1 .

-t는 이미지 이름과 태그, 마지막 .은 빌드 컨텍스트(Docker에 넘겨줄 폴더)다. COPY는 이 폴더 안의 파일만 가져올 수 있다.


5. 컨테이너의 일생

docker create   docker start      docker stop        docker rm
   생성  ──────▶  실행 중  ──────▶  멈춤  ──────────▶  삭제
          docker run = create + start
docker run --rm -it python:3.11-slim bash
옵션뜻
--rm컨테이너가 끝나면 자동으로 삭제
-i입력을 열어 둠 (interactive)
-t터미널처럼 보이게 함 (tty). 보통 -it로 붙여 씀
-d백그라운드에서 실행 (detached)
--name컨테이너에 이름 붙이기
-e KEY=VALUE환경 변수 전달

컨테이너는 안에서 돌던 주 프로그램이 끝나면 같이 멈춘다. docker run ubuntu가 바로 꺼지는 이유는 기본 명령(bash)이 입력을 받을 터미널 없이 시작했다가 곧바로 끝나기 때문이다.

이미 돌고 있는 컨테이너 안에 들어가려면 docker exec를 쓴다.

docker exec -it my-container bash

컨테이너는 일회용으로 생각한다

컨테이너 안에서 pip install을 하거나 파일을 고친 것은 그 컨테이너에만 남는다. 지우면 사라지고, 같은 이미지로 새 컨테이너를 띄우면 처음 상태다. 그래서 바뀐 환경을 남기고 싶으면 컨테이너를 고치지 말고 Dockerfile을 고쳐서 다시 빌드한다. docker commit으로 컨테이너를 이미지로 굳히는 방법도 있지만, 무엇을 바꿨는지 기록이 안 남아서 재현성이 떨어진다.


6. 데이터 보존: 볼륨과 바인드 마운트

컨테이너가 지워져도 남아야 하는 것(데이터셋, 학습 결과, DB 파일)은 컨테이너 바깥에 둔다. 방법은 두 가지다.

방식쓰는 법데이터 위치언제
바인드 마운트-v /Users/me/data:/data내가 지정한 호스트 폴더내 코드·데이터를 컨테이너에서 바로 보고 고칠 때
볼륨 (named volume)-v mydata:/dataDocker가 관리하는 저장 공간DB 파일처럼 Docker 안에서만 쓰는 데이터
# 현재 폴더를 컨테이너의 /workspace로 연결
docker run --rm -it -v "$(pwd)":/workspace -w /workspace python:3.11-slim bash

콜론(:) 왼쪽이 호스트, 오른쪽이 컨테이너다. 바인드 마운트는 양쪽이 같은 폴더를 보는 것이므로, 컨테이너 안에서 파일을 지우면 내 컴퓨터에서도 지워진다.


7. 네트워크와 포트

컨테이너는 자기만의 네트워크 공간을 가진다. 컨테이너 안에서 웹 서버가 80번 포트(프로그램이 통신에 쓰는 번호표)로 떠 있어도, 바깥에서는 그냥 접속할 수 없다. 포트를 연결해 줘야 한다.

docker run -d -p 8080:80 nginx

이것도 왼쪽이 호스트, 오른쪽이 컨테이너다. “내 컴퓨터의 8080번으로 들어온 요청을 컨테이너의 80번으로 보내라”는 뜻이다. 이제 브라우저에서 http://localhost:8080으로 접속할 수 있다.

컨테이너끼리 통신할 때는 같은 사용자 정의 네트워크에 넣으면 컨테이너 이름으로 서로를 찾을 수 있다. 예를 들어 앱 컨테이너는 DB에 localhost가 아니라 db:5432로 접속한다. 컨테이너 안에서 localhost는 그 컨테이너 자신이다. 이걸 놓쳐서 “DB에 연결이 안 된다”는 문제가 자주 생긴다.


8. Docker Compose: 여러 컨테이너를 한 파일로

실제 서비스는 웹 서버, DB, 캐시처럼 컨테이너 여러 개로 이뤄진다. 매번 긴 docker run 명령을 치는 대신 compose.yaml(예전 이름 docker-compose.yml) 한 파일에 적는다.

services:
  app:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/mydb
    depends_on:
      - db
  db:
    image: postgres:16
    environment:
      - POSTGRES_PASSWORD=pass
    volumes:
      - dbdata:/var/lib/postgresql/data
 
volumes:
  dbdata:
docker compose up -d     # 전부 띄우기
docker compose logs -f   # 로그 보기
docker compose down      # 전부 내리기 (볼륨은 남음)

Compose는 서비스마다 자동으로 같은 네트워크를 만들어 주므로 app이 db라는 이름으로 DB에 접속할 수 있다. 예전 명령인 docker-compose(하이픈 있음)는 구버전이고, 지금은 docker compose(띄어쓰기)를 쓴다.


9. 연구 환경에서의 Docker

GPU 쓰기

NVIDIA GPU가 있는 Linux 서버에서는 NVIDIA Container Toolkit을 설치하면 컨테이너가 GPU를 쓸 수 있다.

docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi

역할 분담을 기억해 두면 버전 문제를 덜 헷갈린다.

  • GPU 드라이버는 호스트에 깔린다. 컨테이너가 가져가지 않는다.
  • CUDA 툴킷과 cuDNN, PyTorch는 이미지 안에 들어간다.
  • 이미지 안의 CUDA 버전은 호스트 드라이버가 지원하는 범위 안이어야 한다. 드라이버가 너무 오래되면 최신 CUDA 이미지는 돌지 않는다.

그래서 연구용으로는 처음부터 pytorch/pytorch나 NVIDIA NGC의 nvcr.io/nvidia/pytorch 같은 이미지를 기반으로 쓰는 경우가 많다.

Mac에서는 Docker로 NVIDIA GPU를 쓸 수 없다. Apple Silicon의 GPU(Metal)도 Docker 컨테이너에는 일반적으로 노출되지 않는다. Mac의 Docker는 CPU 전용 실험이나 서버 환경 미리 맞춰 보기에 쓴다고 생각하는 편이 맞다.

Apple Silicon과 CPU 아키텍처

이미지는 CPU 종류(아키텍처)별로 따로 만들어진다. Apple Silicon Mac은 arm64(aarch64), 대부분의 연구실 서버와 클라우드는 amd64(x86_64)다.

  • 공식 이미지 다수는 두 아키텍처를 모두 제공하므로 자동으로 맞는 것을 받는다.
  • amd64용으로만 나온 이미지는 Mac에서 에뮬레이션으로 돌아서 느리거나 실패할 수 있다.
  • Mac에서 빌드한 이미지를 서버에 올릴 때는 docker build --platform linux/amd64 ...로 서버용을 따로 만들어야 한다. 이걸 빼먹으면 서버에서 exec format error가 난다.

공용 서버와 클러스터

  • docker 명령을 쓸 수 있는 사용자는 사실상 그 서버의 root 권한을 가진다. 컨테이너에 호스트의 /를 마운트하면 모든 파일에 접근할 수 있기 때문이다. 그래서 공용 서버 관리자는 docker 그룹 추가를 신중히 한다.
  • 대학 HPC 클러스터는 이 문제 때문에 Docker 대신 Apptainer(옛 이름 Singularity)를 쓰는 경우가 많다. root 데몬 없이 일반 사용자 권한으로 돌고, Docker 이미지를 변환해서 쓸 수 있다.
  • Docker 자체에도 root 없이 도는 rootless 모드가 있다.

벤치마크 샌드박스

LLM 에이전트 벤치마크는 에이전트가 명령을 실행하고 파일을 고쳐도 다른 과제에 영향이 없도록, 과제마다 깨끗한 컨테이너를 띄우는 방식을 많이 쓴다. 예를 들어 Terminal-Bench는 과제마다 Docker 환경을 두고, SWE-bench도 저장소별 실행 환경을 컨테이너로 재현해 테스트를 돌린다. 앞에서 말했듯 컨테이너는 커널을 공유하므로, 정말 위험한 코드를 다루는 곳에서는 gVisor 같은 추가 격리층이나 VM을 함께 쓰기도 한다.


10. 자주 쓰는 명령어

# 이미지
docker pull python:3.11-slim        # 내려받기
docker images                       # 내 컴퓨터의 이미지 목록
docker build -t name:tag .          # Dockerfile로 빌드
docker rmi name:tag                 # 이미지 삭제
docker push user/name:tag           # 레지스트리에 올리기 (docker login 필요)
 
# 컨테이너
docker run --rm -it image bash      # 띄워서 들어가기
docker run -d --name web -p 8080:80 nginx   # 백그라운드 + 포트 연결
docker ps                           # 실행 중인 컨테이너
docker ps -a                        # 멈춘 것까지 전부
docker logs -f web                  # 로그 따라 보기
docker exec -it web bash            # 실행 중인 컨테이너에 들어가기
docker stop web                     # 멈추기
docker rm web                       # 삭제
 
# 정리
docker system df                    # 디스크 사용량
docker system prune                 # 멈춘 컨테이너·안 쓰는 네트워크·이름 없는 이미지 정리

문제가 생기면 순서대로 본다. docker ps -a로 컨테이너가 살아 있는지, docker logs로 왜 죽었는지, docker exec로 안에 들어가 파일과 환경 변수가 기대한 대로인지.


11. 비슷하게 들리는 이름들

이름무엇
Docker Engine실제로 컨테이너를 만들고 돌리는 Linux용 프로그램. dockerd라는 데몬(뒤에서 계속 도는 프로그램) + docker 명령줄 도구
Docker DesktopMac·Windows용 앱. Linux VM + Docker Engine + GUI를 한 번에 설치
Docker HubDocker 회사가 운영하는 공개 이미지 레지스트리
containerd, runcDocker 아래에서 실제 컨테이너 실행을 맡는 부품들
OCI이미지 형식과 컨테이너 실행 방식의 공개 표준. 덕분에 Docker 이미지를 다른 도구에서도 씀
PodmanDocker와 거의 같은 명령을 쓰는 다른 도구. 데몬 없이, root 없이 돌리는 것이 기본
Kubernetes (k8s)컨테이너를 여러 서버에 걸쳐 수백·수천 개 배치하고 관리하는 시스템. Docker가 “컨테이너 하나 돌리기”라면 Kubernetes는 “컨테이너 함대 운영하기”
OrbStack, ColimaMac에서 Docker Desktop 대신 쓸 수 있는 가벼운 대안
Apptainer (Singularity)HPC 클러스터용 컨테이너 도구. 일반 사용자 권한으로 실행

12. 아주 짧은 암기

  • 이미지 = 얼려 둔 환경, 컨테이너 = 그걸 실행한 것. 이미지 하나로 컨테이너 여러 개.
  • Dockerfile = 이미지 만드는 레시피. RUN은 빌드 때, CMD는 실행 때.
  • 컨테이너는 VM이 아니라 격리된 프로세스. 커널을 호스트와 공유한다.
  • 컨테이너 안의 변경은 지우면 사라진다. 남길 것은 볼륨이나 바인드 마운트로 바깥에.
  • -p 호스트:컨테이너, -v 호스트:컨테이너. 콜론 왼쪽이 내 컴퓨터.
  • 컨테이너 안의 localhost는 그 컨테이너 자신.
  • 레이어 캐시: 안 바뀌는 것은 아래, 자주 바뀌는 것은 위.
  • GPU 드라이버는 호스트, CUDA·PyTorch는 이미지. Mac에서는 NVIDIA GPU 불가.
  • Apple Silicon(arm64)과 서버(amd64)는 이미지가 다르다.

입문자가 조심할 점

  • 비밀 정보를 이미지에 넣지 않는다. Dockerfile의 ENV OPENAI_API_KEY=...나 COPY .env는 이미지 층에 그대로 남는다. 나중에 지우는 명령을 추가해도 아래 층에는 남아 있다. 키는 실행할 때 -e나 --env-file로 넘긴다.
  • latest 태그는 재현성을 보장하지 않는다. 논문 재현이나 실험 기록에는 python:3.11.9-slim처럼 구체적인 태그나 다이제스트를 적는다.
  • 디스크가 조용히 찬다. 빌드를 반복하면 이름 없는 이미지와 빌드 캐시가 쌓인다. Mac에서는 Docker Desktop VM의 디스크 파일이 수십 GB까지 커질 수 있다. 가끔 docker system df로 확인한다.
  • docker 권한은 root 권한이다. 남의 서버에서 받은 권한, 출처를 모르는 이미지 모두 조심한다. 이미지는 가능하면 공식(Official) 또는 검증된 배포처의 것을 쓴다.
  • Docker가 모든 재현성 문제를 해결하지는 않는다. 하드웨어(GPU 종류), 드라이버, 난수 시드, 외부 API처럼 이미지 바깥에 있는 요인은 여전히 결과를 바꾼다.
  • Docker Desktop은 회사에서는 유료일 수 있다. 개인·교육·소규모 기업은 무료지만, 일정 규모(직원 250명 이상 또는 연매출 1천만 달러 이상) 기업은 유료 구독이 필요하다. Docker Engine 자체(Linux)는 오픈소스다.

출처