컴퓨터 한 대에 프로그램 하나만 올리던 시절에는 배포라는 말이 지금처럼 무겁지 않았다. 서버 한 대를 사서 운영체제를 설치하고, 필요한 라이브러리를 깔고, 애플리케이션을 복사해 실행하면 됐다. 문제는 서버가 비싸고 느리게 늘어났다는 데 있었다. CPU는 놀고 있는데 메모리는 남고, 디스크도 남는데, 어느 한 프로그램이 시스템 전체를 망가뜨릴까 봐 같은 서버에 다른 프로그램을 마음 놓고 올리지 못했다.
이 불편함이 Docker까지 이어지는 긴 역사의 출발점이다.

Docker 컨테이너는 애플리케이션과 실행 의존성을 이미지로 묶는 배포 단위다.
Docker가 갑자기 세상을 뒤집은 것처럼 보이지만, 실제로는 하드웨어 발전과 운영체제 발전이 충분히 쌓인 뒤에 등장한 얇은 포장지에 가깝다. 좋은 의미의 포장지다. Docker는 CPU를 새로 발명하지 않았고, 리눅스 커널의 격리 기능을 혼자 만든 것도 아니다. 이미 있던 격리와 파일시스템, 네트워크, 이미지 배포 방식을 개발자가 이해할 수 있는 형태로 묶었다. 그 차이가 컸다. 기술은 있었지만 손에 잡히지 않았고, Docker는 그 기술을 docker run 한 줄로 낮췄다.
서버가 비싸던 시대: 한 기계에 하나의 세계
물리 서버, 하드웨어 가상화, VM, 리눅스 커널 격리, Docker, Kubernetes로 이어지는 흐름.
초기의 서버 운영은 물리 장비 중심이었다. 애플리케이션을 하나 배포하려면 CPU, 메모리, 디스크, 네트워크 카드가 들어간 물리 서버를 준비했다. 서버실에 장비를 넣고, 운영체제를 설치하고, 방화벽을 열고, 런타임을 맞췄다. 이 방식은 이해하기 쉽다. 장비 하나가 곧 실행 환경 하나다.
단순하지만 낭비가 컸다.
웹 서버는 낮에는 바쁘고 밤에는 한가하다. 배치 서버는 특정 시간에만 바쁘다. DB 서버는 디스크 I/O가 병목일 수 있고, API 서버는 CPU보다 네트워크 대기 시간이 더 길 수 있다. 그런데 물리 서버 단위로만 배포하면 남는 자원을 잘게 쪼개 쓰기 어렵다. 같은 서버에 여러 서비스를 올릴 수도 있지만, 그때부터 라이브러리 버전 충돌과 장애 전파가 시작된다. /usr/lib 아래의 어떤 공유 라이브러리를 업그레이드했더니 옆 서비스가 죽는 식이다.
운영팀 입장에서 가장 무서운 것은 예측 불가능성이다. A 서비스가 로그를 폭발적으로 쓰면 디스크가 꽉 차고, B 서비스까지 같이 멈춘다. C 서비스가 메모리를 계속 잡아먹으면 커널의 OOM killer가 엉뚱한 프로세스를 죽인다. 프로세스는 서로 다른 프로그램처럼 보이지만, 같은 운영체제 안에서는 많은 것을 공유한다. 파일시스템, 네트워크 스택, 프로세스 테이블, 사용자 계정, 커널이 전부 같은 세계다.
그래서 사람들은 더 강한 경계를 원했다. 한 물리 서버 안에 여러 개의 작은 서버처럼 보이는 환경을 만들고 싶었다.
가상화가 먼저 해결한 것
가상화는 물리 서버 한 대를 여러 대의 가짜 서버처럼 나눠 쓰는 기술이다. 여기서 가짜라는 말은 낮춰 부르는 표현이 아니다. 게스트 운영체제는 자기 앞에 CPU, 메모리, 디스크, 네트워크 카드가 있다고 믿고 실행된다. 그 아래에서 하이퍼바이저가 실제 하드웨어를 나눠준다.
하이퍼바이저는 가상 머신을 관리하는 계층이다. VMware ESX, Xen, KVM 같은 기술이 이 흐름을 대표한다. KVM은 리눅스 커널 안에 들어간 하이퍼바이저 기능이고, 리눅스는 2.6.20 릴리스에서 KVM을 포함했다는 기록을 kernelnewbies.org의 Linux 2.6.20 변경 목록에서 확인할 수 있다. 이 시점부터 리눅스 서버에서 가상 머신을 운영하는 길이 훨씬 자연스러워졌다.
가상 머신의 장점은 경계가 강하다는 점이다. VM 하나에는 운영체제가 통째로 들어간다. A VM에서 Ubuntu 20.04를 돌리고, B VM에서 CentOS 7을 돌릴 수 있다. 한 VM 안에서 커널 패닉이 나도 다른 VM까지 바로 같이 죽지는 않는다. 운영체제 수준에서 분리되므로 보안과 운영 모델도 물리 서버와 비슷하게 가져갈 수 있다.
문제는 무게다.
VM 하나를 띄우려면 게스트 운영체제가 필요하다. 커널도 있고, init 시스템도 있고, 기본 유틸리티도 있다. 애플리케이션 하나를 실행하려고 운영체제를 하나 더 부팅하는 셈이다. 물론 서버 자원이 빠르게 좋아지면서 이 비용은 감당 가능한 수준이 됐다. 오히려 당시에는 그 비용을 내고서라도 물리 서버 낭비를 줄이는 편이 훨씬 이득이었다.

가상화와 컨테이너는 물리 서버 자원을 더 잘게 나눠 쓰려는 흐름 위에서 성장했다.
하드웨어도 가상화를 밀어줬다. CPU가 멀티코어가 되고, 메모리 가격이 내려가고, x86 서버의 성능이 올라가면서 한 서버 안에 여러 VM을 넣을 수 있게 됐다. Intel VT-x, AMD-V 같은 하드웨어 가상화 지원은 하이퍼바이저가 CPU 명령을 더 효율적으로 다루게 했다. 스토리지는 SAN, NAS, 나중에는 분산 스토리지로 확장됐고, 네트워크는 1GbE에서 10GbE 이상으로 올라갔다. 인프라가 커지면서 서버 한 대를 통째로 쓰는 방식은 점점 어색해졌다.
이 시기의 승자는 가상 머신이었다. 클라우드도 이 모델 위에서 컸다. AWS EC2 같은 서비스는 사용자가 물리 서버를 직접 사지 않고도 VM을 받아 쓰는 경험을 만들었다. 인프라의 기본 단위가 장비에서 인스턴스로 바뀌었다.
VM이 해결하지 못한 배포의 찝찝함
가상 머신은 서버를 잘게 나누는 문제를 풀었다. 애플리케이션 배포 문제까지 깔끔하게 풀지는 못했다.
개발자는 로컬에서 코드를 실행한다. 운영 서버에는 다른 운영체제 버전, 다른 OpenSSL, 다른 glibc, 다른 Python minor 버전이 있다. 스테이징 서버는 또 다르다. 문서에는 apt install 명령이 길게 적혀 있고, 누군가는 순서를 하나 빼먹는다. 배포 스크립트는 시간이 갈수록 조건문이 늘어난다. 서버를 오래 운영할수록 처음 설치 상태와 멀어진다. 이것을 흔히 snowflake server라고 부른다. 눈송이처럼 서버마다 모양이 달라진다는 뜻이다.
VM 이미지로도 어느 정도 해결할 수 있다. 애플리케이션이 들어간 VM 이미지를 만들어 배포하면 된다. 하지만 이미지가 무겁고 빌드와 전송이 느리다. 작은 코드 변경 하나에도 큰 디스크 이미지를 다시 만들고 옮겨야 한다. 운영체제 전체를 포함하므로 애플리케이션 단위의 반복 배포에는 둔하다.
여기서 컨테이너가 들어온다.
컨테이너는 같은 커널을 공유하면서 프로세스의 세계를 나눈다. VM처럼 운영체제를 통째로 하나 더 띄우지 않는다. 리눅스 커널 하나 위에서 여러 프로세스 그룹을 서로 다른 환경처럼 보이게 만든다. 프로세스 입장에서는 자기만의 파일시스템 루트, 자기만의 프로세스 목록, 자기만의 네트워크 인터페이스가 있는 것처럼 보인다. 실제로는 같은 커널 안에서 커널 기능으로 격리된다.
이 차이가 크다. 커널을 공유하면 시작이 빠르고 이미지가 작아진다. 애플리케이션과 그 의존성만 묶으면 되기 때문이다. 컨테이너 하나를 띄우는 일은 운영체제 부팅보다 프로세스 실행에 가깝다.
리눅스 커널 안에 이미 있던 조각들
Docker 이전에도 컨테이너의 재료는 리눅스 안에 있었다. Docker가 잘한 일은 그 재료를 제품으로 엮은 것이다.
가장 먼저 봐야 할 조각은 namespace다. namespace는 프로세스가 보는 시스템 자원을 분리한다. PID namespace는 프로세스 ID 공간을 나눈다. 컨테이너 안에서 ps를 실행하면 자기 컨테이너 안의 프로세스만 보인다. Mount namespace는 파일시스템 마운트 지점을 나눈다. Network namespace는 네트워크 인터페이스와 라우팅 테이블을 나눈다. 리눅스의 namespace 종류와 동작은 namespaces(7) 매뉴얼에 정리되어 있다.
cgroups는 자원 사용량을 제한하고 측정한다. CPU를 얼마나 쓸 수 있는지, 메모리를 얼마까지 쓸 수 있는지, 블록 I/O를 어떻게 제한할지 같은 일을 맡는다. 컨테이너 하나가 메모리를 무한정 먹지 못하게 막는 장치가 여기서 나온다. cgroups v2의 설계와 컨트롤러 구조는 Linux kernel documentation에 설명되어 있다.
chroot도 오래된 재료다. 프로세스의 루트 디렉터리를 바꿔서 특정 디렉터리 바깥을 보지 못하게 만든다. chroot만으로 안전한 격리를 만들기는 어렵지만, 프로세스가 보는 파일시스템을 바꾼다는 아이디어는 컨테이너를 이해할 때 도움이 된다.
Union filesystem 계열도 중요하다. 컨테이너 이미지는 여러 레이어로 쌓인다. 베이스 이미지 위에 패키지를 설치한 레이어, 애플리케이션 파일을 복사한 레이어, 설정을 추가한 레이어가 올라간다. 같은 베이스 이미지를 쓰는 컨테이너가 많으면 레이어를 재사용한다. Docker가 초기에 AUFS를 활용했고, 현재 리눅스 환경에서는 overlay2 드라이버가 널리 쓰인다. Docker의 스토리지 드라이버 문서에서도 overlay2를 권장 드라이버로 설명한다.
이 네 가지를 합치면 컨테이너의 뼈대가 나온다.
- namespace로 보이는 세계를 나눈다.
- cgroups로 자원 사용량을 제한한다.
- chroot와 mount 격리로 파일시스템 경계를 만든다.
- Union filesystem으로 이미지를 레이어로 쌓고 재사용한다.
처음 접하면 컨테이너가 엄청난 마법처럼 느껴지지만, 커널 입장에서는 프로세스 실행 방식을 정교하게 제한하는 일에 가깝다. 그래서 컨테이너는 VM보다 가볍다. 반대로 커널을 공유한다는 특성 때문에 VM과 같은 수준의 커널 격리는 기대하면 안 된다. 보안 경계가 필요하면 seccomp, AppArmor, SELinux, user namespace, rootless 모드 같은 추가 장치까지 같이 봐야 한다.
Docker 이전에도 컨테이너는 있었다
컨테이너 아이디어가 Docker에서 처음 나온 것은 아니다. FreeBSD Jail은 2000년 전후부터 운영체제 수준 격리의 대표적인 사례로 언급됐다. Solaris Zones도 서버 안에 격리된 실행 환경을 만들었다. 리눅스에는 OpenVZ, Linux-VServer, LXC 같은 흐름이 있었다. LXC는 리눅스 namespace와 cgroups를 사용해 시스템 컨테이너를 제공했다.
그런데 대중화는 다른 문제다.
LXC를 직접 다루면 운영체제 내부 지식이 꽤 필요하다. 네트워크를 어떻게 붙일지, rootfs를 어떻게 만들지, cgroups 설정을 어떻게 줄지, 이미지를 어디서 가져올지, 배포 단위를 어떻게 공유할지까지 사용자가 직접 고민해야 했다. 커널 기능은 준비되어 있었지만 개발자의 일상 언어로 내려오지 못했다.
Docker는 이 간극을 제대로 찔렀다. 2013년 PyCon에서 dotCloud가 Docker를 공개했고, Solomon Hykes의 발표 영상은 지금도 The future of Linux Containers라는 제목으로 남아 있다. Docker가 초기에 LXC 위에서 출발했다는 점도 중요하다. 나중에는 libcontainer, runc, containerd 같은 런타임 계층으로 정리되며 Docker 자체의 구조가 바뀌었다.
기술의 본질은 컨테이너였지만, 개발자가 체감한 본질은 이미지와 워크플로우였다.
Dockerfile이 바꾼 배포 언어
Dockerfile은 Docker 생태계에서 가장 강력한 발명에 가깝다. 컨테이너를 실행하는 기술보다 더 큰 영향을 줬다고 본다.
기존 배포 문서는 사람에게 읽히는 절차였다.
sudo apt update
sudo apt install -y python3 python3-pip
pip install -r requirements.txt
export APP_ENV=production
python app.py
이 문서는 시간이 지나면 깨진다. 패키지 저장소가 바뀌고, 기본 Python 버전이 바뀌고, 어떤 서버에는 이미 패키지가 깔려 있다. 사람이 순서를 해석해서 실행하는 순간 환경 차이가 끼어든다.
Dockerfile은 실행 환경을 코드처럼 적는다.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
이 파일 하나에는 많은 정보가 들어 있다. 어떤 베이스 운영체제 계열을 쓰는지, 어떤 런타임 버전인지, 의존성을 언제 설치하는지, 애플리케이션 파일을 어디에 둘지, 시작 명령이 무엇인지가 보인다. 빌드 결과는 이미지가 된다. 이미지는 레지스트리에 올리고, 다른 서버에서 내려받아 실행한다.
여기서 배포의 단위가 바뀐다. 더 이상 서버에 접속해 이것저것 설치하는 방식이 중심이 아니다. 이미지를 만들고, 그 이미지를 실행한다. 운영 서버는 애플리케이션을 설치하는 장소가 아니라 이미지를 실행하는 장소가 된다. 이 변화가 CI/CD와 잘 맞았다. Git push 이후 테스트를 돌리고, 이미지를 빌드하고, 레지스트리에 푸시하고, 배포 시스템이 해당 이미지를 가져가 실행한다.
초보자 입장에서는 Dockerfile이 낯설 수 있다. 그래도 한 번 익숙해지면 배포 문서보다 낫다. 문서는 사람이 읽고 따라 하는 절차지만, Dockerfile은 빌드 시스템이 직접 실행하는 절차다. 틀리면 빌드가 실패한다. 이 차이가 운영 품질을 바꾼다.
이미지 레이어가 만든 재사용의 경제
Docker 이미지는 통째 파일 하나처럼 보이지만 내부적으로는 레이어 묶음이다. FROM python:3.12-slim 레이어 위에 pip install 레이어가 쌓이고, 그 위에 소스 코드 복사 레이어가 올라간다. 각 명령은 대체로 새 레이어를 만든다.
이 구조는 빌드 속도와 배포 속도에 영향을 준다. 의존성 설치 레이어가 이미 캐시되어 있으면 소스 코드만 바뀐 빌드는 훨씬 빨라진다. 서버가 이미 베이스 이미지를 가지고 있으면 새로 바뀐 레이어만 받으면 된다. 여러 서비스가 같은 베이스 이미지를 공유하면 디스크도 아낀다.
그래서 Dockerfile 작성 순서가 중요하다. 자주 바뀌는 파일을 앞에서 복사하면 뒤 레이어 캐시가 매번 깨진다. Python 프로젝트에서 requirements.txt를 먼저 복사해 설치하고, 그 다음 전체 소스를 복사하는 패턴이 널리 쓰이는 이유다.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
이 순서를 반대로 쓰면 코드 한 줄 수정할 때마다 의존성 설치가 다시 돌 수 있다. 작은 차이지만 팀 단위 CI에서는 비용 차이가 바로 보인다.
이미지 레이어는 배포물의 신뢰도에도 영향을 준다. 태그만 보면 같은 이미지처럼 보이지만, 실제로는 digest가 이미지 내용을 식별한다. 운영 배포에서는 latest 태그에 기대는 방식보다 명시적인 버전 태그나 digest 기반 배포가 낫다. latest는 편하지만, 나중에 같은 배포를 재현하려고 할 때 발목을 잡는다.
개발 환경까지 먹어버린 이유
Docker가 서버 배포 도구로만 끝났다면 지금만큼 강하지 않았을 것이다. Docker는 개발 환경까지 들어왔다.
신입 개발자가 프로젝트를 받았다고 해보자. 예전에는 README를 보며 로컬에 PostgreSQL을 깔고, Redis를 깔고, Node 버전을 맞추고, Python 가상환경을 만들고, 환경 변수를 넣었다. macOS와 Ubuntu의 패키지 이름이 다르고, Apple Silicon과 x86 이미지 문제가 생기고, 포트가 이미 점유되어 있으면 또 막힌다. 이 과정에서 하루가 사라진다. 과장이 아니다.
Docker Compose는 이 문제를 꽤 현실적으로 줄였다.
services:
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgres://app:app@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- "5432:5432"
docker compose up을 실행하면 API와 DB가 같이 뜬다. 로컬 머신에 PostgreSQL을 직접 설치하지 않아도 된다. 프로젝트마다 서로 다른 DB 버전을 써도 충돌이 적다. 지우고 다시 만들기도 쉽다. 개발 환경을 코드로 저장한다는 점에서 Docker Compose는 작은 팀에 특히 강하다.
물론 로컬 Docker가 모든 문제를 없애지는 않는다. 파일시스템 성능, 볼륨 권한, 플랫폼 아키텍처 차이, 네트워크 디버깅 같은 문제가 남는다. 그래도 팀 전체가 비슷한 출발선에서 개발을 시작하게 만든다는 장점이 훨씬 크다. 내 기준에서는 백엔드 프로젝트에서 DB, 캐시, 메시지 브로커를 로컬에 직접 설치하는 방식으로 돌아갈 이유가 거의 없다.
Docker Hub와 레지스트리의 힘
Docker 생태계가 커진 데에는 레지스트리가 결정적이었다. 컨테이너 이미지를 만들 수 있어도 공유가 불편하면 생태계는 느리게 큰다. Docker Hub는 이미지를 찾고 내려받는 기본 장소가 됐다. docker pull nginx, docker pull redis, docker pull postgres 같은 명령은 서버 소프트웨어 설치 경험을 바꿨다.
예전에는 PostgreSQL을 설치하려면 운영체제 패키지 저장소를 보고, 버전을 맞추고, 설정 파일 위치를 찾고, 데이터 디렉터리 권한을 맞췄다. Docker에서는 일단 이미지를 실행해 기능을 확인할 수 있다.
docker run --rm -e POSTGRES_PASSWORD=pass -p 5432:5432 postgres:16
이 명령이 운영 환경의 정답이라는 뜻은 아니다. 데이터 볼륨, 백업, 업그레이드, 보안 설정은 따로 다뤄야 한다. 그래도 처음 실행하는 경험이 짧아졌다. 기술 채택에서 첫 실행까지의 시간은 생각보다 중요하다. 개발자가 5분 만에 실행해볼 수 있는 도구는 퍼진다.
레지스트리는 사내 배포에도 잘 맞는다. 팀은 이미지를 빌드해 사내 레지스트리에 올리고, 배포 시스템은 그 이미지를 가져간다. 빌드와 실행 사이에 명확한 계약이 생긴다. 컨테이너 이미지는 애플리케이션 바이너리, 런타임, 일부 OS 유저랜드를 함께 담은 배포 단위가 된다.
Kubernetes가 Docker를 더 키웠다
Docker가 컨테이너를 개발자에게 익숙하게 만들었다면, Kubernetes는 컨테이너를 대규모 운영의 기본 단위로 밀어 올렸다. Kubernetes는 컨테이너를 여러 서버에 배치하고, 죽으면 다시 띄우고, 서비스 디스커버리와 롤링 업데이트를 제공한다. Google이 Kubernetes를 2014년에 공개했다는 기록은 Kubernetes 블로그의 10주년 글에도 나온다.
초기에는 많은 사람이 Kubernetes와 Docker를 거의 한 세트로 이해했다. Docker image를 만들고 Kubernetes가 그 이미지를 Pod로 실행하는 흐름이 자연스러웠다. 시간이 지나며 Kubernetes는 Docker Engine에 직접 의존하지 않는 방향으로 갔다. CRI, containerd, runc 같은 계층이 분리되면서 Kubernetes 노드에서 Docker 자체를 쓰지 않아도 컨테이너 이미지를 실행할 수 있게 됐다. Kubernetes 1.24에서 dockershim이 제거된 사실은 Kubernetes 1.24 릴리스 노트에 명시되어 있다.
이 변화가 Docker의 패배를 뜻하지는 않는다. 오히려 Docker가 만든 이미지 포맷과 개발 워크플로우가 업계 표준처럼 굳었다는 쪽에 가깝다. 운영 런타임은 containerd가 맡고, 개발자는 여전히 Dockerfile을 쓰고, 이미지는 OCI 규격에 맞춰 레지스트리에 올라간다. Docker라는 제품 이름과 컨테이너 생태계의 표준 구성요소가 분리된 셈이다.
하드웨어 발전이 없었다면 Docker도 이렇게 크지 못했다
Docker는 소프트웨어 혁신으로 기억되지만, 그 배경에는 하드웨어의 여유가 있다. CPU 코어 수가 늘고, 메모리가 커지고, SSD가 보편화되고, 네트워크 대역폭이 올라가면서 이미지를 자주 빌드하고 내려받는 방식이 현실적인 선택이 됐다.
컨테이너는 VM보다 가볍지만 공짜는 아니다. 이미지를 저장할 디스크가 필요하고, 레이어를 압축하고 풀 CPU가 필요하고, 레지스트리에서 이미지를 받을 네트워크가 필요하다. CI 서버는 수십 개의 이미지를 빌드하고 테스트 컨테이너를 동시에 띄운다. 이런 작업은 느린 디스크와 좁은 네트워크 위에서는 금방 병목이 된다.
SSD의 보급은 특히 체감이 크다. 이미지 레이어를 읽고 쓰고, 패키지 매니저가 수많은 작은 파일을 만들고, 테스트 컨테이너가 뜨고 지는 과정은 랜덤 I/O를 많이 만든다. HDD 기반 서버에서도 Docker는 동작하지만, 개발자가 기대하는 빠른 반복성과는 거리가 멀다.
멀티코어 CPU도 중요하다. 컨테이너가 가벼우면 한 머신에 더 많은 프로세스를 올리게 된다. 그만큼 스케줄링과 병렬 빌드가 중요해진다. BuildKit 같은 현대적인 빌드 시스템은 가능한 단계를 병렬화하고 캐시를 더 적극적으로 쓴다. 하드웨어가 받쳐주지 않으면 이런 장점은 절반만 보인다.
왜 Docker가 생태계를 장악했나
Docker의 승리는 단일 기술의 승리가 아니다. 적당한 시점에 적당한 추상화를 제공한 제품의 승리다.
리눅스 커널에는 namespace와 cgroups가 있었다. 서버 시장에는 가상화와 클라우드가 이미 자리를 잡았다. 개발팀은 배포 재현성 문제에 지쳐 있었다. 오픈소스 서버 소프트웨어는 늘어났고, 마이크로서비스라는 말이 퍼지며 한 애플리케이션이 여러 구성요소로 쪼개지고 있었다. CI/CD는 빌드 산출물을 명확히 요구했다. 이 모든 흐름이 한 방향을 가리켰다. 애플리케이션과 실행 환경을 함께 묶어 어디서나 비슷하게 실행하고 싶다는 방향이다.
Docker는 그 요구에 맞는 인터페이스를 만들었다.
docker build -t my-app .
docker run -p 8080:8080 my-app
이 두 줄은 단순하다. 뒤에는 namespace, cgroups, overlay filesystem, bridge network, NAT, 이미지 manifest, 레지스트리 인증 같은 요소가 숨어 있다. 사용자는 처음부터 그 모든 것을 알 필요가 없다. 쓰다 보면 천천히 배운다. 좋은 도구는 내부 복잡도를 숨기되, 필요할 때 내려가 볼 수 있는 계단을 남긴다. Docker는 그 계단을 꽤 잘 만들었다.
VM 중심 세계에서는 배포 단위가 서버에 가까웠다. Docker 이후에는 배포 단위가 프로세스와 애플리케이션 쪽으로 내려왔다. 이 변화가 클라우드와 잘 맞았다. 서버는 언제든 교체 가능한 자원이 되고, 애플리케이션은 이미지로 흘러다닌다. 운영자는 서버에 접속해 패키지를 손으로 고치기보다 새 이미지를 배포한다.
초보자가 잡아야 할 감각
Docker를 처음 배울 때 명령어를 외우는 것보다 이 감각을 먼저 잡는 편이 낫다.
컨테이너는 프로세스다. 다만 커널이 그 프로세스 주변에 별도의 세계를 만들어준다. 그 세계에는 파일시스템, 네트워크, 프로세스 목록, 자원 제한이 있다. 이미지는 그 세계를 만들기 위한 읽기 전용 재료 묶음이다. 컨테이너는 이미지를 실행한 결과이며, 쓰기 가능한 얇은 레이어를 위에 얹고 움직인다. 레지스트리는 이미지를 올리고 내려받는 저장소다. Dockerfile은 이미지를 만드는 레시피다. Compose는 여러 컨테이너를 한 번에 띄우는 로컬 오케스트레이션 파일이다.
이 정도 모델을 잡으면 명령어가 덜 흩어진다.
# 이미지 만들기
docker build -t demo-api .
# 컨테이너 실행
docker run --name demo -p 8000:8000 demo-api
# 실행 중인 컨테이너 보기
docker ps
# 로그 보기
docker logs demo
# 컨테이너 삭제
docker rm -f demo
여기서 docker build는 이미지를 만든다. docker run은 이미지로 컨테이너를 만든 뒤 실행한다. docker ps는 실행 중인 컨테이너를 보여준다. docker logs는 컨테이너의 표준 출력과 표준 에러를 보여준다. docker rm -f는 컨테이너를 강제로 지운다. 이미지와 컨테이너를 섞어 이해하면 계속 헷갈린다.
볼륨도 초반에 반드시 잡아야 한다. 컨테이너 안에 쓴 파일은 컨테이너 생명주기와 엮인다. DB 데이터를 컨테이너 내부 레이어에만 두면 컨테이너 삭제와 함께 날아갈 수 있다. 운영 데이터는 볼륨이나 외부 스토리지로 분리한다.
docker volume create pgdata
docker run -d \
--name pg \
-e POSTGRES_PASSWORD=pass \
-v pgdata:/var/lib/postgresql/data \
postgres:16
네트워크도 마찬가지다. 컨테이너는 기본적으로 격리된 네트워크 안에서 뜬다. 호스트의 포트와 연결하려면 -p 8080:80처럼 포트 매핑을 한다. Compose 안의 서비스끼리는 서비스 이름으로 통신한다. 그래서 로컬에서는 localhost로 되던 코드가 컨테이너 안에서는 다른 의미가 된다. 컨테이너 내부의 localhost는 호스트가 아니라 자기 자신이다.
Docker를 과신하면 생기는 문제
Docker는 환경 차이를 줄여주지만, 환경 문제를 삭제하지는 않는다. CPU 아키텍처가 다르면 이미지도 달라질 수 있다. linux/amd64에서 빌드한 이미지를 linux/arm64 환경에서 실행할 때 에뮬레이션이 끼거나 아예 실패할 수 있다. 네이티브 확장을 쓰는 Python 패키지, 특정 libc에 묶인 바이너리, GPU 드라이버처럼 호스트와 강하게 연결된 요소는 더 조심해야 한다.
보안도 자동으로 해결되지 않는다. 컨테이너 안에서 root로 실행하면 컨테이너 내부 root다. 설정을 잘못하면 호스트 자원에 위험한 권한을 줄 수 있다. Docker socket을 컨테이너에 마운트하는 패턴은 특히 강력한 권한을 넘긴다. /var/run/docker.sock에 접근할 수 있는 컨테이너는 호스트의 Docker 데몬을 조작할 수 있다. 편해서 쓰는 순간이 있지만, 운영 환경에서는 거의 항상 다시 생각해야 한다.
이미지 크기도 방치하면 커진다. 빌드 도구와 런타임 도구를 같은 이미지에 넣고, 패키지 캐시를 지우지 않고, 불필요한 파일을 복사하면 수백 MB에서 몇 GB까지 금방 간다. 멀티스테이지 빌드는 이 문제를 줄이는 좋은 방식이다.
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o app ./cmd/server
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=build /src/app ./app
CMD ["./app"]
첫 번째 스테이지에는 컴파일러가 있다. 두 번째 스테이지에는 실행 파일만 들어간다. 빌드 환경과 실행 환경을 분리하므로 최종 이미지가 작아지고 공격 표면도 줄어든다.
Docker 이후의 세계
요즘 운영 환경에서는 Docker라는 단어가 예전보다 덜 보일 때가 있다. Kubernetes 노드의 런타임은 containerd일 수 있고, 이미지는 OCI 표준을 따른다. Podman, Buildah, nerdctl 같은 도구도 있다. 그래도 Docker가 만든 사용성의 문법은 남았다. Dockerfile을 쓰고, 이미지를 빌드하고, 레지스트리에 푸시하고, 컨테이너로 실행한다는 흐름은 그대로다.
초보자가 Docker를 배울 때 제품 이름 하나에만 매달릴 필요는 없다. 아래 계층을 나눠 보면 오래 간다.
- 커널 기능: namespace, cgroups, mount, network stack
- 이미지 형식: 레이어, manifest, digest, OCI image
- 런타임: runc, containerd, Docker Engine
- 개발 도구: Docker CLI, Dockerfile, Compose
- 운영 계층: Kubernetes, ECS, Nomad 같은 오케스트레이션
처음부터 전부 알 필요는 없다. 다만 문제가 생겼을 때 어느 층의 문제인지 구분할 수 있어야 한다. 이미지 빌드가 실패한 것인지, 컨테이너 실행 명령이 틀린 것인지, 네트워크 매핑 문제인지, 애플리케이션 내부 설정 문제인지가 갈린다. Docker를 잘 쓰는 사람은 명령어를 많이 외운 사람보다 이 층을 빨리 가르는 사람에 가깝다.
최근에는 개발 환경에서도 컨테이너의 역할이 더 넓어졌다. Dev Container는 에디터의 개발 환경 자체를 컨테이너로 묶는다. CI는 테스트 DB를 컨테이너로 띄운다. 로컬 LLM이나 GPU 워크로드도 CUDA 런타임과 드라이버 호환성을 맞추기 위해 컨테이너 이미지를 쓴다. 이 영역에서는 호스트 드라이버와 컨테이너 런타임의 경계가 중요해진다.
Docker를 이해하면 단순히 docker run을 잘 치는 수준을 넘어서 현대 배포 시스템의 기본 문법을 읽게 된다. VM이 서버를 소프트웨어로 만들었다면, 컨테이너는 실행 환경을 배포 가능한 산출물로 만들었다. 다음에 더 파고들 부분은 containerd와 runc 사이의 경계다. docker run 한 줄이 실제로 어떤 프로세스 호출과 namespace 생성으로 내려가는지 보면 Docker의 마법이 꽤 많이 풀린다.