프롤로그 - "내 컴퓨터에서는 됐는데"
도커를 배우게 된 계기는 단순했다. 로컬에서는 완벽하게 돌아가던 Node.js 앱이 서버에 올리자마자 에러를 뿜는 경험이었다. "ModuleNotFoundError: No module named 'bcrypt'"
문제는 간단했다. 내 맥북에서는 Python 3.9였고, 서버는 3.7이었다. OpenSSL 버전도 달랐고, Node.js 버전도 미묘하게 달랐다. 서버 환경을 맞추느라 apt-get install을 수십 번 쳤고, 그제야 "의존성 지옥(Dependency Hell)"이 무엇인지 이해했다.
그 후 도커를 만났다. 솔로몬 하익스(Solomon Hykes)가 2013년 PyCon US에서 "The Future of Linux Containers"를 발표했을 때, 나는 그 영상을 5번 돌려봤다. 이 사람이 풀려는 문제가 정확히 내 문제였기 때문이다. "내 컴퓨터에서는 되는데"라는 핑계가 더 이상 통하지 않는 세상. 그게 도커가 약속한 미래였다.
이 글은 그때부터 지금까지 도커를 파고들며 정리해본 나만의 학습 노트다.
1. 고민의 시작 - 왜 가상머신으로는 안 되는 걸까?
처음엔 단순하게 생각했다. "환경을 통째로 복사하면 되잖아? VirtualBox로 VM 이미지 만들어서 배포하면 되는 거 아니야?" 그런데 현실은 달랐다.
VM의 한계를 받아들였다:
- 부팅 시간: Ubuntu VM 하나 띄우는 데 30초. 마이크로서비스 10개를 띄우려면? 계산도 하기 싫었다.
- 디스크 용량: VM 이미지 하나당 수 GB. 개발자 맥북에 5개만 띄워도 SSD가 비명을 질렀다.
- 메모리 낭비: 게스트 OS마다 커널을 따로 띄운다. 호스트에서 이미 리눅스 커널이 돌고 있는데, VM 안에서 또 리눅스 커널을 띄우는 게 말이 되나 싶었다.
그러다 컨테이너 개념을 접했을 때 와닿았다. "아, 커널은 공유하고 프로세스만 격리하는 거구나." 가볍고, 빠르고, 효율적이었다. 도커는 이 아이디어를 개발자들이 쓸 수 있는 형태로 포장한 것이었다.
2. 깨달음의 순간 - 도커는 마법이 아니라 리눅스 커널이었다
가장 큰 오해를 풀었던 순간은 도커 공식 문서의 이 문장이었다:
"A container is just a process on the host machine."
컨테이너는 그냥 프로세스다. 특별한 격리 기술로 감싼 프로세스. 이 사실을 이해하고 나니 모든 게 명확해졌다. 도커가 사용하는 기술 3가지를 정리해본다.
2.1. Namespaces: 각자의 세계
PID Namespace가 가장 먼저 이해됐다. 컨테이너 안에서 ps aux를 치면 내 프로세스만 보인다. 심지어 그 프로세스가 PID 1이다. 호스트에서는 같은 프로세스가 PID 5432일지도 모르는데 말이다.
NET Namespace는 네트워크 디버깅하면서 깨달았다. 컨테이너마다 eth0 인터페이스를 가진다. IP도 따로다. 하나의 물리 머신에서 수백 개의 가상 네트워크 인터페이스가 돌아가는 구조였다.
MNT, IPC, UTS, USER Namespace까지 총 6가지. 이걸 다 조합하면 완벽한 격리 환경이 만들어진다. 리눅스 커널 개발자들의 천재성이 느껴지는 순간이었다.
2.2. Cgroups: 자원 독식 방지
실제로 컨테이너 하나가 서버를 다운시키는 걸 본 적 있다. 메모리 릭이 있는 Node.js 앱이 호스트 메모리 16GB를 다 먹어치웠고, 결국 OOM Killer가 작동해서 전체 서버가 먹통이 됐다.
그 사건 이후 Cgroups의 중요성을 뼈저리게 이해했다. CPU 쿼터, 메모리 리미트, 디스크 I/O 제한. 이게 없으면 컨테이너는 그냥 위험한 폭탄이다.
# 이제 항상 이렇게 띄운다
docker run -d --memory="512m" --cpus="0.5" my-app
2.3. OverlayFS: 디스크를 아끼는 마법
100개의 컨테이너를 띄워도 디스크를 거의 안 먹는 비밀이 바로 OverlayFS였다. Copy-on-Write 전략. 베이스 이미지 레이어는 읽기 전용으로 공유하고, 각 컨테이너가 변경하는 부분만 별도 레이어에 기록한다.
이 구조를 이해하고 나니 Dockerfile 최적화 전략도 명확해졌다. 자주 바뀌는 레이어(소스 코드)는 가장 아래로, 거의 안 바뀌는 레이어(베이스 이미지, 패키지)는 위로. 캐시 히트율이 올라가면 빌드 속도가 크게 개선된다고 한다.
3. 실제 투입 - 아키텍처를 뜯어보다
도커가 단일 바이너리인 줄 알았다. 그런데 실제로는 여러 컴포넌트의 오케스트라였다.
3.1. 명령어의 여정
docker run nginx를 치면 내부적으로 이런 일이 벌어진다는 걸 결국 이거였다 수준으로 이해했다:
- Docker Client (
dockerCLI): 사용자 명령을 받아서 REST API로 변환한다. - Docker Daemon (
dockerd): API 요청을 받아서 이미지가 로컬에 있는지 확인한다. 없으면 Docker Hub에서 pull한다. - Containerd: 실제 컨테이너 생명주기 관리자. 이미지 압축 해제, 네트워크 설정 등을 담당한다.
- runC: OCI 스펙에 맞춰 실제로 프로세스를 띄운다. Namespaces와 Cgroups를 설정하고 프로세스를 fork한다.
- Containerd-shim: runC가 종료된 후에도 컨테이너 프로세스를 감시한다.
이 구조를 알고 나니 쿠버네티스가 왜 Docker Shim을 제거하고 containerd를 직접 쓰기로 했는지도 이해됐다. Docker Daemon은 개발자 경험(DX)을 위한 래퍼였고, 프로덕션 오케스트레이션에는 불필요한 레이어였던 것이다.
4. 진짜 실력은 Dockerfile에서 나온다
이론은 알았다. 이제 실제이다. 처음 짠 Dockerfile은 형편없었다.
# 나쁜 예시 (초보 시절의 나)
FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y nodejs
RUN apt-get install -y npm
COPY . /app
WORKDIR /app
RUN npm install
CMD node server.js
문제가 한두 가지가 아니었다:
ubuntu:latest: 300MB나 되는 비대한 이미지RUN을 3번 나눔: 레이어가 불필요하게 많아짐COPY . /app을 먼저 함: 소스 코드만 바뀌어도npm install이 다시 돌아감- 루트 유저로 실행: 보안 재앙
이걸 몇 달에 걸쳐 개선했다. 현재 내가 쓰는 템플릿은 이렇다:
# 1단계 - 빌드 (멀티 스테이지)
FROM node:18-alpine AS builder
# 작업 디렉토리
WORKDIR /app
# 의존성 파일만 먼저 복사 (캐시 최적화)
COPY package.json package-lock.json ./
RUN npm ci --only=production
# 소스 코드 복사
COPY . .
# 2단계 - 프로덕션 (Distroless 같은 최소 이미지)
FROM node:18-alpine
# 비root 유저 생성
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# 빌더 스테이지에서 결과물만 복사
COPY --from=builder /app .
# 유저 전환 (보안)
USER appuser
# 헬스체크
HEALTHCHECK --interval=30s --timeout=3s \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# Exec form 사용 (PID 1 문제 방지)
CMD ["node", "server.js"]
이 Dockerfile의 빌드 시간은 첫 번째 버전 대비 70% 줄었고, 이미지 크기는 300MB에서 80MB로 감소했다.
5. 실습 - 네트워크와 볼륨으로 고생하다
도커 네트워크는 처음에 정말 헷갈렸다. "왜 localhost가 안 되지?"라는 의문으로 하루를 날린 적도 있다.
5.1. 네트워크 격리의 함정
컨테이너 안에서 curl localhost:3000을 치면 컨테이너 자기 자신의 localhost를 본다. 호스트의 localhost가 아니다. 이 개념을 받아들이는 데 시간이 좀 걸렸다.
해결책은 커스텀 브리지 네트워크였다:
# 네트워크 생성
docker network create backend-net
# Redis 띄우기
docker run -d --name redis --net backend-net redis:alpine
# App 띄우기 (Redis에 연결)
docker run -d --name app --net backend-net \
-e REDIS_URL=redis://redis:6379 \
my-node-app
같은 네트워크에 있으면 컨테이너 이름으로 DNS 해석이 된다. redis:6379로 바로 접근 가능. 이게 프로덕션에서 마이크로서비스 간 통신의 기본이다.
5.2. 데이터를 날려먹고 배운 교훈
개발 중이던 PostgreSQL 컨테이너를 실수로 docker rm -f db로 날렸다. 며칠치 테스트 데이터가 순식간에 증발했다. 그때 배운 게 Volume이다.
# Named Volume 생성
docker volume create pgdata
# DB 실행 (볼륨 마운트)
docker run -d --name db \
-v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:14
# 컨테이너를 지워도...
docker rm -f db
# 볼륨은 남아있다
docker volume ls # pgdata 존재
# 다시 같은 볼륨으로 띄우면 데이터 복구
docker run -d --name db \
-v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:14
컨테이너는 임시적이고, Volume은 영속적이다. 이 원칙을 머리가 아니라 몸으로 배웠다.
6. 트러블슈팅 - 현장에서 겪은 5가지 재앙
이론으로는 알 수 없는 것들. 실제로 배포하고, 장애 겪고, 새벽에 깨어나서 고치면서 배운 것들을 정리한다.
| 상황 | 증상 | 삽질 끝에 찾은 원인 | 해결책 |
|---|---|---|---|
| 빌드 중 의존성 에러 | ERROR: Unable to locate package python3 | Alpine 리눅스는 apt-get이 아니라 apk 사용 | RUN apk add --no-cache python3 |
| 컨테이너 시작 후 즉시 종료 | docker ps에 안 보임 | CMD가 백그라운드 프로세스(&)로 실행됨 | Foreground로 실행되도록 수정 |
| localhost 연결 실패 | ECONNREFUSED 127.0.0.1:5432 | 컨테이너의 localhost는 호스트와 다름 | 맥에서는 host.docker.internal 사용 |
| 디스크 풀 | No space left on device | 댕글링 이미지가 50GB 차지 | docker system prune -a --volumes |
| SIGTERM 무시 | 컨테이너가 graceful shutdown 안 됨 | PID 1이 쉘 스크립트라 시그널 전파 안 됨 | exec 사용 또는 tini init 프로세스 추가 |
특히 마지막 PID 1 문제는 프로덕션에서 롤링 업데이트 시 커넥션이 끊기는 원인이었다. CMD ["node", "server.js"] 형태의 exec form을 쓰면 node가 직접 PID 1이 되어서 SIGTERM을 제대로 받는다.
7. 보안 - CIS Benchmark를 따라 배우다
프로덕션에 처음 올렸을 때 보안팀에서 컨테이너를 스캔하고 나서 벌건 리포트를 들이밀었다. "Critical 취약점 23개"라고 적혀 있었다. 부끄러웠다.
CIS Docker Benchmark를 하나하나 적용하면서 배운 원칙들:
7.1. 절대 루트로 실행하지 마라
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
7.2. 리소스 제한은 필수
docker run -d \
--memory="1g" \
--cpus="1.0" \
--pids-limit=100 \
my-app
7.3. 읽기 전용 파일시스템
docker run -d --read-only \
--tmpfs /tmp \
my-app
해커가 침투해도 파일을 수정 못 한다.
7.4. Capability 최소화
docker run -d \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
my-app
7.5. 이미지 취약점 스캔 (CI/CD 필수)
# Trivy로 스캔
trivy image my-app:latest
이걸 CI/CD 파이프라인에 넣어서 Critical CVE가 있으면 배포를 자동으로 막는다.
8. 도커의 역사를 보면 미래가 보인다
기술을 깊이 이해하려면 역사를 알아야 한다고 믿는다. 도커의 타임라인을 정리하며 많은 걸 배웠다.
- 2013: Docker 0.1 발표. LXC를 감싼 wrapper였다.
- 2014: Docker 1.0. 구글, AWS, 마이크로소프트가 파트너십 체결. 산업 표준으로 자리잡기 시작.
- 2015: OCI(Open Container Initiative) 결성. 런타임 스펙과 이미지 스펙 표준화.
- 2016: Docker Swarm vs Kubernetes 전쟁 시작.
- 2017: Kubernetes의 압도적 승리. 도커는 엔터프라이즈 오케스트레이션에서 밀림.
- 2020: Kubernetes가 Docker Shim 제거 선언. Containerd 직접 사용.
- 2025: 도커는 개발자 경험(DX) 도구의 절대 강자로 남았다. Docker Desktop의 편의성은 여전히 압도적이다.
이 과정에서 느낀 건 "표준의 힘"이다. OCI 덕분에 Podman, Buildah 같은 대안들이 나올 수 있었고, 쿠버네티스는 도커 없이도 돌아갈 수 있게 됐다.
9. FAQ: 실무에서 자주 나오는 질문들
Q1. 도커 컨테이너와 VM의 차이는?
컨테이너는 커널을 공유하고, VM은 커널을 각자 띄운다. 그래서 컨테이너가 가볍다. 하지만 완전한 격리는 VM이 더 강하다.
Q2. 도커가 죽으면 데이터도 날아가나요?
Writable Layer는 임시적이다. 중요한 데이터는 반드시 Volume이나 Bind Mount로 호스트에 저장해야 한다.
Q3. 이미지 크기를 줄이는 방법은?
Alpine 베이스 이미지(5MB), Multi-stage build, .dockerignore 파일 활용, 레이어 최적화가 핵심이다.
Q4. 쿠버네티스와 도커의 관계는?
도커는 벽돌(컨테이너)을 만드는 도구, 쿠버네티스는 그 벽돌로 빌딩(클러스터)을 짓는 도구다. 이제 쿠버네티스는 도커 없이도 돌아간다(containerd).
Q5. localhost 연결이 안 되는 이유는?
컨테이너의 네트워크 Namespace가 격리돼 있어서다. 맥/윈도우에서는 host.docker.internal을 쓰고, 리눅스에서는 --network host 옵션을 쓰면 된다.
Q6. COPY와 ADD의 차이는?
COPY는 로컬 파일 복사만 한다. ADD는 URL 다운로드와 tar 자동 압축 해제까지 한다. 명확성을 위해 COPY 사용을 권장한다.
Q7. PID 1 문제가 뭔가요?
쉘 스크립트가 PID 1이 되면 SIGTERM을 자식 프로세스에게 전파하지 않는다. exec 키워드를 쓰거나 tini 같은 init 프로세스를 쓰면 해결된다.
Q8. Docker는 무료인가요?
Docker Engine은 오픈소스이고 무료다. Docker Desktop은 대기업에게는 유료지만 개인, 교육 기관, 중소기업에게는 무료다.
Q9. 컨테이너끼리 통신은 어떻게?
같은 네트워크에 있으면 IP로 통신 가능하다. 커스텀 브리지 네트워크를 만들면 컨테이너 이름으로 DNS 해석이 되어 더 편하다.
Q10. Docker가 안전한가요?
기본 설정은 안전하지 않다. Non-root 유저 사용, Capability 제한, Read-only 파일시스템, 리소스 제한, 이미지 스캔 등의 보안 하드닝이 필수다.
10. 필수 명령어 치트시트
손에 익혀야 하는 것들:
| 명령어 | 설명 |
|---|---|
docker ps -a | 모든 컨테이너 목록 (실행 중 + 중지됨) |
docker logs -f <id> | 컨테이너 로그 실시간 추적 |
docker exec -it <id> /bin/sh | 실행 중인 컨테이너에 쉘 접속 |
docker build -t <tag> . | 현재 디렉토리의 Dockerfile로 이미지 빌드 |
docker system prune -a | 사용하지 않는 이미지, 컨테이너, 네트워크, 볼륨 전부 삭제 |
docker inspect <id> | 컨테이너/이미지 상세 정보 JSON 출력 |
docker stats | 실시간 CPU/메모리 사용량 모니터링 |
docker-compose up -d | docker-compose.yml로 스택 백그라운드 실행 |
docker volume ls | 볼륨 목록 확인 |
docker network inspect bridge | 네트워크 상세 정보 (IP 할당 등) 확인 |
11. 용어 사전 (20가지 핵심 개념)
- Image: 컨테이너를 만들기 위한 읽기 전용 템플릿. 레이어들의 스택.
- Container: 이미지의 실행 인스턴스. 격리된 프로세스.
- Dockerfile: 이미지를 빌드하기 위한 명령어 스크립트.
- Registry: 이미지를 저장하고 배포하는 저장소 (예: Docker Hub).
- Repository: 같은 이름의 이미지들을 태그로 구분한 컬렉션.
- Tag: 이미지 버전을 나타내는 레이블 (예:
latest,v1.2.3). - Layer: Dockerfile의 각 명령어가 생성하는 읽기 전용 파일시스템 변경사항.
- Volume: 컨테이너가 삭제되어도 데이터가 보존되는 영속적 저장소.
- Bind Mount: 호스트의 특정 디렉토리를 컨테이너에 마운트.
- Bridge Network: 기본 네트워크 드라이버. 같은 브리지의 컨테이너끼리 통신 가능.
- Overlay Network: 여러 Docker 호스트에 걸친 네트워크 (Swarm/K8s).
- Daemon: 백그라운드에서 컨테이너를 관리하는 서비스 (
dockerd). - Client: 사용자가 쓰는 CLI 도구 (
docker명령어). - Docker Compose: 여러 컨테이너를 YAML로 정의하고 실행하는 도구.
- Swarm: 도커의 네이티브 클러스터링/오케스트레이션 도구 (현재는 K8s에 밀림).
- Kubernetes: 컨테이너 오케스트레이션 플랫폼. 사실상 표준.
- Namespace: 리눅스 커널 기능. 시스템 리소스를 격리 (PID, NET, MNT 등).
- Cgroups: 리눅스 커널 기능. CPU, 메모리, I/O 등 리소스 사용량 제한.
- OCI: Open Container Initiative. 컨테이너 표준화 단체.
- Multi-stage Build: 하나의 Dockerfile에서 여러
FROM을 써서 이미지 크기를 줄이는 기법.
12. Docker vs Podman: 실제로 느낀 차이
회사에서 보안팀이 "도커 데몬이 root 권한으로 돌아가는 게 위험하다"며 Podman 도입을 검토했다.
Docker:
- Daemon 기반 (
dockerd가 항상 떠 있어야 함) - 대부분 root 권한 필요
- 단일 데몬이라 장애 시 모든 컨테이너 영향 받음
- Docker Compose 생태계가 성숙함
Podman (RedHat):
- Daemon-less (fork/exec 직접)
- Rootless 기본 지원 (보안 우수)
- Pod 개념 내장 (K8s Pod를 로컬에서 테스트 가능)
- Docker CLI와 호환됨 (
alias docker=podman)
결론은 "개발 환경은 Docker Desktop, 프로덕션 K8s는 containerd, 보안이 극도로 중요한 환경은 Podman" 이렇게 정리했다.
도커는 시작일 뿐이다
도커를 배우면서 가장 중요하게 이해한 것은 "도커 자체"가 아니라 "컨테이너 사고방식"이었다. 애플리케이션을 격리하고, 불변 인프라로 다루고, 선언적으로 정의하는 철학.
이제 "내 컴퓨터에서는 되는데"라는 핑계는 통하지 않는다. Dockerfile과 docker-compose.yml만 있으면 누구나 동일한 환경을 재현할 수 있다.
앞으로는 WebAssembly(Wasm)가 특정 워크로드에서 컨테이너를 대체할지도 모른다. 하지만 "환경을 코드로 정의한다"는 핵심 아이디어는 절대 사라지지 않을 것이다.
이 글이 누군가의 새벽 3시 배포 실패를 막아주길 바란다.
Docker: The Comprehensive Textbook (Architecture, Labs, Tuning)
Prologue: "It Works on My Machine"
My motivation for learning Docker was simple. A Node.js app that ran flawlessly on my local machine started throwing errors the moment it hit the server — "ModuleNotFoundError: No module named 'bcrypt'".
The problem was straightforward. My MacBook had Python 3.9; the server had 3.7. Different OpenSSL versions. Slightly mismatched Node.js versions. I spent hours installing and reinstalling dependencies, manually compiling native modules. That was when I truly understood what "Dependency Hell" meant.
The following week, I discovered Docker. I watched Solomon Hykes' 2013 PyCon talk "The Future of Linux Containers" five times in a row. This man was solving my exact problem. A world where "It works on my machine" would no longer be an excuse. That was Docker's promise.
This article is my personal learning journal from that moment to now.
1. The Question: Why Not Just Use Virtual Machines?
My first instinct was simple: "Just copy the entire environment. Package a VirtualBox VM and ship that." But reality had other plans.
I learned VM limitations the hard way:
- Boot time: 30 seconds to start an Ubuntu VM. Ten microservices? Do the math.
- Disk bloat: Each VM image was several gigabytes. Five running VMs and my SSD was screaming.
- Memory waste: Each guest OS runs its own kernel. The host already has a Linux kernel running. Running another Linux kernel inside seemed absurd.
When I encountered the concept of containers, it clicked instantly. "Oh, you share the kernel but isolate the processes." Lightweight, fast, efficient. Docker packaged this idea into something developers could actually use.
2. The Revelation: Docker Is Linux, Not Magic
The biggest misconception I cleared up came from this sentence in the Docker docs:
"A container is just a process on the host machine."
Containers are just processes. Special processes wrapped in isolation technology. Once I internalized this, everything became crystal clear. Here are the three core Linux technologies Docker uses.
2.1. Namespaces: Separate Realities
PID Namespace was the easiest to grasp. When you run ps aux inside a container, you only see your own processes. Your main process is even PID 1. On the host, that same process might be PID 5432.
NET Namespace became obvious while debugging networking. Each container gets its own eth0 interface. Its own IP. A single physical machine can run hundreds of virtual network interfaces.
There are six total: PID, NET, MNT, IPC, UTS, USER. Combined, they create perfect isolation. I was in awe of the Linux kernel developers' genius.
2.2. Cgroups: Preventing Resource Starvation
I once witnessed a single container crash an entire server. A Node.js app with a memory leak consumed all 16GB of host RAM. The OOM Killer eventually kicked in and took down everything.
After that incident, I deeply understood the importance of Cgroups. CPU quotas, memory limits, disk I/O throttling. Without these, containers are dangerous.
# Now I always run containers like this
docker run -d --memory="512m" --cpus="0.5" my-app
2.3. OverlayFS: The Disk-Saving Wizardry
Running 100 containers barely consumes disk space. The secret? OverlayFS with Copy-on-Write. Base image layers are read-only and shared. Each container only writes its changes to a separate layer.
Once I understood this structure, Dockerfile optimization strategies became obvious. Put frequently changing layers (source code) at the bottom. Put rarely changing layers (base images, packages) at the top. Cache hit rates go up significantly, and build times are said to improve dramatically as a result.
3. Real-World Architecture: Deconstructing the Stack
I initially thought Docker was a single binary. Turns out, it's an orchestra of components working together.
3.1. The Journey of a Command
When you type docker run nginx, here's what happens under the hood:
- Docker Client (
dockerCLI): Converts your command into a REST API call. - Docker Daemon (
dockerd): Receives the API request, checks if the image exists locally. If not, pulls from Docker Hub. - Containerd: The actual container lifecycle manager. Unpacks images, configures networking.
- runC: Implements the OCI spec. Sets up Namespaces and Cgroups, forks the process.
- Containerd-shim: Monitors the container process after runC exits.
Understanding this architecture clarified why Kubernetes removed Docker Shim and now talks directly to containerd. Docker Daemon was a wrapper for developer experience (DX), an unnecessary layer for production orchestration.
4. Real Skills Show in the Dockerfile
Theory is good. Practice is everything. My first Dockerfile was terrible.
# Bad Example (Beginner Me)
FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y nodejs
RUN apt-get install -y npm
COPY . /app
WORKDIR /app
RUN npm install
CMD node server.js
Problems everywhere:
ubuntu:latest: 300MB bloated image- Three separate
RUNcommands: unnecessary layers COPY . /apptoo early: any source code change invalidates thenpm installcache- Running as root: security disaster
I spent months refining this. Here's my current production template:
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
# Copy dependency files first (cache optimization)
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Copy source code
COPY . .
# Stage 2: Production
FROM node:18-alpine
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy artifacts from builder
COPY --from=builder /app .
# Switch to non-root user
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=3s \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# Exec form (prevents PID 1 issues)
CMD ["node", "server.js"]
This Dockerfile reduced build time by 70% and image size from 300MB to 80MB.
5. Lab: Wrestling with Networks and Volumes
Docker networking confused me initially. I burned an entire day wondering "Why doesn't localhost work?"
5.1. The Localhost Trap
Inside a container, curl localhost:3000 refers to the container's own localhost, not the host's. This concept took time to internalize.
The solution: custom bridge networks.
# Create network
docker network create backend-net
# Run Redis
docker run -d --name redis --net backend-net redis:alpine
# Run app (connects to Redis)
docker run -d --name app --net backend-net \
-e REDIS_URL=redis://redis:6379 \
my-node-app
Containers on the same network resolve each other by name via DNS. redis:6379 just works. This is the foundation of microservice communication in production.
5.2. Data Loss Taught Me Volumes
I accidentally ran docker rm -f db and deleted my PostgreSQL container mid-development. Days of test data evaporated instantly. That's when I learned about Volumes.
# Create named volume
docker volume create pgdata
# Run DB with volume mount
docker run -d --name db \
-v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:14
# Even if you delete the container...
docker rm -f db
# The volume survives
docker volume ls # pgdata exists
# Restart with same volume, data restored
docker run -d --name db \
-v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:14
Containers are ephemeral. Volumes are persistent. I learned this principle not with my head, but with my hands.
6. Troubleshooting: Five Production Nightmares
Theory doesn't teach you these. You learn them by deploying, failing, and fixing at 3 AM.
| Situation | Symptoms | Root Cause After Hours of Debugging | Solution |
|---|---|---|---|
| Build Dependency Error | ERROR: Unable to locate package python3 | Alpine uses apk, not apt-get | RUN apk add --no-cache python3 |
| Container Exits Immediately | Doesn't show in docker ps | CMD runs process in background (&) | Run process in foreground |
| Localhost Connection Refused | ECONNREFUSED 127.0.0.1:5432 | Container's localhost ≠ host's localhost | Use host.docker.internal on Mac/Windows |
| Disk Full | No space left on device | Dangling images consuming 50GB | docker system prune -a --volumes |
| SIGTERM Ignored | Container doesn't gracefully shutdown | PID 1 is shell script, doesn't propagate signals | Use exec or add tini init process |
The last PID 1 problem caused connection drops during rolling updates in production. Using exec form CMD ["node", "server.js"] makes node PID 1, properly receiving SIGTERM.
7. Security: Learning from the CIS Benchmark
When I first deployed to production, the security team scanned my containers and handed me a blood-red report: "23 Critical Vulnerabilities." I was mortified.
I went through the CIS Docker Benchmark line by line. Here's what I learned:
7.1. Never Run as Root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
7.2. Resource Limits Are Mandatory
docker run -d \
--memory="1g" \
--cpus="1.0" \
--pids-limit=100 \
my-app
7.3. Read-Only Filesystem
docker run -d --read-only \
--tmpfs /tmp \
my-app
If attackers breach, they can't modify files.
7.4. Minimize Capabilities
docker run -d \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
my-app
7.5. Image Vulnerability Scanning (CI/CD Essential)
# Scan with Trivy
trivy image my-app:latest
We integrated this into CI/CD to automatically block deployment if Critical CVEs are found.
8. History Reveals the Future
To deeply understand technology, you must know its history. Here's Docker's timeline and what I learned from it:
- 2013: Docker 0.1 released. Just a wrapper around LXC.
- 2014: Docker 1.0. Google, AWS, Microsoft signed partnerships. Industry adoption began.
- 2015: OCI (Open Container Initiative) formed. Runtime and image spec standardization started.
- 2016: Docker Swarm vs Kubernetes war began.
- 2017: Kubernetes won decisively. Docker lost enterprise orchestration market.
- 2020: Kubernetes announced Docker Shim removal. Direct containerd usage.
- 2025: Docker remains king of developer experience (DX). Docker Desktop's convenience is still unmatched.
The lesson: the power of standards. OCI enabled alternatives like Podman and Buildah. Kubernetes could run without Docker thanks to standardization.
9. FAQ: Common Questions
Q1. Docker vs VM differences?
Containers share the kernel; VMs each run their own kernel. That's why containers are lighter. But VMs provide stronger isolation.
Q2. Do I lose data when containers die?
The writable layer is ephemeral. Critical data must be stored in Volumes or Bind Mounts on the host.
Q3. How to reduce image size?
Alpine base images (5MB), multi-stage builds, .dockerignore files, and layer optimization.
Q4. What's the relationship between Docker and Kubernetes?
Docker builds the bricks (containers). Kubernetes builds the building (cluster). Kubernetes now runs without Docker (using containerd).
Q5. Why doesn't localhost work?
Network namespaces are isolated. On Mac/Windows, use host.docker.internal. On Linux, use --network host.
Q6. COPY vs ADD?
COPY only copies local files. ADD can fetch URLs and auto-extract tars. Use COPY for clarity.
Q7. What's the PID 1 problem?
If a shell script becomes PID 1, it doesn't forward SIGTERM to child processes. Use exec keyword or an init process like tini.
Q8. Is Docker free?
Docker Engine is open source and free. Docker Desktop requires paid subscription for large enterprises but is free for individuals, education, and small businesses.
Q9. How do containers communicate?
If on the same network, they can communicate by IP. Custom bridge networks enable DNS resolution by container name.
Q10. Is Docker secure?
Default settings are not secure. You must harden: non-root users, capability restrictions, read-only filesystems, resource limits, and image scanning.
10. Essential Commands Cheat Sheet
Commands you need muscle memory for:
| Command | Description |
|---|---|
docker ps -a | List all containers (running + stopped) |
docker logs -f <id> | Tail container logs in real-time |
docker exec -it <id> /bin/sh | Shell into a running container |
docker build -t <tag> . | Build image from Dockerfile in current directory |
docker system prune -a | Delete unused images, containers, networks, volumes |
docker inspect <id> | View detailed JSON metadata (IP, mounts, env vars) |
docker stats | Live CPU/RAM usage monitoring |
docker-compose up -d | Start stack in background via docker-compose.yml |
docker volume ls | List persistent volumes |
docker network inspect bridge | Debug networking, see IP allocations |
11. Comprehensive Glossary (20 Core Concepts)
- Image: Read-only template for creating containers. Stack of layers.
- Container: Running instance of an image. Isolated process.
- Dockerfile: Script of commands to build an image.
- Registry: Storage and distribution system for images (e.g., Docker Hub).
- Repository: Collection of images with same name, different tags.
- Tag: Version label for an image (e.g.,
latest,v1.2.3). - Layer: Read-only filesystem change created by each Dockerfile instruction.
- Volume: Persistent storage that survives container deletion.
- Bind Mount: Host directory mounted into container.
- Bridge Network: Default network driver. Containers on same bridge can communicate.
- Overlay Network: Network driver spanning multiple Docker hosts (Swarm/K8s).
- Daemon: Background service managing containers (
dockerd). - Client: CLI tool users interact with (
dockercommand). - Docker Compose: Tool for defining multi-container apps with YAML.
- Swarm: Docker's native clustering/orchestration (now overshadowed by K8s).
- Kubernetes: Container orchestration platform. De facto standard.
- Namespace: Linux kernel feature isolating system resources (PID, NET, MNT).
- Cgroups: Linux kernel feature limiting resource usage (CPU, memory, I/O).
- OCI: Open Container Initiative. Container standardization body.
- Multi-stage Build: Using multiple
FROMinstructions in one Dockerfile to reduce size.
12. Docker vs Podman: Field Experience
Our security team raised concerns about the Docker daemon running with root privileges and proposed evaluating Podman.
Docker:
- Daemon-based (
dockerdmust always run) - Mostly requires root privileges
- Single daemon point of failure
- Mature Docker Compose ecosystem
Podman (RedHat):
- Daemon-less (direct fork/exec)
- Rootless by default (superior security)
- Built-in Pod concept (test K8s pods locally)
- Docker CLI compatible (
alias docker=podman)
Our conclusion: "Docker Desktop for dev environments, containerd for production K8s, Podman for extremely security-sensitive environments."