[심화편] Docker
- [기초편] Docker
- [기본편] Docker
- [심화편] Docker
기본편까지는 개발 환경에서 Docker를 쓰는 방법을 알아봤다.
이번엔 실제 실무에서 마주칠 수 있는 문제들과 그 해결 방법을 알아보려고 한다.
1. 멀티스테이지 빌드
문제: 이미지가 너무 크다
기초편에서 작성한 Dockerfile을 다시 보자.
1
2
3
4
5
FROM openjdk:21-jdk-slim
WORKDIR /app
COPY build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
이 방식은 로컬에서 ./gradlew bootJar로 미리 빌드한 뒤, jar만 컨테이너에 넣어서 실행하는 구조다.
빌드 -> 로컬
실행 -> 컨테이너
근데 CI/CD 환경에서는 빌드도 컨테이너 안에서 해야 할 때가 있다. 그럼 이렇게 된다.
1
2
3
4
5
FROM openjdk:21-jdk-slim
WORKDIR /app
COPY . .
RUN ./gradlew bootJar # 빌드 도구, 소스 코드, 캐시 전부 이미지에 남음
ENTRYPOINT ["java", "-jar", "build/libs/app.jar"]
문제는 여기서 발생한다.
- Gradle
- 소스 코드
- 의존성 캐시
이게 전부 최종 이미지에 그대로 남는다.
실행할 때는 jar 하나면 충분한데, 불필요한 것까지 다 들어가니까 이미지가 커질 수밖에 없다.
멀티스테이지 빌드로 해결
빌드 단계와 실행 단계를 분리한다.
1
2
3
4
5
6
7
8
9
10
11
12
# 1단계: 빌드
FROM gradle:8-jdk21 AS builder
WORKDIR /app
COPY . .
RUN gradle bootJar --no-daemon
# 2단계: 실행
FROM openjdk:21-jdk-slim
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
| 명령어 | 설명 |
|---|---|
FROM gradle:8-jdk21 AS builder | 빌드용 베이스 이미지 사용, builder라는 이름으로 단계 구분 |
WORKDIR /app | 컨테이너 내부 작업 디렉토리 설정 |
COPY . . | 현재 프로젝트 파일 전체를 컨테이너로 복사 |
RUN gradle bootJar --no-daemon | 컨테이너 내부에서 빌드 실행 (jar 생성) |
FROM openjdk:21-jdk-slim | 실행용으로 가벼운 이미지 사용 |
COPY --from=builder ... | builder 단계에서 jar 파일만 가져오기 |
EXPOSE 8080 | 컨테이너가 사용할 포트 명시 |
ENTRYPOINT [...] | 컨테이너 시작 시 실행할 명령어 |
- 1단계 (builder): Gradle로 jar를 빌드한다. 빌드 도구와 소스 코드가 여기 있다.
- 2단계: builder에서 jar 파일만 꺼내서 실행 환경을 만든다.
최종 이미지에는 openjdk와 app.jar만 남는다. 빌드 과정의 흔적이 전혀 없다.
1
2
기존 방식: ~800MB (소스 + Gradle + 캐시 + jar)
멀티스테이지: ~250MB (JDK + jar만)
2. 이미지 최적화
.dockerignore
docker build 를 하면 현재 디렉토리 전체가 Docker에 전달된다. 그래서 필요 없는 파일까지 다 포함되는데, 이걸 .dockerignore로 걸러준다.
1
2
3
4
5
6
7
# .dockerignore
.git
.gradle
build
out
*.md
.DS_Store
이렇게 안해두면 코드를 바꾸지 않았는데 캐시가 계속 깨지는 현상이 발생할 수 있다. 특히 build 디렉토리가 포함되면 매번 새로 빌드하는 상황이 발생된다.
베이스 이미지 선택
같은 Java 21이라도 베이스 이미지에 따라 크기 차이가 크다.
| 이미지 | 크기 | 특징 |
|---|---|---|
openjdk:21 | ~600MB | 풀 JDK, 개발 도구 포함 |
openjdk:21-jdk-slim | ~250MB | 불필요한 패키지 제거 |
eclipse-temurin:21-jre | ~200MB | JRE만 포함 (실행 전용) |
eclipse-temurin:21-jre-alpine | ~100MB | Alpine 리눅스 기반, 가장 가벼움 |
프로덕션에서는 실행만 하면 되므로 JDK 대신 JRE 기반 이미지를 쓰는 게 좋다.
Alpine은 가장 가볍지만 musl libc를 쓰기 때문에 일부 라이브러리와 호환성 문제가 생길 수 있어서 검증이 필요하다.
3. 환경 변수 분리 (.env)
문제: 비밀번호를 파일에 직접 박으면 안 된다
기본편에서 작성한 docker-compose.yml을 보면 이런 부분이 있다.
1
2
3
environment:
MYSQL_ROOT_PASSWORD: 1234
MYSQL_DATABASE: testdb
이 파일을 Git에 올리면 비밀번호가 그대로 노출된다. 실무에서는 절대 이렇게 쓰면 안 된다.
.env 파일로 분리
프로젝트 루트에 .env 파일을 만들고 민감한 값을 분리한다.
1
2
3
# .env
MYSQL_ROOT_PASSWORD=실제비밀번호
MYSQL_DATABASE=mydb
docker-compose.yml에서는 변수를 참조한다.
1
2
3
4
5
6
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
Docker Compose는 같은 디렉토리의 .env 파일을 자동으로 읽어서 변수를 채워준다.
그리고 .env는 반드시 .gitignore에 추가한다.
1
2
# .gitignore
.env
대신 .env.example 파일을 Git에 올려서 어떤 변수가 필요한지 알려주는 것이 관례다.
1
2
3
# .env.example (Git에 올리는 파일)
MYSQL_ROOT_PASSWORD=
MYSQL_DATABASE=
4. depends_on의 한계와 healthcheck
문제: 컨테이너가 떴다고 앱이 준비된 게 아니다
기본편에서 depends_on으로 실행 순서를 지정했다.
1
2
3
app:
depends_on:
- mysql
그런데 이건 MySQL 컨테이너가 시작되는 순서만 보장한다.
MySQL 프로세스가 실제로 커넥션을 받을 준비가 됐는지는 보장하지 않는다.
1
2
3
4
5
MySQL 컨테이너 시작 ──→ app 컨테이너 시작
↓
DB 연결 시도 (MySQL 아직 초기화 중)
↓
연결 실패
healthcheck로 해결
MySQL이 실제로 준비됐는지 확인하는 healthcheck를 추가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s # 5초마다 확인
timeout: 5s # 5초 안에 응답 없으면 실패
retries: 5 # 5번 실패하면 unhealthy
start_period: 30s # 처음 30초는 실패해도 카운트 안 함
app:
build: .
depends_on:
mysql:
condition: service_healthy # healthy 상태일 때만 시작
| 옵션 | 설명 |
|---|---|
services | 실행할 컨테이너들을 정의하는 영역 |
mysql | 서비스 이름 (컨테이너 식별용) |
image: mysql:8.0 | 사용할 Docker 이미지와 태그 |
environment | 컨테이너 내부 환경 변수 설정 |
MYSQL_ROOT_PASSWORD | MySQL root 비밀번호 설정 |
MYSQL_DATABASE | 컨테이너 시작 시 생성할 DB 이름 |
${변수} | .env 파일에서 값을 가져옴 |
healthcheck | 컨테이너가 정상 동작하는지 확인하는 설정 |
test | 상태 확인을 위한 명령어 |
interval | 헬스체크 실행 주기 |
timeout | 응답을 기다리는 최대 시간 |
retries | 실패 허용 횟수 |
start_period | 초기 실행 시 유예 시간 |
app | 애플리케이션 서비스 |
build: . | 현재 디렉토리의 Dockerfile로 이미지 빌드 |
depends_on | 다른 컨테이너와의 의존 관계 설정 |
condition: service_healthy | 대상 컨테이너가 healthy 상태일 때만 시작 |
condition: service_healthy를 쓰면 MySQL이 healthcheck를 통과한 뒤에 app이 뜬다.
5. 로깅
컨테이너 로그가 쌓이면 디스크가 찬다
Docker는 기본적으로 컨테이너 로그를 호스트의 json 파일로 저장한다.
별도 설정 없이 운영하면 로그가 무한정 쌓여서 디스크가 가득 찰 수 있다.
로그 용량 제한
docker-compose.yml에서 로그 드라이버 옵션으로 용량을 제한한다.
1
2
3
4
5
6
7
8
services:
app:
build: .
logging:
driver: "json-file"
options:
max-size: "50m" # 파일 하나당 최대 50MB
max-file: "5" # 최대 5개 파일 (총 250MB)
| 옵션 | 설명 |
|---|---|
services | 실행할 컨테이너들을 정의하는 영역 |
app | 애플리케이션 서비스 이름 |
build: . | 현재 디렉토리의 Dockerfile로 이미지 빌드 |
logging | 컨테이너 로그 관련 설정 |
driver | 로그를 저장하는 방식 (여기서는 json 파일로 저장) |
options | 로그 드라이버의 세부 설정 |
max-size | 로그 파일 하나의 최대 크기 |
max-file | 유지할 로그 파일 개수 |
파일이 max-size를 넘으면 새 파일을 만들고, max-file 개수를 초과하면 오래된 파일부터 삭제한다.
로그 확인 명령어
1
2
3
4
5
6
7
8
# 실시간 로그
docker compose logs -f app
# 최근 100줄만
docker compose logs --tail=100 app
# 특정 시간 이후 로그
docker compose logs --since="2026-03-31T00:00:00" app
6. 컨테이너 자동 재시작 (restart policy)
문제: 컨테이너가 죽으면 수동으로 올려야 한다
별도 설정이 없으면 컨테이너가 OOM이나 예외로 종료됐을 때 자동으로 다시 뜨지 않는다.
restart 옵션을 주어 자동으로 컨테이너를 올릴 수 있다.
restart 옵션
1
2
3
4
services:
app:
build: .
restart: unless-stopped
| 옵션 | 동작 |
|---|---|
no (기본값) | 재시작 안 함 |
always | 항상 재시작 (수동으로 stop해도 재시작) |
unless-stopped | 수동으로 stop한 경우만 제외하고 재시작 |
on-failure | 비정상 종료(exit code != 0)일 때만 재시작 |
프로덕션에서는 보통 unless-stopped를 쓴다.
always는 docker stop으로 명시적으로 내렸는데도 다시 뜨기 때문에 운영하기 불편하다.
서버 자체가 재부팅됐을 때도 unless-stopped는 자동으로 컨테이너를 다시 올린다.
7. 리소스 제한 (메모리 / CPU)
문제: 한 컨테이너가 서버 자원을 전부 먹을 수 있다
리소스 제한이 없으면 트래픽이 몰리거나 메모리 누수가 생겼을 때 한 컨테이너가 서버 전체 자원을 사용할 수 있다. 그러면 같은 서버에서 돌고 있는 다른 컨테이너까지 함께 죽는 경우가 발생한다.
리소스 제한 설정
1
2
3
4
5
6
7
8
9
10
11
services:
app:
build: .
deploy:
resources:
limits:
memory: 512m # 최대 512MB
cpus: "1.0" # 최대 1코어
reservations:
memory: 256m # 최소 보장 메모리
cpus: "0.5" # 최소 보장 CPU
limits: 이 값을 초과하면 Docker가 강제로 제한한다. 메모리를 초과하면 컨테이너가 OOMKilled된다.reservations: 이 자원은 항상 이 컨테이너를 위해 확보해둔다.
EC2 t3.small (2GB RAM)에서 여러 컨테이너를 띄운다면 각 컨테이너에 적절히 나눠서 제한을 걸어야 한다. 제한 없이 운영하다 메모리 부족으로 서버 전체가 죽는 경우가 실무에서 꽤 자주 생긴다.
JVM 메모리 설정 주의
컨테이너에 메모리 제한을 걸면 JVM 설정도 같이 맞춰줘야 한다. JVM은 기본적으로 호스트 전체 메모리를 기준으로 힙을 잡기 때문에, 컨테이너 제한을 모르고 큰 힙을 잡으면 OOMKilled된다.
1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
-XX:MaxRAMPercentage=75.0은 컨테이너에 할당된 메모리의 75%를 힙으로 사용하도록 JVM에 알려준다. 컨테이너 메모리 제한을 인식해서 자동으로 조정해준다.
정리
| 주제 | 핵심 |
|---|---|
| 멀티스테이지 빌드 | 빌드/실행 환경 분리 → 이미지 크기 대폭 감소 |
| .dockerignore | 불필요한 파일 제외 → 빌드 속도 향상, 캐시 안정화 |
| 베이스 이미지 | 프로덕션은 JRE slim 기반 사용 |
| .env 분리 | 민감한 값은 파일에 직접 박지 않기 |
| healthcheck | depends_on만으로는 부족, 실제 준비 완료 보장 |
| 로그 용량 제한 | max-size, max-file 설정으로 디스크 보호 |
| restart policy | unless-stopped로 자동 재시작 보장 |
| 리소스 제한 | 메모리/CPU 제한으로 서버 전체 장애 방지 |