Skip to content
ioob.dev
Go back

Docker 입문 1편 — Docker란

· 10분 읽기
Docker 시리즈 (1/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

”내 컴퓨터에선 되는데” 라는 말의 무덤

개발자가 새 팀에 합류하면 의식처럼 겪는 일이 있다. 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를 제공한다.

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 등")]

구성 요소를 차례대로 짚어보자.

이 아키텍처가 얼핏 복잡해 보여도 핵심은 단순하다. CLI가 요청을 보내고, Daemon이 접수하고, containerd/runc가 실제로 컨테이너를 띄운다.

이미지와 컨테이너, 같지만 다른 것

Docker를 배울 때 가장 먼저 헷갈리는 용어가 이미지컨테이너다.

같은 이미지로 컨테이너를 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

이 한 줄이 내부에서 하는 일은 이렇다.

  1. hello-world라는 이미지를 로컬에서 찾는다
  2. 없으면 Docker Hub에서 pull한다
  3. 이미지를 기반으로 컨테이너를 만든다
  4. 컨테이너 안에서 정의된 프로그램을 실행한다 (이 경우 환영 메시지 출력)
  5. 프로그램이 끝나면 컨테이너도 종료된다

출력 메시지는 아래와 비슷하다.

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를 배운다는 것은 이 한계를 포함해서 어디까지가 Docker의 영역이고 어디서부터 다른 도구가 필요한지를 이해해가는 과정이기도 하다.

이 시리즈에서 걸어갈 길

이 글에서는 “왜 Docker인가”와 “Docker가 어떻게 생겼는가”만 봤다. 앞으로 여섯 편에서 다음을 차례로 파고든다.

한 편 한 편이 실전에서 바로 쓰는 내용을 담도록 풀어간다.


다음 편에서는 Docker 이미지의 내부를 해부한다. 이미지가 왜 “레이어”로 쌓이는지, 왜 같은 이미지를 두 번째 pull 받을 때는 순식간에 끝나는지, 이미지 용량을 줄이는 기초 기법은 무엇인지 살펴본다.

2편: 이미지와 레이어


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
네트워크 기초 7편 — 로드 밸런서와 프록시
Next Post
Docker 입문 2편 — 이미지와 레이어