Table of contents
- ”내 컴퓨터에선 되는데” 라는 말의 무덤
- VM과 컨테이너는 다르다
- 컨테이너의 실체는 리눅스 프로세스다
- Docker가 등장하기 전에도 컨테이너는 있었다
- Docker의 전체 아키텍처
- 이미지와 컨테이너, 같지만 다른 것
- 첫 컨테이너 실행해보기
- Docker가 해결하지 못하는 것
- 이 시리즈에서 걸어갈 길
”내 컴퓨터에선 되는데” 라는 말의 무덤
개발자가 새 팀에 합류하면 의식처럼 겪는 일이 있다. README를 열고, Java 버전을 맞추고, PostgreSQL을 설치하고, Redis를 받고, 환경변수를 세팅한다. 그러다 어딘가에서 에러가 뜬다. 동료가 와서 “아 그거 libssl 버전이 달라서 그래. 나도 겪었어.” 한 마디를 남기고 간다. 그렇게 오전이 사라진다.
좀 더 규모가 커지면 이런 일도 생긴다. 개발자 로컬에선 멀쩡한 앱이 QA 서버에만 올라가면 죽는다. 원인을 파보면 OS 버전 차이, 시스템 라이브러리의 마이너 버그, 파일 경로 대소문자 구분. 그 어느 것도 코드의 책임이 아닌데, 코드가 죽는다. “내 컴퓨터에선 되는데” 라는 말은 농담이 아니라 구조적 문제의 증상이다.
Docker는 이 문제를 정면으로 공격한다. 앱을 실행하는 데 필요한 모든 것 — 코드, 런타임, 시스템 라이브러리, 설정 — 을 하나의 패키지로 묶어서, 어디서든 똑같이 돌아가게 만들자는 아이디어다. 이 패키지를 컨테이너라 부르고, 이 생태계를 만든 회사이자 도구가 Docker다.
VM과 컨테이너는 다르다
Docker를 처음 배울 때 혼동하기 쉬운 게 가상머신(VM)과 컨테이너의 차이다. 둘 다 “격리된 환경”을 주는 것 같지만, 내부는 전혀 다르다.
flowchart LR
subgraph VM["가상머신(VM)"]
HW1["Hardware"] --> HOS1["Host OS"]
HOS1 --> HYP["Hypervisor"]
HYP --> GOS1["Guest OS 1"]
HYP --> GOS2["Guest OS 2"]
GOS1 --> APP1["App A"]
GOS2 --> APP2["App B"]
end
subgraph CT["컨테이너"]
HW2["Hardware"] --> HOS2["Host OS (커널 공유)"]
HOS2 --> DK["Container Runtime"]
DK --> C1["Container A"]
DK --> C2["Container B"]
C1 --> APPC1["App A"]
C2 --> APPC2["App B"]
end
VM은 하이퍼바이저 위에 독립된 OS 전체를 올린다. 각 VM은 자기 커널을 따로 갖는다. 그래서 격리 수준은 강력한데, 부팅에 수십 초가 걸리고 메모리도 GB 단위로 잡아먹는다. VM 하나 띄우는 것은 작은 컴퓨터 한 대를 사는 것과 비슷하다.
컨테이너는 다르다. 호스트 OS의 커널을 공유한다. 그 위에서 프로세스 수준의 격리만 건다. 커널을 통째로 올리지 않으니 이미지도 가볍고, 시작도 거의 즉시 이뤄진다. nginx 컨테이너 하나 띄우는 데 1초도 안 걸린다.
이 차이를 한 줄로 요약하면 이렇다. VM은 컴퓨터를 가상화하고, 컨테이너는 프로세스를 가상화한다.
컨테이너의 실체는 리눅스 프로세스다
컨테이너가 마법처럼 들리지만, 리눅스 관점에서 보면 그냥 좀 특별한 프로세스다. Docker가 새로 만든 기술이 아니라, 리눅스 커널이 오래 전부터 제공해온 기능 두 가지를 조합해서 만든 것이다.
namespace — 세상을 따로 보게 만든다
프로세스가 “자기가 혼자인 줄 알게” 만드는 기술이다. 리눅스 커널은 여러 종류의 namespace를 제공한다.
- PID namespace: 프로세스 ID 공간을 격리. 컨테이너 안의 PID 1은 호스트에선 PID 12345일 수 있다
- Network namespace: 네트워크 인터페이스를 격리. 컨테이너마다 자기
eth0을 갖는다 - Mount namespace: 파일시스템 마운트 포인트를 격리. 컨테이너는 자기만의
/를 본다 - UTS namespace: 호스트명을 격리. 컨테이너는 자기 호스트명을 가질 수 있다
- IPC namespace: 프로세스 간 통신(세마포어, 공유 메모리)을 격리
- User namespace: UID/GID 매핑을 격리. 컨테이너 안에선 root지만 호스트에선 일반 유저인 식
unshare 명령어로 namespace를 직접 만들어볼 수 있다. 아래 명령은 새 PID namespace 안에서 bash를 실행한다.
sudo unshare --fork --pid --mount-proc bash
# 컨테이너 안에 들어온 것처럼 보인다
ps aux
# PID USER TIME COMMAND
# 1 root 0:00 bash
# 5 root 0:00 ps aux
호스트에선 수백 개의 프로세스가 돌고 있는데, 이 쉘에서 ps를 치면 딱 두 개만 보인다. 이게 namespace다.
cgroups — 자원을 가둔다
namespace가 “시야”를 제한한다면, cgroups(control groups)는 “먹을 수 있는 양”을 제한한다. CPU, 메모리, 디스크 I/O, 네트워크 대역폭을 프로세스 그룹 단위로 제약할 수 있다.
# cgroup v2 환경에서 메모리 512MB 제한 예시
sudo mkdir /sys/fs/cgroup/mygroup
echo "536870912" | sudo tee /sys/fs/cgroup/mygroup/memory.max
echo $$ | sudo tee /sys/fs/cgroup/mygroup/cgroup.procs
이 명령이 하는 일은 단순하다. /sys/fs/cgroup/mygroup 아래 cgroup을 만들고, 메모리 상한을 512MB로 정하고, 현재 쉘 프로세스를 그 그룹에 집어넣는다. 이제 이 쉘과 자식 프로세스들은 512MB 이상 메모리를 쓰면 OOM으로 죽는다.
Docker가 하는 일은 결국 namespace로 격리하고, cgroups로 자원 제한을 건 프로세스를 실행하는 것이다. 마법이 아니라 리눅스 커널 기능의 조합이다.
Docker가 등장하기 전에도 컨테이너는 있었다
컨테이너 개념은 Docker가 처음 만든 게 아니다. 2000년 FreeBSD의 jail, 2005년 Solaris의 Zones, 2008년 리눅스의 LXC(Linux Containers) 같은 기술이 이미 있었다. 구글은 2006년부터 cgroups를 만들어 내부 시스템에서 컨테이너를 써왔다.
그런데 이런 기술은 너무 복잡했다. LXC 하나 세팅하려면 수십 줄의 설정 파일을 쓰고, 리눅스 시스템 내부를 꽤 깊이 이해해야 했다. 일반 개발자가 쉽게 쓸 수 있는 물건이 아니었다.
Docker는 2013년에 LXC를 감싸서 CLI로 쉽게 쓰게 해주는 도구로 출발했다. docker run ubuntu라는 명령 하나로, 예전엔 반나절 걸리던 작업이 끝났다. 이후 Docker는 자체 런타임(libcontainer, 나중에 runc)을 만들어 LXC 의존을 제거했고, 이미지 포맷을 정의하고, 레지스트리를 만들어 이미지 공유를 쉽게 했다. 컨테이너 기술의 대중화는 Docker 이전과 이후로 나뉜다고 해도 과하지 않다.
Docker의 전체 아키텍처
Docker를 설치하고 docker run hello-world를 치면 보이지 않는 곳에서 꽤 많은 일이 벌어진다. 큰 그림부터 보자.
flowchart TB
USER["개발자 / CI"] -->|docker CLI| CLI[docker CLI]
CLI -->|REST API via socket| DAEMON[Docker Daemon<br/>dockerd]
subgraph HOST["Docker Host"]
DAEMON --> CONTAINERD[containerd]
CONTAINERD --> RUNC[runc]
RUNC --> C1["컨테이너 A<br/>(namespace + cgroups)"]
RUNC --> C2["컨테이너 B"]
DAEMON --> IMG[("로컬 이미지 저장소")]
DAEMON --> NET[("네트워크 / 볼륨")]
end
DAEMON -->|pull/push| REG[("Docker Registry<br/>Docker Hub, ECR 등")]
구성 요소를 차례대로 짚어보자.
- Docker CLI: 터미널에서 치는
docker명령이다. 사실상 REST 클라이언트에 가깝다. 받은 명령을 API 요청으로 바꿔 Docker Daemon에 던진다 - Docker Daemon (
dockerd): 호스트에서 항상 떠 있는 프로세스. 이미지, 컨테이너, 볼륨, 네트워크 전반을 관리한다. 기본적으로/var/run/docker.sock유닉스 소켓으로 요청을 받는다 - containerd: 컨테이너의 실행/생명주기를 담당하는 런타임. 원래 Docker 내부 컴포넌트였다가 CNCF로 기부돼 독립된 프로젝트가 됐다
- runc: 실제로 컨테이너 프로세스를 띄우는 최하위 런타임. OCI(Open Container Initiative) 표준을 구현한다. namespace와 cgroups를 세팅하는 “진짜 일꾼”이다
- Docker Registry: 이미지 저장소. Docker Hub이 대표적이고, AWS ECR·GCR·GitHub Container Registry·사내 Harbor 같은 여러 구현이 있다
이 아키텍처가 얼핏 복잡해 보여도 핵심은 단순하다. CLI가 요청을 보내고, Daemon이 접수하고, containerd/runc가 실제로 컨테이너를 띄운다.
이미지와 컨테이너, 같지만 다른 것
Docker를 배울 때 가장 먼저 헷갈리는 용어가 이미지와 컨테이너다.
- 이미지(Image): 실행에 필요한 모든 것을 담은 읽기 전용 템플릿. 클래스에 비유할 수 있다
- 컨테이너(Container): 이미지를 기반으로 실제 실행되는 인스턴스. 객체에 해당한다
같은 이미지로 컨테이너를 100개 띄울 수도 있고, 각 컨테이너는 독립된 파일시스템과 네트워크를 갖는다.
# 이미지 받기
docker pull nginx:1.27
# 이미지로 컨테이너 띄우기 — 같은 이미지로 여러 개도 가능
docker run -d --name web1 -p 8080:80 nginx:1.27
docker run -d --name web2 -p 8081:80 nginx:1.27
# 실행 중인 컨테이너 확인
docker ps
docker pull은 레지스트리에서 이미지를 내려받고, docker run은 그 이미지를 바탕으로 컨테이너 프로세스를 실행한다. 두 명령의 관계를 머릿속에 잡아두면 이후의 명령들이 훨씬 편해진다.
첫 컨테이너 실행해보기
설명만 이어가면 지루하니 한 번 돌려보자. Docker Desktop이나 Docker Engine이 설치된 환경이라고 가정한다.
docker run hello-world
이 한 줄이 내부에서 하는 일은 이렇다.
hello-world라는 이미지를 로컬에서 찾는다- 없으면 Docker Hub에서
pull한다 - 이미지를 기반으로 컨테이너를 만든다
- 컨테이너 안에서 정의된 프로그램을 실행한다 (이 경우 환영 메시지 출력)
- 프로그램이 끝나면 컨테이너도 종료된다
출력 메시지는 아래와 비슷하다.
Hello from Docker!
This message shows that your installation appears to be working correctly.
...
이 메시지를 본 순간부터 Docker가 당신 머신에서 돌고 있다는 뜻이다. docker ps -a로 방금 실행된 컨테이너 기록을 확인할 수 있다.
docker ps -a
# CONTAINER ID IMAGE COMMAND CREATED STATUS
# 3f2b9e... hello-world "/hello" 10 seconds ago Exited (0)
컨테이너는 프로그램이 끝나면 Exited 상태로 남는다. 삭제하지 않는 한 디스크에 계속 존재한다. 이 생명주기는 4편에서 본격적으로 파헤친다.
Docker가 해결하지 못하는 것
Docker가 만능이라고 착각하기 쉬운데, 한계도 분명하다.
- 리눅스 커널을 공유한다는 사실: 윈도우 컨테이너를 리눅스 호스트에서 직접 돌릴 수 없다 (반대도 마찬가지). Docker Desktop이 윈도우/맥에서 돌아가는 건 내부적으로 경량 리눅스 VM을 깔아두기 때문이다
- 보안 격리는 VM만큼 강하지 않다: 커널을 공유하기 때문에, 커널 취약점이 나오면 컨테이너 탈출이 이론적으로 가능하다. 프로덕션에선
gVisor,Kata Containers같은 보조 격리 기술을 쓰기도 한다 - 애플리케이션의 상태는 컨테이너가 해결하지 않는다: DB 데이터나 업로드 파일 같은 영속 데이터는 볼륨으로 별도 관리해야 한다 (5편에서 다룬다)
- 여러 서버에 걸친 오케스트레이션은 Docker 단독으론 부족하다: Docker Swarm이 있긴 하지만, 현실적으로 Kubernetes가 사실상의 표준이다
Docker를 배운다는 것은 이 한계를 포함해서 어디까지가 Docker의 영역이고 어디서부터 다른 도구가 필요한지를 이해해가는 과정이기도 하다.
이 시리즈에서 걸어갈 길
이 글에서는 “왜 Docker인가”와 “Docker가 어떻게 생겼는가”만 봤다. 앞으로 여섯 편에서 다음을 차례로 파고든다.
- 2편 이미지와 레이어: 이미지가 어떻게 쌓이고, 어떻게 캐싱되고, 어떻게 용량을 줄이는지
- 3편 Dockerfile: 이미지를 직접 빌드하는 문법과 철학
- 4편 컨테이너 생명주기: 실행·중지·재시작·종료 시그널의 모든 것
- 5편 볼륨과 데이터: 컨테이너가 죽어도 살아남는 데이터
- 6편 네트워크: 컨테이너 사이, 컨테이너와 외부 사이의 통신
한 편 한 편이 실전에서 바로 쓰는 내용을 담도록 풀어간다.
다음 편에서는 Docker 이미지의 내부를 해부한다. 이미지가 왜 “레이어”로 쌓이는지, 왜 같은 이미지를 두 번째 pull 받을 때는 순식간에 끝나는지, 이미지 용량을 줄이는 기초 기법은 무엇인지 살펴본다.




Loading comments...