학습 목표
이미지와 컨테이너의 개념과 관계를 설명할 수 있다.
컨테이너의 생명주기(생성·실행·중지·삭제)를 이해할 수 있다.
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
donePowerShell 에서는 이렇게 한다.
foreach ($img in "hello-world","alpine","nginx:alpine","redis:alpine","mysql:8.4","ubuntu:24.04","traefik/whoami") {
docker image pull $img
}강의실·사내망에서 20명이 동시에 받으면 여기서 수업 시간이 다 간다. 공유 회선 하나로 같은 이미지를 동시에 끌어오기 때문이다. 실제 용량은 다음과 같다.
이미지 | 내려받는 양 | 디스크 사용량 |
|---|---|---|
| 9.49 kB | 25.9 kB |
| 3.93 MB | 13 MB |
| 5.17 MB | 19.2 MB |
| 27.2 MB | 94.4 MB |
| 31.7 MB | 119 MB |
| 39.5 MB | 160 MB |
| 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) / 붕어빵 |
삭제 시 | 이미지 파일 제거 | 실행 환경 제거 (이미지는 유지) |
생성 방법 |
|
|
1-4. 컨테이너 생명주기 (Lifecycle)
컨테이너는 이미지로부터 만들어져 여러 상태를 거친다. 이 흐름을 이해하면 이후에 배울 모든 명령어가 자연스럽게 연결된다.
상태 | 설명 | 관련 명령어 |
|---|---|---|
Created | 컨테이너가 생성되었으나 아직 실행되지 않은 상태 |
|
Running | 프로세스가 실행 중인 상태 |
|
Stopped | 실행이 중지된 상태. 다시 시작할 수 있음 |
|
Removed | 컨테이너가 완전히 삭제된 상태 |
|
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) |
|
|
관리 명령어 (신형) |
|
|
2-2. 왜 두 가지 방식이 생겼나?
Docker 초기에는 docker run, docker ps 같은 단축 명령어만 있었다. 기능이 늘어나면서 docker inspect가 컨테이너도 되고 이미지도 되는 등 명령어 대상이 모호해졌다. 이를 해결하기 위해 객체(container, image, volume, network)를 명시적으로 지정하는 구조로 개편됐다.
어떤 방식을 써야 할까?
실무에서는 타이핑이 짧은 구형 방식을 여전히 많이 사용한다.
공식 문서·CI/CD 스크립트·팀 컨벤션에서는 명시적인 관리 명령어 방식이 선호된다.
두 방식을 모두 읽고 쓸 수 있어야 오픈소스 코드나 팀 코드를 이해할 수 있다.
2-3. 주요 명령어 대응표
구형 (Legacy) | 관리 명령어 (신형) | 설명 |
|---|---|---|
|
| 컨테이너 생성 및 실행 |
|
| 컨테이너 목록 조회 |
|
| 전체 컨테이너 목록 |
|
| 컨테이너 중지 |
|
| 컨테이너 시작 |
|
| 컨테이너 재시작 |
|
| 컨테이너 삭제 |
|
| 컨테이너 내 명령 실행 |
|
| 컨테이너 로그 확인 |
|
| 컨테이너 상세 정보 |
|
| 컨테이너 변경 파일 목록 |
|
| 이미지 목록 조회 |
|
| 이미지 내려받기 |
|
| 이미지 삭제 |
|
| 이미지 빌드 |
|
| 네트워크 목록 (동일) |
|
| 볼륨 목록 (동일) |
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-worldUnable 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 lsCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES아무것도 없다. 방금 분명히 실행했는데 목록이 비어 있다. -a 를 붙여 보자.
docker container ls -aCONTAINER 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
exitexit 를 치는 순간 호스트 프롬프트로 돌아온다. 상태를 보자.
docker container ls -aCONTAINER ID IMAGE COMMAND CREATED STATUS NAMES
97097dd2cadd alpine "sh" 10 seconds ago Exited (0) 3 seconds ago my-alpineexit 는 쉘을 끝낸 것이고, 쉘이 끝나면 컨테이너도 멈춘다. 컨테이너는 "안에서 돌고 있는 프로세스"가 살아 있는 동안만 살아 있다. 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-alpine3-4. docker rm — 컨테이너 삭제
# 구형
docker rm <컨테이너명> # 삭제 (먼저 stop 필요)
docker rm -f <컨테이너명> # 강제 삭제 (실행 중이어도 가능)
# 신형
docker container rm <컨테이너명>
docker container rm -f <컨테이너명>
# 중지된 컨테이너 일괄 삭제 (구형·신형 동일)
docker container prunedocker 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 을 연다.
Welcome to nginx! — 컨테이너 안 80 번이 호스트 8080 번으로 연결됐다
-p 8080:80 의 왼쪽이 호스트, 오른쪽이 컨테이너다. Nginx 이미지는 80 번으로 서비스하도록 만들어져 있고, 호스트에서는 8080 번으로 받겠다는 뜻이다. 왼쪽 숫자만 바꾸면 같은 이미지를 몇 개든 다른 포트로 띄울 수 있다.
② 로그를 실시간으로 본다
터미널을 하나 더 열고 아래를 실행한 뒤, 브라우저에서 새로고침을 몇 번 해 본다.
docker container logs -f web172.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 web3-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 SAVESHUTDOWN 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 cache3-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 -aCONTAINER ID IMAGE COMMAND STATUS NAMES
... mysql:8.4 "docker-entrypoint.s…" Exited (1) 3 seconds ago dbExited (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: 3306Ctrl+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 db3-8. docker diff — 컨테이너가 바꾼 파일 보기
이미지를 만들 때와 비교해 컨테이너 안에서 무엇이 달라졌는지 보여준다. 쓰기 레이어를 들여다보는 명령이다.
docker container diff <컨테이너명>
docker diff <컨테이너명> # 구형도 동일하게 동작출력 맨 앞 글자의 뜻은 다음과 같다.
기호 | 뜻 |
|---|---|
| Added — 새로 생긴 파일·디렉터리 |
| Changed — 내용이 바뀐 것 |
| 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 labC /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
exitdocker container diff lab
# (아무것도 안 나온다 — 0줄)아까 한 설치가 통째로 사라졌다. 이미지는 읽기 전용이라 바뀌지 않았고, 설치 내용을 담고 있던 쓰기 레이어는 컨테이너와 함께 지워졌기 때문이다.
그래서 컨테이너 안에서 손으로 설치하면 안 된다. 컨테이너를 다시 만들 때마다 처음부터 다시 해야 하고, 무엇을 했는지 기억에만 남는다. 다른 사람은 재현할 수도 없다.
그러면 이 설치 과정을 파일로 적어두면 어떨까? 그것이 Dockerfile 이고, 그 파일로 나만의 이미지를 만드는 방법이 이미지 생성 및 관리다.
정리한다.
docker container rm -f lab3-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 dfTYPE 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.41GBRECLAIMABLE 은 지금 쓰이지 않아 지워도 되는 양이다. 이미지를 빌드하기 시작하면 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) | 바인드 마운트 | |
|---|---|---|
쓰는 법 |
|
|
왼쪽 값 | 이름 | 호스트의 경로 |
저장 위치 | 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
exitHostname: 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: web2Hostname 과 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
exitcurl: (28) Resolving timed out after 8001 milliseconds"이름을 못 찾았다" 가 아니라 "시간 초과" 로 보인다. 기본 bridge 네트워크에는 컨테이너 이름을 풀어 주는 DNS 가 없어서, 이름이 바깥 DNS 로 넘어갔다가 응답 없이 끝난다. 이름 문제인지 확인하려면 이렇게 본다.
docker run --rm alpine nslookup web3
# ** server can't find web3: NXDOMAINIP 를 직접 쓰면 통신 자체는 된다. 즉 막힌 것은 통신이 아니라 이름이다.
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 web36. 이미지 관련 명령어
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 UDocker 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 redisNAME 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 alpineIMAGE 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-alpineIMAGE ID DISK USAGE CONTENT SIZE EXTRA
my-alpine:tmp d9e853e87e55 12.2MB 3.71MBID 가 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/whoamiprune 계열은 이름을 묻지 않고 지운다. 실습용 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 를 다룬다.