Part 1. Docker 기본 과정

Docker 기본 명령어

학습 목표

  • 이미지와 컨테이너의 개념과 관계를 설명할 수 있다.

  • 컨테이너의 생명주기(생성·실행·중지·삭제)를 이해할 수 있다.

  • Docker 컨테이너의 생성·실행·중지·삭제 명령어를 사용할 수 있다.

  • 컨테이너에 접속하고, 로그와 변경 내역으로 문제의 원인을 찾을 수 있다.

  • 네트워크와 볼륨을 생성하고 관리할 수 있다.

  • 이미지를 검색·다운로드·삭제하고 실습 흔적을 정리할 수 있다.

이 장에서는 이미지를 만들지 않는다. 명령어 하나하나를 그 명령어가 가장 잘 드러나는 기성 이미지로 배운다. Dockerfile 을 쓰고 docker build 로 내 이미지를 만드는 것은 이미지 생성 및 관리에서 시작한다.

0. 실습 준비 — 이미지 미리 받아두기

이 장의 실습은 아래 7개 이미지를 쓴다. 수업을 시작하기 전에 미리 받아두자.

for img in hello-world alpine nginx:alpine redis:alpine mysql:8.4 ubuntu:24.04 traefik/whoami; do
  docker image pull $img
done

PowerShell 에서는 이렇게 한다.

foreach ($img in "hello-world","alpine","nginx:alpine","redis:alpine","mysql:8.4","ubuntu:24.04","traefik/whoami") {
  docker image pull $img
}

강의실·사내망에서 20명이 동시에 받으면 여기서 수업 시간이 다 간다. 공유 회선 하나로 같은 이미지를 동시에 끌어오기 때문이다. 실제 용량은 다음과 같다.

이미지

내려받는 양

디스크 사용량

hello-world

9.49 kB

25.9 kB

alpine

3.93 MB

13 MB

traefik/whoami

5.17 MB

19.2 MB

nginx:alpine

27.2 MB

94.4 MB

ubuntu:24.04

31.7 MB

119 MB

redis:alpine

39.5 MB

160 MB

mysql:8.4

255 MB

1.12 GB

합계

약 360 MB

약 1.5 GB

mysql:8.4 하나가 나머지를 다 합친 것보다 크다. 이것만이라도 미리 받아두면 실습이 끊기지 않는다.

로그인하지 않으면 Docker Hub 가 내려받기 횟수를 막는다. 2025년 4월부터 로그인하지 않은 사용자는 공인 IP 하나당 6시간에 100회까지만 받을 수 있다. 횟수는 사람이 아니라 IP 로 센다는 점이 문제다. 강의실은 수강생 전원이 공유기 하나, 즉 IP 하나로 나간다. 20명이 위 7개를 받으면 140회라 중간부터 전원이 막힌다.

toomanyrequests: You have reached your unauthenticated pull rate limit.

무료 Docker Hub 계정으로 로그인하면 계정마다 6시간에 200회로 따로 센다. 수업 전에 각자 hub.docker.com 에 가입하고 로그인해 둔다.

  • Docker Desktop 오른쪽 위의 Sign in 으로 로그인하거나

  • 터미널에서 docker login 을 실행해 브라우저 인증을 마친다.

계정은 이미지 생성 및 관리에서 이미지를 올릴 때도 쓰므로 어차피 필요하다. 정확한 한도는 바뀔 수 있으니 Docker Hub 사용량 문서에서 확인한다.

Apple Silicon(M 시리즈) 도 그대로 된다. 위 7개 이미지는 모두 arm64/linux 를 함께 제공하므로 --platform 을 붙일 필요가 없다. 혹시 mysql:8.4 에서 아키텍처 관련 문제가 생기면 mariadb:11 로 바꿔도 이 장의 명령은 거의 그대로 쓸 수 있다(mysql 명령이 mariadb 로 바뀌는 정도).

1. 명령어를 배우기 전에 — 이미지와 컨테이너

Docker 명령어를 제대로 이해하려면 이미지(Image) 와 컨테이너(Container) 가 무엇인지, 그리고 둘의 관계가 어떻게 되는지를 먼저 알아야 한다.

1-1. 이미지 (Image)

이미지는 컨테이너를 만들기 위한 읽기 전용 템플릿이다. 마치 붕어빵 틀처럼, 하나의 이미지로 동일한 컨테이너를 몇 개든 찍어낼 수 있다.

  • 레이어 구조: 이미지는 여러 레이어가 쌓인 구조다. 베이스 OS 위에 런타임, 라이브러리, 앱 코드 순으로 레이어가 추가된다.

  • 불변성(Immutable): 한 번 만들어진 이미지는 변경되지 않는다. 새로운 변경이 필요하면 새 이미지를 빌드한다.

  • 재사용성: 레이어를 공유하므로 저장 공간을 효율적으로 사용한다. 예를 들어 Ubuntu 베이스 레이어는 여러 이미지가 함께 사용한다.

  • 출처: Dockerfile을 작성해 직접 빌드하거나, Docker Hub 등 레지스트리에서 공개 이미지를 내려받는다.

1-2. 컨테이너 (Container)

컨테이너는 이미지를 기반으로 실제로 실행되는 인스턴스다. 이미지가 클래스(Class)라면 컨테이너는 그 클래스로 만든 객체(Instance)에 해당한다.

  • 쓰기 레이어: 컨테이너는 이미지 레이어 위에 얇은 쓰기 가능 레이어를 하나 추가한다. 컨테이너 안에서 파일을 생성하거나 수정하면 이 레이어에만 기록된다.

  • 독립성: 각 컨테이너는 완전히 독립된 환경에서 실행된다. 같은 이미지로 만든 컨테이너 A와 B는 서로 영향을 주지 않는다.

  • 휘발성: 컨테이너를 삭제하면 쓰기 레이어의 데이터는 사라진다. 데이터를 유지하려면 볼륨(Volume) 을 사용한다.

  • 경량성: VM처럼 게스트 OS 전체를 포함하지 않고 호스트 커널을 공유하므로 매우 가볍고 시작 속도가 빠르다.

1-3. 이미지와 컨테이너의 관계

구분

이미지

컨테이너

상태

정적 (파일)

동적 (프로세스)

변경 가능 여부

읽기 전용

쓰기 가능 (쓰기 레이어)

비유

클래스(Class) / 붕어빵 틀

인스턴스(Instance) / 붕어빵

삭제 시

이미지 파일 제거

실행 환경 제거 (이미지는 유지)

생성 방법

docker build / docker pull

docker run / docker create

1-4. 컨테이너 생명주기 (Lifecycle)

컨테이너는 이미지로부터 만들어져 여러 상태를 거친다. 이 흐름을 이해하면 이후에 배울 모든 명령어가 자연스럽게 연결된다.

상태

설명

관련 명령어

Created

컨테이너가 생성되었으나 아직 실행되지 않은 상태

docker create

Running

프로세스가 실행 중인 상태

docker run, docker start

Stopped

실행이 중지된 상태. 다시 시작할 수 있음

docker stop, docker kill

Removed

컨테이너가 완전히 삭제된 상태

docker rm

docker run = docker create + docker start

가장 자주 쓰이는 명령어로, 이미지에서 컨테이너 생성과 실행을 한 번에 처리한다.

모든 Docker 명령어는 docker 키워드로 시작한다. 명령어에 대한 자세한 도움말은 --help 옵션으로 확인할 수 있다. 예: docker run --help

2. 구형 명령어 vs 관리 명령어 (Management Commands)

Docker 1.13 (2017년) 부터 명령어 체계가 개편되어 관리 명령어(Management Commands) 방식이 추가됐다. 이전 방식과 새 방식 모두 현재까지 사용 가능하며, 공식 문서는 새 방식을 권장한다.

2-1. 명령어 구조 비교

구분

형식

예시

구형 (Legacy)

docker <명령어>

docker run, docker ps, docker rmi

관리 명령어 (신형)

docker <객체> <동작>

docker container run, docker container ls, docker image rm

2-2. 왜 두 가지 방식이 생겼나?

Docker 초기에는 docker run, docker ps 같은 단축 명령어만 있었다. 기능이 늘어나면서 docker inspect가 컨테이너도 되고 이미지도 되는 등 명령어 대상이 모호해졌다. 이를 해결하기 위해 객체(container, image, volume, network)를 명시적으로 지정하는 구조로 개편됐다.

어떤 방식을 써야 할까?

  • 실무에서는 타이핑이 짧은 구형 방식을 여전히 많이 사용한다.

  • 공식 문서·CI/CD 스크립트·팀 컨벤션에서는 명시적인 관리 명령어 방식이 선호된다.

  • 두 방식을 모두 읽고 쓸 수 있어야 오픈소스 코드나 팀 코드를 이해할 수 있다.

2-3. 주요 명령어 대응표

구형 (Legacy)

관리 명령어 (신형)

설명

docker run

docker container run

컨테이너 생성 및 실행

docker ps

docker container ls

컨테이너 목록 조회

docker ps -a

docker container ls -a

전체 컨테이너 목록

docker stop

docker container stop

컨테이너 중지

docker start

docker container start

컨테이너 시작

docker restart

docker container restart

컨테이너 재시작

docker rm

docker container rm

컨테이너 삭제

docker exec

docker container exec

컨테이너 내 명령 실행

docker logs

docker container logs

컨테이너 로그 확인

docker inspect

docker container inspect

컨테이너 상세 정보

docker diff

docker container diff

컨테이너 변경 파일 목록

docker images

docker image ls

이미지 목록 조회

docker pull

docker image pull

이미지 내려받기

docker rmi

docker image rm

이미지 삭제

docker build

docker image build

이미지 빌드

docker network ls

docker network ls

네트워크 목록 (동일)

docker volume ls

docker volume ls

볼륨 목록 (동일)

3. 컨테이너 관련 명령어

3-1. docker run — 컨테이너 생성 및 실행

이미지를 기반으로 새 컨테이너를 생성하고 실행한다. 이미지가 로컬에 없으면 Docker Hub에서 자동으로 내려받는다.

# 구형 (Legacy)
docker run [OPTIONS] IMAGE [COMMAND] [ARG...]

# 신형 (Management Commands — Docker 1.13+)
docker container run [OPTIONS] IMAGE [COMMAND] [ARG...]

주요 옵션

-d, --detach       백그라운드(데몬) 모드로 실행
-p, --publish      호스트:컨테이너 포트 매핑  예) -p 8080:80
-v, --volume       볼륨 또는 호스트 디렉토리 마운트  예) -v mydata:/data
-e, --env          환경 변수 설정  예) -e MYSQL_ROOT_PASSWORD=1234
--name             컨테이너 이름 지정
--rm               컨테이너 종료 시 자동 삭제
-it                인터랙티브 + TTY 모드 (쉘로 들어갈 때 사용)
--network          연결할 네트워크 지정

이미지 뒤에 명령을 붙이면 이미지의 기본 명령(CMD)을 덮어쓴다. docker run alpine sh 의 sh 가 그것이다. 옵션은 이미지 이름 앞, 컨테이너 안에서 실행할 명령은 이미지 이름 뒤에 온다. 이 순서가 바뀌면 옵션이 컨테이너 안 명령의 인자로 넘어가 엉뚱하게 동작한다.

🔬 실습 — Step 1 : hello-world — 컨테이너의 한살이

가장 작은 컨테이너로 시작한다. 이 이미지는 메시지 한 번 출력하고 바로 끝난다.

docker run hello-world
Unable to find image "hello-world:latest" locally
latest: Pulling from library/hello-world
...
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

첫 줄이 중요하다. 로컬에 이미지가 없으면 run 이 알아서 pull 을 먼저 한다. 두 번째로 실행하면 이 줄이 사라진다.

이제 컨테이너를 찾아보자.

docker container ls
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS   PORTS   NAMES

아무것도 없다. 방금 분명히 실행했는데 목록이 비어 있다. -a 를 붙여 보자.

docker container ls -a
CONTAINER ID   IMAGE         COMMAND    CREATED        STATUS                     NAMES
0668ec5d0f1d   hello-world   "/hello"   1 second ago   Exited (0) 1 second ago    crazy_brattain

있다. 상태가 Exited (0) 이다.

"컨테이너가 안 보인다" 와 "컨테이너가 없다" 는 다르다.

docker container ls 는 실행 중인 컨테이너만 보여준다. 컨테이너 안의 프로세스가 끝나면 컨테이너도 멈추지만, 사라지지는 않는다. 멈춘 컨테이너는 디스크를 차지한 채 남아 있다가 docker container rm 으로 지워야 없어진다.

docker run 은 docker create(만들기) + docker start(시작)를 한 번에 하는 명령이다. hello-world 는 메시지를 찍는 순간 할 일이 끝나므로 create → start → 프로세스 종료 → Exited 까지 눈 깜짝할 사이에 지나간다.

--name 을 주지 않아 Docker 가 crazy_brattain 같은 이름을 자동으로 붙였다. 실습에서는 항상 --name 으로 이름을 정해 주는 편이 낫다.

이제 지운다. <id> 자리에는 위에서 본 CONTAINER ID 앞 몇 글자만 써도 된다.

docker container rm <id>
docker container ls -a      # 목록에서 사라졌다

3-2. docker ps — 컨테이너 목록 조회

# 구형
docker ps          # 실행 중인 컨테이너만
docker ps -a       # 중지된 컨테이너 포함 전체

# 신형
docker container ls       # 실행 중인 컨테이너만
docker container ls -a    # 중지된 컨테이너 포함 전체

docker ps는 "process status"의 줄임말로 Unix의 ps 명령어에서 따온 이름이다. 신형의 docker container ls가 의미를 더 명확하게 표현한다.

PORTS 컬럼 읽는 법: 0.0.0.0:8080->80/tcp

  • 왼쪽 0.0.0.0:8080 = 호스트의 모든 IP에서 8080 포트

  • 오른쪽 80/tcp = 컨테이너 내부 80 포트

  • -> = 호스트 → 컨테이너 방향으로 포워딩됨

뒤에 [::]:8080->80/tcp 가 함께 붙는 경우가 있는데, 같은 매핑의 IPv6 쪽 표기다. 0.0.0.0 이 IPv4 의 모든 주소라면 [::] 는 IPv6 의 모든 주소를 뜻한다. Docker 29 는 두 줄을 함께 보여주므로 매핑이 두 개인 것이 아니라 하나를 두 프로토콜로 표시한 것이다.

3-3. docker stop / start / restart — 컨테이너 상태 제어

# 구형
docker stop <컨테이너명>      # graceful shutdown (SIGTERM 전송 후 10초 뒤 SIGKILL)
docker kill <컨테이너명>      # 즉시 강제 종료 (SIGKILL 바로 전송)
docker start <컨테이너명>     # 중지된 컨테이너 시작
docker restart <컨테이너명>   # 재시작

# 신형
docker container stop <컨테이너명>
docker container kill <컨테이너명>
docker container start <컨테이너명>
docker container restart <컨테이너명>

docker stop vs docker kill

  • docker stop: 컨테이너 프로세스에 SIGTERM을 보내 정상 종료할 시간을 준다 (기본 10초). 시간 초과 시 SIGKILL로 강제 종료. DB처럼 데이터를 안전하게 플러시해야 하는 서비스에 사용한다.

  • docker kill: SIGKILL을 즉시 보내 강제 종료. 응답 없는 컨테이너를 즉시 중단할 때 사용한다.

🔬 실습 — Step 2 : alpine — 인터랙티브 실행과 되살리기

Alpine Linux 는 13 MB 짜리 아주 작은 리눅스다. 20명이 동시에 받아도 부담이 없어 실습용으로 자주 쓴다.

docker run -it --name my-alpine alpine sh

프롬프트가 / # 로 바뀐다. 여기부터는 컨테이너 안이다.

# ── 컨테이너 안에서 ──
echo "hello" > /tmp/test.txt
cat /tmp/test.txt
# hello
exit

exit 를 치는 순간 호스트 프롬프트로 돌아온다. 상태를 보자.

docker container ls -a
CONTAINER ID   IMAGE     COMMAND   CREATED          STATUS                     NAMES
97097dd2cadd   alpine    "sh"      10 seconds ago   Exited (0) 3 seconds ago   my-alpine

exit 는 쉘을 끝낸 것이고, 쉘이 끝나면 컨테이너도 멈춘다. 컨테이너는 "안에서 돌고 있는 프로세스"가 살아 있는 동안만 살아 있다. sh 하나만 돌고 있었으니 그것이 끝나면 더 할 일이 없다.

멈춘 컨테이너는 되살릴 수 있다.

docker container start my-alpine
docker container ls          # 다시 Up 상태다

-it 로 만든 컨테이너라서 start 만 해도 살아 있다. 터미널(TTY)이 붙어 있어 sh 가 입력을 기다리며 대기하기 때문이다. -it 없이 만든 컨테이너를 start 하면 sh 가 바로 EOF 를 만나 다시 종료된다.

되살린 컨테이너 안으로 다시 들어가 아까 만든 파일을 확인한다.

docker container exec -it my-alpine sh
# ── 컨테이너 안에서 ──
cat /tmp/test.txt
# hello      ← 남아 있다
exit

파일이 그대로 있다. 컨테이너를 멈췄다 켜는 것은 쓰기 레이어를 지우지 않기 때문이다. 지우면 어떻게 되는지는 Step 6 에서 본다.

exec 와 attach 의 차이

  • docker container exec -it my-alpine sh — 컨테이너 안에 새 프로세스(새 쉘)를 하나 더 띄운다. 여기서 exit 해도 원래 돌던 프로세스는 그대로라 컨테이너가 멈추지 않는다.

  • docker container attach my-alpine — 원래 돌고 있던 프로세스의 입출력에 다시 연결한다. 여기서 exit 하면 그 프로세스가 끝나므로 컨테이너도 멈춘다.

실무에서 쓰는 것은 거의 언제나 exec 다.

정리한다.

docker container rm -f my-alpine

3-4. docker rm — 컨테이너 삭제

# 구형
docker rm <컨테이너명>        # 삭제 (먼저 stop 필요)
docker rm -f <컨테이너명>     # 강제 삭제 (실행 중이어도 가능)

# 신형
docker container rm <컨테이너명>
docker container rm -f <컨테이너명>

# 중지된 컨테이너 일괄 삭제 (구형·신형 동일)
docker container prune

docker container prune 은 멈춰 있는 컨테이너를 전부 지운다. 내 실습 컨테이너만 골라 지우는 명령이 아니다. 다른 작업에 쓰던 컨테이너가 같은 PC 에 멈춰 있다면 그것까지 사라지므로, 확실하지 않을 때는 이름을 하나씩 적어 docker container rm 을 쓰는 편이 안전하다.

3-5. docker logs — 컨테이너 로그 확인

컨테이너 안 프로세스가 표준 출력·표준 에러로 찍은 것을 보여준다.

# 구형
docker logs <컨테이너명>            # 전체 로그 출력
docker logs -f <컨테이너명>         # 실시간 스트리밍
docker logs --tail 20 <컨테이너명>  # 마지막 20줄만
docker logs --since 5m <컨테이너명> # 최근 5분치만

# 신형
docker container logs <컨테이너명>

🔬 실습 — Step 3 : nginx:alpine — 백그라운드 실행, 포트 매핑, 로그, 바인드 마운트

이 Step 은 브라우저 새로고침 한 번으로 포트 매핑과 마운트를 눈으로 증명한다.

① 백그라운드로 띄우고 포트를 연다

docker run -d -p 8080:80 --name web nginx:alpine
docker container ls

-d 는 백그라운드 실행이다. hello-world 나 alpine 과 달리 Nginx 는 스스로 끝나지 않는 프로세스라 계속 살아 있다.

브라우저에서 http://localhost:8080 을 연다.

Nginx 기본 페이지

Welcome to nginx! — 컨테이너 안 80 번이 호스트 8080 번으로 연결됐다

-p 8080:80 의 왼쪽이 호스트, 오른쪽이 컨테이너다. Nginx 이미지는 80 번으로 서비스하도록 만들어져 있고, 호스트에서는 8080 번으로 받겠다는 뜻이다. 왼쪽 숫자만 바꾸면 같은 이미지를 몇 개든 다른 포트로 띄울 수 있다.

② 로그를 실시간으로 본다

터미널을 하나 더 열고 아래를 실행한 뒤, 브라우저에서 새로고침을 몇 번 해 본다.

docker container logs -f web
172.17.0.1 - - [19/Sep/2026:07:41:27 +0000] "GET / HTTP/1.1" 200 896 "-" "curl/8.18.0" "-"
172.17.0.1 - - [19/Sep/2026:07:41:27 +0000] "GET / HTTP/1.1" 200 896 "-" "curl/8.18.0" "-"

새로고침할 때마다 줄이 하나씩 늘어난다. Ctrl+C 로 빠져나온다.

③ 내 파일을 보여주게 만든다 — 바인드 마운트

호스트에 html 폴더를 만들고 그 안에 index.html 을 쓴다.

mkdir html
<!-- html/index.html -->
<!doctype html>
<html lang="ko">
  <head><meta charset="utf-8"><title>내 첫 페이지</title></head>
  <body>
    <h1>안녕하세요</h1>
    <p>이 파일은 호스트에 있습니다.</p>
  </body>
</html>

컨테이너를 지우고, 이번에는 그 폴더를 Nginx 의 문서 루트에 연결해서 다시 띄운다.

docker container rm -f web

# macOS / Linux
docker run -d -p 8080:80 -v "$(pwd)/html":/usr/share/nginx/html --name web nginx:alpine
# Windows PowerShell
docker run -d -p 8080:80 -v "${PWD}/html:/usr/share/nginx/html" --name web nginx:alpine

새로고침하면 내가 만든 페이지가 보인다.

바인드 마운트로 내 파일이 서비스되는 화면

이미지를 다시 만들지 않고 호스트 폴더를 연결해 내용을 바꿨다

이제 호스트에서 html/index.html 의 문구를 고치고 저장한 뒤 새로고침한다. 컨테이너를 다시 띄우지 않아도 즉시 반영된다. 호스트의 폴더를 컨테이너 안 경로에 그대로 연결했기 때문이다. 이것을 바인드 마운트라고 한다.

Windows 에서 Git Bash 로 실행하면 조용히 실패한다. Git Bash(MSYS)는 슬래시로 시작하는 인자를 Windows 경로로 바꿔 버린다. 에러는 나지 않고, 브라우저에는 Nginx 기본 페이지가 그대로 보인다. 마운트가 엉뚱한 곳에 걸렸기 때문이다.

docker container inspect web --format='{{json .Mounts}}'

출력의 Destination 이 /usr/share/nginx/html 이 아니라 \Program Files\Git\usr\share\nginx\html 처럼 나오면 이 문제다. 해결 방법은 셋 중 하나다.

  • PowerShell 을 쓴다 (권장) — 경로 변환이 없다.

  • 명령 앞에 MSYS_NO_PATHCONV=1 을 붙인다.

  • 컨테이너 쪽 경로를 슬래시 두 개로 쓴다 — //usr/share/nginx/html

바인드 마운트 vs 볼륨 — 바인드 마운트는 내가 아는 호스트 폴더를 그대로 연결하므로 탐색기로 열어 편집할 수 있다. 개발 중 소스를 즉시 반영할 때 쓴다. 반대로 데이터베이스 데이터처럼 Docker 가 관리해 주면 되는 데이터는 다음 Step 에서 쓸 볼륨이 맞다.

정리한다.

docker container rm -f web

3-6. docker exec — 실행 중인 컨테이너에 명령 실행

# 구형
docker exec [OPTIONS] CONTAINER COMMAND [ARG...]

# 신형
docker container exec [OPTIONS] CONTAINER COMMAND [ARG...]
# 컨테이너 안에 쉘을 띄워 들어가기
docker container exec -it <컨테이너명> sh      # alpine 계열
docker container exec -it <컨테이너명> bash    # debian/ubuntu 계열

# 쉘 없이 명령 하나만 실행하고 결과 받기
docker container exec <컨테이너명> ls /app

🔬 실습 — Step 4 : redis:alpine — exec 의 진가와 볼륨

exec 가 실무에서 가장 많이 쓰이는 장면은 컨테이너 안에 들어 있는 CLI 도구를 쓰는 것이다. Redis 이미지에는 redis-cli 가 들어 있다.

① 컨테이너 안의 CLI 로 들어간다

docker run -d --name cache redis:alpine
docker container exec -it cache redis-cli

프롬프트가 127.0.0.1:6379> 로 바뀐다. 여기부터는 Redis 안이다.

127.0.0.1:6379> SET visit 1
OK
127.0.0.1:6379> INCR visit
(integer) 2
127.0.0.1:6379> GET visit
"2"
127.0.0.1:6379> exit

호스트에 Redis 를 설치한 적이 없는데 redis-cli 를 쓰고 있다는 점이 중요하다. 도구는 이미지 안에 들어 있다.

② 컨테이너를 지우면 데이터도 사라진다

docker container rm -f cache
docker run -d --name cache redis:alpine
docker container exec -it cache redis-cli GET visit
(nil)

사라졌다. Redis 가 저장한 파일이 컨테이너의 쓰기 레이어에 있었기 때문이다.

③ 볼륨을 붙이면 살아남는다

docker container rm -f cache
docker volume create redis-data

docker run -d --name cache -v redis-data:/data redis:alpine redis-server --save 60 1
docker container exec -it cache redis-cli SET visit 42
docker container exec -it cache redis-cli SHUTDOWN SAVE

SHUTDOWN SAVE 는 저장한 뒤 서버를 끄라는 명령이다. 서버가 죽으면서 연결이 끊기므로 화면에 아무것도 안 나오거나 오류처럼 보일 수 있는데, 정상이다. docker container ls -a 로 보면 Exited (0) 이다.

이미지 이름 뒤에 붙인 redis-server --save 60 1 은 이미지의 기본 명령을 덮어쓴 것이다. "60초 안에 1건 이상 바뀌면 파일로 저장하라"는 뜻이다.

볼륨 안에 파일이 실제로 생겼는지 임시 컨테이너로 확인할 수 있다.

docker run --rm -v redis-data:/data alpine ls -l /data
-rw-------    1 999      1000           103 Sep 19 07:42 dump.rdb

이제 컨테이너를 지우고 같은 볼륨으로 새로 띄운다.

docker container rm -f cache
docker run -d --name cache -v redis-data:/data redis:alpine
docker container exec -it cache redis-cli GET visit
"42"

살아남았다. 기동 로그를 보면 파일에서 읽어 들인 것이 보인다.

docker container logs cache
# 1:M 19 Sep 2026 07:42:24.513 * DB loaded from disk: 0.000 seconds

컨테이너는 지워도 볼륨은 남는다. 데이터를 컨테이너 바깥(볼륨)에 두면 컨테이너를 자유롭게 지우고 다시 만들 수 있다. 버전을 올릴 때 이미지만 바꿔 끼우는 배포 방식이 가능한 이유가 여기에 있다.

컨테이너만 정리한다. 볼륨은 바로 다음 절에서 한 번 더 보고 Step 8 에서 지운다.

docker container rm -f cache

3-7. docker inspect — 상세 정보 조회

# 구형 — 컨테이너와 이미지 모두 같은 명령어 사용 (대상이 모호)
docker inspect <컨테이너명>
docker inspect <이미지명:태그>

# 신형 — 대상을 명시적으로 구분
docker container inspect <컨테이너명>    # 컨테이너 전용
docker image inspect <이미지명:태그>     # 이미지 전용

전체 JSON 은 매우 길다. --format 으로 필요한 값만 꺼내 쓴다.

# 컨테이너에 할당된 내부 IP
# 최상위 .NetworkSettings.IPAddress 는 Docker 29 에서 제거돼 템플릿 에러가 난다.
# 연결된 네트워크를 순회해서 꺼낸다.
docker container inspect <컨테이너명> \
  --format='{{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}}{{end}}'

docker container inspect <컨테이너명> --format='{{json .NetworkSettings.Ports}}'  # 포트 매핑
docker container inspect <컨테이너명> --format='{{json .Mounts}}'                 # 마운트
docker container inspect <컨테이너명> --format='{{json .Config.Env}}'             # 환경 변수

--format 옵션은 Go 템플릿 문법을 사용한다. 전체 JSON에서 원하는 값만 꺼낼 수 있어 스크립트 자동화에 매우 유용하다.

🔬 실습 — Step 5 : mysql:8.4 — 환경변수와 실패 진단

이 Step 은 이 장에서 가장 실무적인 장면이다. 컨테이너가 죽었을 때 logs 로 원인을 찾는 습관을 여기서 들인다.

① 일부러 실패시킨다

docker run -d --name db mysql:8.4
docker container ls -a
CONTAINER ID   IMAGE       COMMAND                  STATUS                     NAMES
...            mysql:8.4   "docker-entrypoint.s…"   Exited (1) 3 seconds ago   db

Exited (1) — 0 이 아니면 비정상 종료다. 화면에는 아무 설명도 없다. 원인은 컨테이너 안에 있고, 그것을 꺼내 보는 명령이 logs 다.

docker container logs db
[Note] [Entrypoint]: Entrypoint script for MySQL Server 8.4.11-1.el9 started.
[ERROR] [Entrypoint]: Database is uninitialized and password option is not specified
    You need to specify one of the following as an environment variable:
    - MYSQL_ROOT_PASSWORD
    - MYSQL_ALLOW_EMPTY_PASSWORD
    - MYSQL_RANDOM_ROOT_PASSWORD

원인이 정확히 적혀 있다. root 비밀번호를 환경변수로 주지 않았다.

docker container rm db

② 환경변수를 주고 다시 띄운다

docker run -d --name db \
  -e MYSQL_ROOT_PASSWORD=test1234 \
  -e MYSQL_DATABASE=testdb \
  mysql:8.4

docker container logs -f db

-e 는 몇 개든 붙일 수 있다. MYSQL_ROOT_PASSWORD 는 필수이고, MYSQL_DATABASE 는 "기동하면서 이 이름의 데이터베이스를 하나 만들어 두라"는 뜻이다. 환경변수 이름은 이미지마다 다르다. Docker Hub 의 이미지 설명 페이지에 정해져 있다.

로그가 멈출 때까지 기다린다. 이 교안을 쓰며 측정한 값은 약 14초였다. 아래 줄이 나오면 준비된 것이다.

[System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections.
Version: "8.4.11"  socket: "/var/run/mysqld/mysqld.sock"  port: 3306

Ctrl+C 로 스트리밍을 끝낸다.

초기화 중에 접속하면 실패하는데, 에러 메시지가 원인을 알려주지 않는다.

ERROR 2002 (HY000): Can't connect to local MySQL server through socket
'/var/run/mysqld/mysqld.sock' (2)

MySQL 이미지는 첫 기동 때 임시 서버를 잠깐 띄워 초기화 작업을 하고, 그것을 끈 다음 진짜 서버를 올린다. 그 사이에 접속하면 위 에러가 난다. 컨테이너 상태는 Up 이라 더 헷갈린다.

logs -f 로 port: 3306 이 찍힌 줄을 확인하고 접속한다. 초기화 중 로그에 나오는 port: 0 은 임시 서버다.

③ 접속해서 확인한다

docker container exec -it db mysql -uroot -ptest1234 -e "SHOW DATABASES;"
mysql: [Warning] Using a password on the command line interface can be insecure.
Database
information_schema
mysql
performance_schema
sys
testdb

맨 위 경고는 비밀번호를 명령줄에 적었기 때문에 나오는 것으로, 실습에서는 그냥 넘어가도 된다. 맨 아래 testdb 가 MYSQL_DATABASE 로 만들어진 데이터베이스다.

④ 컨테이너에 무엇이 설정됐는지 되짚어 본다

docker container inspect db --format='{{json .Config.Env}}'
["MYSQL_ROOT_PASSWORD=test1234","MYSQL_DATABASE=testdb","PATH=...","MYSQL_VERSION=8.4.11"]

내가 준 값과 이미지가 원래 갖고 있던 값이 함께 보인다. "분명히 환경변수를 줬는데 왜 안 먹지?" 싶을 때 가장 먼저 볼 곳이 여기다.

비밀번호가 inspect 출력에 그대로 보인다. 그 컨테이너를 다룰 수 있는 사람은 누구나 볼 수 있다는 뜻이다. 실습이니 test1234 를 쓰지만, 실제 비밀번호를 이렇게 넣으면 안 된다. 안전하게 넘기는 방법은 Docker Compose 의 .env 와 Secrets 에서 다룬다.

정리한다.

docker container rm -f db

3-8. docker diff — 컨테이너가 바꾼 파일 보기

이미지를 만들 때와 비교해 컨테이너 안에서 무엇이 달라졌는지 보여준다. 쓰기 레이어를 들여다보는 명령이다.

docker container diff <컨테이너명>
docker diff <컨테이너명>          # 구형도 동일하게 동작

출력 맨 앞 글자의 뜻은 다음과 같다.

기호

뜻

A

Added — 새로 생긴 파일·디렉터리

C

Changed — 내용이 바뀐 것

D

Deleted — 지워진 것

🔬 실습 — Step 6 : ubuntu:24.04 — 컨테이너의 휘발성

① 컨테이너 안에 무언가를 설치한다

docker run -it --name lab ubuntu:24.04 bash
# ── 컨테이너 안에서 ──
apt-get update && apt-get install -y curl
curl --version
# curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 ...
exit

② 무엇이 달라졌는지 본다

docker container diff lab
C /var
C /var/log
C /var/log/apt
A /var/log/apt/term.log
C /usr/bin
A /usr/bin/curl
...

이 교안을 쓰며 측정했을 때는 698줄이 나왔다. curl 하나 깔았을 뿐인데 이만큼 바뀐다. 이 변경분 전부가 그 컨테이너의 쓰기 레이어에만 들어 있다.

③ 컨테이너를 지우고 같은 이미지로 다시 만든다

docker container rm -f lab
docker run -it --name lab ubuntu:24.04 bash
# ── 컨테이너 안에서 ──
curl --version
# bash: curl: command not found
exit
docker container diff lab
# (아무것도 안 나온다 — 0줄)

아까 한 설치가 통째로 사라졌다. 이미지는 읽기 전용이라 바뀌지 않았고, 설치 내용을 담고 있던 쓰기 레이어는 컨테이너와 함께 지워졌기 때문이다.

그래서 컨테이너 안에서 손으로 설치하면 안 된다. 컨테이너를 다시 만들 때마다 처음부터 다시 해야 하고, 무엇을 했는지 기억에만 남는다. 다른 사람은 재현할 수도 없다.

그러면 이 설치 과정을 파일로 적어두면 어떨까? 그것이 Dockerfile 이고, 그 파일로 나만의 이미지를 만드는 방법이 이미지 생성 및 관리다.

정리한다.

docker container rm -f lab

3-9. 자주 쓰는 진단 명령 — stats / cp / system df

실습이 아니라 문제가 생겼을 때 꺼내 쓰는 명령들이다. 이름만 알아 두어도 막혔을 때 찾아갈 곳이 생긴다.

docker stats — 컨테이너가 자원을 얼마나 쓰는가

docker stats              # 실시간 갱신 (Ctrl+C 로 빠져나온다)
docker stats --no-stream  # 한 번만 찍고 끝낸다
CONTAINER ID   NAME      CPU %     MEM USAGE / LIMIT     MEM %     NET I/O         BLOCK I/O         PIDS
c1adb7198c38   web       0.00%     8.727MiB / 7.686GiB   0.11%     1.21kB / 126B   1.91MB / 12.3kB   9

"서버가 느리다", "컨테이너가 자꾸 죽는다" 할 때 가장 먼저 본다. MEM USAGE 가 LIMIT 에 붙어 있으면 메모리 부족이다. Part 4 의 무중단 배포에서 메모리 1 GiB 서버에 컨테이너 두 개를 띄울 때 이 숫자가 중요해진다.

docker cp — 컨테이너와 호스트 사이에 파일 복사

docker container cp web:/etc/nginx/conf.d/default.conf ./default.conf   # 컨테이너 → 호스트
docker container cp ./default.conf web:/etc/nginx/conf.d/default.conf   # 호스트 → 컨테이너

이미지에 원래 들어 있는 설정 파일을 꺼내 볼 때 자주 쓴다. 컨테이너가 멈춰 있어도 동작하므로, 죽은 컨테이너 안에 남은 파일을 꺼낼 때도 쓸 수 있다. 다만 컨테이너 안에 넣은 파일은 컨테이너를 지우면 함께 사라진다(Step 6). 계속 쓸 설정이라면 바인드 마운트나 이미지에 넣는다.

docker system df — Docker 가 디스크를 얼마나 쓰는가

docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          47        14        16.26GB   9.905GB (60%)
Containers      16        6         7.352MB   344.1kB (4%)
Local Volumes   10        7         4.305GB   2.285GB (53%)
Build Cache     301       1         25.16GB   12.41GB

RECLAIMABLE 은 지금 쓰이지 않아 지워도 되는 양이다. 이미지를 빌드하기 시작하면 Build Cache 가 생각보다 빨리 커진다. 디스크가 부족하다는 오류가 나면 무엇이 차지하는지 여기서 먼저 확인한 뒤 Step 8 의 정리 명령을 고른다.

port is already allocated — 가장 자주 만나는 실행 오류

docker: Error response from daemon: failed to set up container networking: driver failed programming
external connectivity on endpoint web2 (...): Bind for 0.0.0.0:8080 failed: port is already allocated

호스트의 8080 을 다른 컨테이너가 이미 쓰고 있다는 뜻이다. 누가 쓰는지 찾는다.

docker container ls --filter publish=8080

그 컨테이너를 지우거나, -p 8081:80 처럼 왼쪽(호스트) 포트만 바꿔 띄운다.

실패한 컨테이너가 남는다는 점도 기억한다. 포트를 잡지 못했을 뿐 컨테이너는 Created 상태로 만들어져 있어서, 같은 --name 으로 다시 실행하면 이번에는 Conflict. The container name "/web2" is already in use 가 난다. docker container rm web2 로 지우고 다시 실행한다.

4. 볼륨 관련 명령어

볼륨(Volume)은 컨테이너가 삭제되어도 데이터가 유지되는 영구 저장소이다. Step 4 에서 Redis 데이터를 살려낸 것이 바로 이것이다. 볼륨 명령어는 구형·신형 모두 docker volume <동작> 형식으로 동일하다.

docker volume create <볼륨명>       # 생성
docker volume ls                     # 목록
docker volume inspect <볼륨명>       # 상세 정보
docker volume rm <볼륨명>            # 삭제
docker volume prune                  # 사용하지 않는 "익명" 볼륨 삭제 (이름 붙인 볼륨은 -a 필요)

볼륨을 마운트하여 컨테이너 실행

docker container run -d \
  --name <컨테이너명> \
  -v <볼륨명>:<컨테이너내경로> \
  <이미지명>

4-1. 볼륨과 바인드 마운트

-v 하나로 둘 다 쓰지만 성격이 다르다. 왼쪽에 무엇을 쓰느냐로 갈린다.

볼륨 (Named Volume)

바인드 마운트

쓰는 법

-v redis-data:/data

-v "$(pwd)/html":/usr/share/nginx/html

왼쪽 값

이름

호스트의 경로

저장 위치

Docker 가 관리하는 영역

내가 지정한 폴더

탐색기로 열기

어렵다

쉽다

주로 쓰는 곳

DB 데이터 등 보관이 목적

개발 중 소스 즉시 반영

docker volume inspect redis-data
# "Mountpoint": "/var/lib/docker/volumes/redis-data/_data"

Windows(Docker Desktop) 사용자 주의 — 볼륨의 실제 데이터는 WSL2 가상 파일시스템 안에 있으므로 위 Mountpoint 경로를 탐색기에서 열 수 없다. 내용을 봐야 한다면 임시 컨테이너를 쓴다.

docker run --rm -v redis-data:/data alpine ls -l /data

파일을 직접 열고 고쳐야 한다면 볼륨이 아니라 바인드 마운트를 쓰는 것이 맞다.

5. 네트워킹 명령어

네트워크 관리 명령어는 구형·신형 모두 docker network <동작> 형식으로 동일하다.

docker network create <네트워크명>       # 생성
docker network ls                        # 목록
docker network inspect <네트워크명>      # 상세 정보
docker network rm <네트워크명>           # 삭제

🔬 실습 — Step 7 : traefik/whoami — 사용자 정의 네트워크

traefik/whoami 는 요청을 받으면 자기 호스트명과 IP 를 그대로 돌려주는 아주 작은 웹 서버다. 어느 컨테이너가 응답했는지 눈으로 확인할 수 있어 네트워크 실습에 알맞다.

① 네트워크를 만들고 두 컨테이너를 올린다

docker network create app-net

docker run -d --name web1 --network app-net traefik/whoami
docker run -d --name web2 --network app-net traefik/whoami

② 같은 네트워크에서 이름으로 부른다

docker run -it --rm --network app-net alpine sh
# ── 컨테이너 안에서 ──
apk add --no-cache curl
curl http://web1
curl http://web2
exit
Hostname: c06b16d03ed1
IP: 172.19.0.2
RemoteAddr: 172.19.0.4:54584
GET / HTTP/1.1
Host: web1

Hostname: 0a7b95088a70
IP: 172.19.0.3
RemoteAddr: 172.19.0.4:40562
GET / HTTP/1.1
Host: web2

Hostname 과 IP 가 서로 다르다. 이름만 보고 각각 다른 컨테이너에 도달했다는 증거다. IP 를 한 번도 적지 않았다는 점이 핵심이다.

③ 기본 bridge 에서는 안 된다

docker run -d --name web3 traefik/whoami          # --network 를 주지 않았다
docker run -it --rm alpine sh
# ── 컨테이너 안에서 ──
apk add --no-cache curl
curl http://web3
exit
curl: (28) Resolving timed out after 8001 milliseconds

"이름을 못 찾았다" 가 아니라 "시간 초과" 로 보인다. 기본 bridge 네트워크에는 컨테이너 이름을 풀어 주는 DNS 가 없어서, 이름이 바깥 DNS 로 넘어갔다가 응답 없이 끝난다. 이름 문제인지 확인하려면 이렇게 본다.

docker run --rm alpine nslookup web3
# ** server can't find web3: NXDOMAIN

IP 를 직접 쓰면 통신 자체는 된다. 즉 막힌 것은 통신이 아니라 이름이다.

docker container inspect web3 \
  --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
# 172.17.0.6

사용자 정의 네트워크에서만 컨테이너 이름이 DNS 로 해석된다. 컨테이너 IP 는 다시 만들 때마다 바뀌므로, 서로를 IP 로 부르게 짜면 배포할 때마다 깨진다. 그래서 여러 컨테이너를 함께 쓰는 구성은 거의 언제나 네트워크를 따로 만든다. Docker Compose 는 이 네트워크를 자동으로 만들어 준다.

④ 네트워크 안을 들여다본다

docker network inspect app-net
# Containers 항목에 web1, web2 와 각자의 IPv4Address 가 보인다

컨테이너만 정리한다. 네트워크는 Step 8 에서 지운다.

docker container rm -f web1 web2 web3

6. 이미지 관련 명령어

6-1. docker images — 로컬 이미지 목록

# 구형
docker images

# 신형
docker image ls
docker image ls alpine       # 이름으로 거르기 (이름은 한 개만 넘길 수 있다)
IMAGE           ID             DISK USAGE   CONTENT SIZE   EXTRA
alpine:3.20     d9e853e87e55       12.2MB         3.71MB
alpine:latest   28bd5fe8b56d         13MB         3.93MB   U

Docker 29 부터 출력 컬럼이 바뀌었다. 오래된 자료는 REPOSITORY / TAG / IMAGE ID / CREATED / SIZE 로 되어 있는데, 같은 정보를 다르게 보여주는 것뿐이다.

  • DISK USAGE — 풀어 놓았을 때 디스크에서 차지하는 크기

  • CONTENT SIZE — 압축된 상태로 내려받는 크기

  • EXTRA U — 지금 쓰이지 않는(unused) 이미지라는 표시

6-2. docker search — Docker Hub 이미지 검색

# 구형·신형 동일 (docker image search 는 존재하지 않는다)
docker search <키워드>
docker search --filter is-official=true redis
NAME      DESCRIPTION                                     STARS     OFFICIAL
redis     Redis is the world’s fastest data platform f…   13616     [OK]

아무 이미지나 받아 쓰면 안 된다. --filter is-official=true 는 Docker 가 관리하는 공식 이미지만 걸러 준다. 공식 이미지가 없는 경우에는 별 수(STARS)와 마지막 갱신일, 그리고 해당 프로젝트의 공식 문서가 그 이미지를 가리키는지 확인하고 고른다.

6-3. docker pull — 이미지 내려받기

# 구형
docker pull <이미지명>           # 태그를 생략하면 latest
docker pull <이미지명>:<태그>

# 신형
docker image pull <이미지명>:<태그>

latest 는 "최신"이라는 뜻의 고정된 태그일 뿐, 자동으로 갱신되지 않는다. 내 PC 의 latest 와 반년 뒤 서버의 latest 가 다른 이미지일 수 있다. 실습이 아닌 곳에서는 nginx:1.30 처럼 버전을 명시한다.

6-4. docker tag / docker rmi — 태그와 삭제

docker image tag <원본> <새이름:태그>   # 별칭 붙이기 (복사가 아니다)
docker image rm <이미지명:태그>         # 삭제 (구형: docker rmi)
docker image prune -a                    # 사용하지 않는 이미지 일괄 삭제

rmi 는 "remove image" 의 줄임말이다. 컨테이너가 사용 중인 이미지는 삭제할 수 없다. 먼저 그 컨테이너를 rm 으로 제거한 뒤 이미지를 삭제한다.

Error response from daemon: conflict: unable to delete nginx:alpine (must be forced)
- container ee9124e68dfb is using its referenced image 62ff2089abf5

🔬 실습 — Step 8 : 이미지 관리와 뒷정리

① 지금 내 PC 에 무엇이 있는지 본다

docker image ls
docker image ls alpine
IMAGE           ID             DISK USAGE   CONTENT SIZE   EXTRA
alpine:3.20     d9e853e87e55       12.2MB         3.71MB
alpine:latest   28bd5fe8b56d         13MB         3.93MB   U

같은 alpine 이라도 태그마다 별개의 이미지로 잡힌다. 태그는 이름표가 아니라 주소의 일부다.

② 검색하고 받아 본다

docker search --filter is-official=true redis
docker image pull alpine:3.20
docker image ls alpine

③ 태그는 별칭이라는 것을 확인한다

docker image tag alpine:3.20 my-alpine:tmp
docker image ls my-alpine
IMAGE           ID             DISK USAGE   CONTENT SIZE   EXTRA
my-alpine:tmp   d9e853e87e55       12.2MB         3.71MB

ID 가 alpine:3.20 과 같다. 이미지가 복사된 것이 아니라 이름표가 하나 더 붙었을 뿐이다. 그래서 이 태그를 지워도 원본은 남는다.

docker image rm my-alpine:tmp
# Untagged: my-alpine:tmp      ← Deleted 가 아니라 Untagged 다
docker image ls alpine         # alpine:3.20 은 그대로 있다

④ 실습 흔적을 정리한다

이 장에서 만든 것은 볼륨 하나와 네트워크 하나다. 이름을 적어 지운다.

docker container ls -a        # 남은 컨테이너가 없는지 먼저 확인
docker volume ls
docker volume rm redis-data
docker network rm app-net

이미지까지 지우고 싶다면 이름을 적어 지운다.

docker image rm hello-world traefik/whoami

prune 계열은 이름을 묻지 않고 지운다. 실습용 PC 라면 편하지만, 다른 작업이 함께 올라와 있는 PC 에서는 위험하다.

docker container prune     # 멈춰 있는 컨테이너를 전부
docker image prune -a      # 컨테이너가 쓰지 않는 이미지를 전부
docker volume prune -a     # 쓰이지 않는 볼륨을 전부 (-a 가 없으면 익명 볼륨만)
docker network prune       # 쓰이지 않는 네트워크를 전부
docker system prune -a --volumes   # 위 전부를 한 번에

특히 docker volume prune -a 와 docker system prune --volumes 는 다른 프로젝트의 데이터베이스 볼륨까지 지운다. 되돌릴 수 없다. 실습 뒤 정리는 위 ④ 처럼 이름을 적어 지우는 습관을 들이는 편이 안전하다.

다음 장 예고 — 여기까지는 남이 만든 이미지를 가져다 썼다. Step 6 에서 본 것처럼 컨테이너 안에서 손으로 한 작업은 사라지므로, 그 과정을 파일로 적어 내 이미지를 만들어야 한다. 이미지 생성 및 관리에서 Dockerfile 과 docker build 를 다룬다.

마지막 수정 2026년 10월 22일