Docker
비유로 시작하기
이사할 때 짐을 어떻게 포장하나요? 물건마다 따로 싸면 이사업체마다 다르게 취급할 수 있습니다. 하지만 규격화된 컨테이너 박스에 모든 짐을 넣으면, 어떤 이사업체든, 어떤 트럭이든 동일하게 처리할 수 있습니다.
Docker는 소프트웨어를 컨테이너라는 표준 단위로 포장하는 기술입니다. “내 로컬에서는 되는데 서버에서 안 돼요” 문제를 근본적으로 해결합니다.
컨테이너 vs 가상머신
Guest OS + App] VM2[VM2
Guest OS + App] HW1 --> OS1 --> HV HV --> VM1 HV --> VM2 end subgraph 컨테이너 HW2[물리 하드웨어] OS2[Host OS] DE[Docker Engine] C1[Container 1
App + Libs] C2[Container 2
App + Libs] HW2 --> OS2 --> DE DE --> C1 DE --> C2 end
| 항목 | VM | Container |
|---|---|---|
| 크기 | GB 단위 | MB 단위 |
| 시작 시간 | 수분 | 수초 |
| OS | 별도 Guest OS 필요 | Host OS 커널 공유 |
| 격리 수준 | 강함 (하이퍼바이저) | 보통 (네임스페이스/cgroup) |
| 사용 목적 | 완전한 OS 격리 | 애플리케이션 배포 |
핵심 개념
Image (이미지)
컨테이너를 만들기 위한 읽기 전용 템플릿입니다. 레이어(Layer) 구조로 이루어져 있습니다.
레이어가 캐시되기 때문에, 앱 코드만 변경되면 Layer 4만 다시 빌드됩니다. 이것이 Docker 빌드가 빠른 이유입니다.
Container (컨테이너)
이미지의 실행 인스턴스입니다. 이미지 위에 읽기/쓰기 레이어가 추가됩니다. 이미지 하나로 컨테이너를 수십 개 생성 가능합니다.
Registry
이미지를 저장하고 배포하는 저장소입니다. Docker Hub가 대표적이며, AWS ECR, GCR, Harbor 등 사설 레지스트리도 있습니다.
Dockerfile
이미지를 만드는 설계도(스크립트)입니다.
기본 Spring Boot 애플리케이션
# 베이스 이미지
FROM eclipse-temurin:17-jre-alpine
# 작업 디렉토리 설정
WORKDIR /app
# JAR 파일 복사
COPY build/libs/app.jar app.jar
# 포트 노출 (문서화 목적, 실제 바인딩은 -p 옵션)
EXPOSE 8080
# 컨테이너 시작 명령
ENTRYPOINT ["java", "-jar", "app.jar"]
멀티스테이지 빌드 (Multi-stage Build)
빌드 환경과 실행 환경을 분리하여 최종 이미지 크기를 최소화합니다.
# Stage 1: 빌드
FROM gradle:8-jdk17-alpine AS builder
WORKDIR /build
COPY . .
RUN gradle bootJar --no-daemon
# Stage 2: 실행
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# 보안: 비 root 사용자로 실행
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
COPY --from=builder /build/build/libs/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-jar", "app.jar"]
빌드 스테이지(JDK, Gradle, 소스코드)는 최종 이미지에 포함되지 않습니다. 결과 이미지 크기: ~150MB vs 단일 스테이지 ~500MB.
.dockerignore
.git
build/
*.log
.gradle
node_modules
빌드 컨텍스트에서 불필요한 파일 제외로 빌드 속도 향상.
주요 명령어
# 이미지 빌드
docker build -t myapp:1.0 .
docker build -t myapp:1.0 --build-arg PROFILE=prod .
# 컨테이너 실행
docker run -d \
--name myapp \
-p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-v /host/logs:/app/logs \
myapp:1.0
# 컨테이너 관리
docker ps # 실행 중인 컨테이너
docker ps -a # 전체 컨테이너
docker logs -f myapp # 로그 스트리밍
docker exec -it myapp /bin/sh # 컨테이너 내부 접속
docker stats # CPU/메모리 사용량
# 이미지 관리
docker images
docker rmi myapp:1.0
docker image prune -a # 미사용 이미지 정리
# 레지스트리
docker tag myapp:1.0 registry.example.com/myapp:1.0
docker push registry.example.com/myapp:1.0
docker pull registry.example.com/myapp:1.0
네트워킹
# 네트워크 생성
docker network create mynetwork
# 같은 네트워크의 컨테이너는 이름으로 통신 가능
docker run -d --name mysql --network mynetwork mysql:8
docker run -d --name myapp --network mynetwork \
-e DB_HOST=mysql \ # 컨테이너 이름으로 접근
myapp:1.0
네트워크 드라이버:
bridge(기본): 단일 호스트 내 컨테이너 간 통신host: 컨테이너가 호스트 네트워크 직접 사용 (성능 최대)overlay: 멀티 호스트 (Swarm, Kubernetes)none: 네트워크 완전 격리
볼륨 (Volume)
컨테이너는 기본적으로 상태를 유지하지 않습니다(Stateless). 데이터 영속성을 위해 볼륨이 필요합니다.
# Named Volume (권장)
docker volume create mydata
docker run -v mydata:/var/lib/mysql mysql:8
# Bind Mount (개발 시 편리)
docker run -v /host/path:/container/path myapp
# tmpfs (임시, 메모리에만)
docker run --tmpfs /tmp myapp
docker-compose
여러 컨테이너를 선언적으로 정의하고 한 번에 관리합니다.
# docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=docker
- DB_HOST=mysql
- REDIS_HOST=redis
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
networks:
- backend
restart: unless-stopped
deploy:
resources:
limits:
memory: 512m
cpus: '0.5'
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: mydb
MYSQL_USER: appuser
MYSQL_PASSWORD: apppass
volumes:
- mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 256mb
volumes:
- redis_data:/data
networks:
- backend
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- app
networks:
- backend
volumes:
mysql_data:
redis_data:
networks:
backend:
driver: bridge
# 전체 스택 시작
docker-compose up -d
# 로그 확인
docker-compose logs -f app
# 특정 서비스만 재시작
docker-compose restart app
# 스케일 아웃
docker-compose up -d --scale app=3
# 전체 종료 및 볼륨 삭제
docker-compose down -v
실무 베스트 프랙티스
1. 레이어 캐시 최적화
# 나쁜 예: 소스 변경 시 의존성도 재다운로드
COPY . .
RUN gradle bootJar
# 좋은 예: 의존성 먼저 복사 → 소스 복사
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
RUN gradle dependencies --no-daemon # 의존성 레이어 캐시
COPY src ./src
RUN gradle bootJar --no-daemon # 앱만 재빌드
2. 컨테이너 리소스 제한
docker run -d \
--memory="512m" \
--cpus="0.5" \
--pids-limit=100 \
myapp:1.0
3. 헬스체크
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
극한 시나리오
시나리오: 프로덕션에서 컨테이너 메모리 OOM
증상: 컨테이너가 갑자기 죽고 재시작을 반복합니다.
# 컨테이너 상태 확인
docker inspect myapp | grep -A5 OOMKilled
# "OOMKilled": true
# 원인: JVM이 컨테이너 메모리 제한을 인식 못하고 Host 메모리 기준으로 Heap 설정
해결:
# Java 8u191+ / Java 11+는 컨테이너 메모리 자동 인식
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \ # 컨테이너 메모리의 75% 사용
"-jar", "app.jar"]
시나리오: 이미지 용량 폭발
docker images
# myapp latest 2.1GB ← 문제
# 원인 진단
docker history myapp:latest
# 해결: 멀티스테이지 빌드 + Alpine 베이스
# Before: openjdk:17 (600MB) + 빌드 산출물 = 2.1GB
# After: eclipse-temurin:17-jre-alpine + 멀티스테이지 = 150MB