학습 목표
Docker Compose의 개념과 사용 목적을 설명할 수 있다.
docker-compose.yml파일의 주요 옵션을 작성할 수 있다..env파일과 Docker Secrets로 비밀값을 안전하게 관리할 수 있다.Nginx 리버스 프록시로 앱 포트를 외부에 노출하지 않는 구조를 구성할 수 있다.
condition: service_healthy로 서비스 시작 순서를 안전하게 제어할 수 있다.docker compose up/down명령어로 다중 컨테이너 환경을 실행·종료할 수 있다.
Docker Compose는 여러 컨테이너로 구성된 애플리케이션을 하나의 YAML 파일(docker-compose.yml)로 정의하고, 단일 명령어로 실행·관리하는 도구이다. 웹 서버 + 애플리케이션 + 데이터베이스처럼 여러 서비스가 함께 동작하는 환경을 구성할 때 특히 유용하다.
docker-compose(하이픈) vs docker compose(스페이스): 예전에는 docker-compose 라는 별도 파이썬 도구를 따로 설치해서 썼다. 지금은 Docker CLI에 내장된 플러그인을 docker compose 처럼 띄어 써서 호출한다. 인터넷 예제에 하이픈 형태가 아직 많이 보이지만, 신규 환경에서는 띄어 쓴 형태를 사용한다.
버전도 함께 기억해 두자. 내장 플러그인은 Docker 엔진과 별개의 번호를 쓰며 현재는 v5.x 다. docker compose version 으로 확인할 수 있다. 오래된 글에 나오는 "Compose V2" 는 하이픈 도구와 구분하려던 당시의 이름이고, 지금 기준으로는 버전 번호가 아니라 명령어 형태로 구분하는 편이 정확하다.
💡 docker run 여러 개 vs docker compose — 왜 Compose를 쓰는가?
Nginx + Spring Boot + MySQL로 구성된 앱을 docker run으로 직접 실행하면 아래와 같다.
# docker run 방식 — 명령어 3개를 순서대로 직접 실행
docker network create app-net
docker volume create db-data
docker run -d --name db --network app-net \
-e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=visitdb \
-v db-data:/var/lib/mysql mysql:8.4
docker run -d --name app --network app-net \
-e SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/visitdb \
visit-counter:1.0
docker run -d --name nginx --network app-net \
-p 80:80 -v ./nginx.conf:/etc/nginx/conf.d/default.conf \
nginx:1.30docker compose 방식 — 단 한 줄
docker compose up -dCompose를 쓰면 실행 순서, 네트워크, 볼륨 생성이 자동으로 처리되고, 설정이 파일로 관리되어 팀 공유와 재현이 쉬워진다.
1. 기본 개념
개념 | 설명 |
|---|---|
서비스(Service) | 컨테이너 하나의 실행 단위. 웹 서버, DB, 캐시 등을 각각 서비스로 정의한다. |
네트워크(Network) | 서비스 간 통신에 사용되는 가상 네트워크. Compose가 자동으로 생성한다. |
볼륨(Volume) | 데이터를 영구적으로 저장하기 위한 공간. 컨테이너 삭제 후에도 데이터가 유지된다. |
시크릿(Secret) | 비밀번호 등 민감한 값을 파일로 안전하게 관리하는 메커니즘. |
2. docker-compose.yml 작성
2-1. 파일 기본 구조
services:
서비스명1:
# 서비스 설정
서비스명2:
# 서비스 설정
networks:
# 네트워크 설정 (선택)
volumes:
# 볼륨 설정 (선택)
secrets:
# 시크릿 설정 (선택)파일 상단의 version: 키는 생략한다. 내장 docker compose 는 이 키를 무시하며, 남겨 두면 사용되지 않는 키라는 경고가 나온다.
2-2. 주요 옵션 설명
image — 사용할 이미지 지정
services:
db:
image: mysql:8.4build — Dockerfile로 이미지 직접 빌드
services:
app:
build:
context: ./app
dockerfile: Dockerfileports — 무엇을 외부에 열 것인가
services:
nginx:
image: nginx:1.30
ports:
- "80:80" # 호스트 80 → 컨테이너 80 (외부에서 접근 가능)
app:
build: ./app
# ports 없음 → 호스트에 발행되지 않아 외부에서 직접 접근할 수 없다.
# 같은 네트워크의 nginx 는 http://app:8080 으로 접근한다.외부 노출은 ports 하나로 결정된다
ports를 쓴 포트만 호스트에 발행되어 외부에서 접근할 수 있다.ports를 쓰지 않으면 외부에서 닿을 수 없다. 반면 같은 네트워크의 다른 컨테이너는 그 포트에 그대로 접근할 수 있다. 컨테이너끼리 통신하려고ports를 열 필요는 없다.
expose:라는 키도 있지만 문서화용 메타데이터일 뿐 접근을 제한하지 않는다. 선언해도 막히는 것이 없고 지워도 달라지는 것이 없으므로, 포트를 닫는 용도로 쓰면 안 된다.
실무에서는 Nginx만 ports로 외부에 열고, 앱·DB에는 ports를 두지 않는 것이 보안 기본이다.
environment — 환경 변수 직접 설정 (개발 환경용)
services:
db:
environment:
MYSQL_DATABASE: visitdb
MYSQL_USER: visituser
# 비밀번호는 직접 넣지 않는다 → env_file 또는 secrets 사용env_file — 환경 변수를 파일에서 로드
services:
db:
env_file:
- .env # .env 파일의 모든 변수를 환경 변수로 주입.env 파일은 반드시 .gitignore에 추가해야 한다. 비밀번호가 Git에 커밋되면 히스토리에 영구 기록된다. 팀원을 위해 키 이름만 담은 .env.example 파일을 함께 제공한다.
.env 는 두 가지 일을 한다 — 헷갈리기 쉽다.
|
| |
|---|---|---|
누가 읽나 | Compose 자신이 YAML 을 해석할 때 | Compose 가 읽어 컨테이너에 넘긴다 |
어떤 파일 | 프로젝트 폴더의 |
|
결과 | YAML 안의 | 파일의 모든 변수가 컨테이너 환경 변수가 된다 |
아래 종합 실습의 compose 파일은 둘을 다 쓴다. app 의 jdbc:mysql://db:3306/${MYSQL_DATABASE} 는 치환이고, db 의 env_file: - .env 는 주입이다. 같은 .env 를 서로 다른 방식으로 두 번 읽는 셈이다.
치환은 컨테이너가 뜨기 전에 끝나므로 docker compose config 로 결과를 미리 볼 수 있다. .env 를 빠뜨리면 실행 전에 이렇게 경고한다.
level=warning msg="The \"MYSQL_DATABASE\" variable is not set. Defaulting to a blank string."치환 쪽은 경고일 뿐 멈추지 않는다는 점이 함정이다. URL 이 jdbc:mysql://db:3306/ 처럼 DB 이름 없이 만들어진 채 진행된다. 이 실습 파일은 env_file: 이 같은 .env 를 요구하므로 up 에서 env file ... .env not found 로 멈춰 주지만, env_file: 없이 치환만 쓰는 compose 파일이라면 빈 값으로 그대로 뜬다.
secrets — 민감한 값을 파일로 안전하게 관리
services:
db:
secrets:
- mysql_root_password # /run/secrets/mysql_root_password 로 마운트됨
environment:
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password
secrets:
mysql_root_password:
file: ./secrets/mysql_root_password.txt # 실제 값이 담긴 파일구분 | env_file | secrets |
|---|---|---|
저장 방식 | 환경 변수 |
|
| 노출됨 | 노출 안 됨 |
컨테이너 내 접근 |
|
|
주 사용처 | 개발 환경 | 운영 환경 |
volumes — 볼륨 또는 디렉토리 마운트
services:
nginx:
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro # 읽기 전용 바인드 마운트
db:
volumes:
- db-data:/var/lib/mysql # Named Volumerestart — 컨테이너 재시작 정책
옵션 | 설명 |
|---|---|
| 재시작하지 않음 (기본값) |
| 항상 재시작 |
| 오류 종료 시에만 재시작 |
| 수동 중지 전까지 항상 재시작 |
depends_on — 서비스 시작 순서 + 준비 상태 대기
services:
app:
depends_on:
db:
condition: service_healthy # DB 헬스체크 통과 후 app 시작
db:
image: mysql:8.4
healthcheck:
# -h 127.0.0.1 로 TCP 를 확인한다. localhost 로 하면 아래 경고의 함정에 걸린다.
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 10s
timeout: 5s
retries: 10
start_period: 40sMySQL 헬스체크를 -h localhost 로 쓰면 안 된다. 흔히 보이는 형태지만 두 가지 이유로 DB가 준비되기 전에 통과해 버린다.
localhost는 유닉스 소켓으로 붙는다. MySQL 공식 이미지는 최초 기동 시 DB를 초기화하려고 임시 서버를 먼저 띄우는데, 이 서버는 소켓만 열고 TCP 3306 은 닫아 둔다(port: 0). 소켓으로 검사하면 이 임시 서버에 성공해 버린다.mysqladmin ping은 인증에 실패해도 0을 반환한다.Access denied가 떠도 "서버는 살아 있다"로 판정된다.
이 조합 때문에 첫 실행에서 app 이 Connection refused 로 죽고, 그 여파로 nginx 가 아예 기동하지 않는 일이 생긴다. -h 127.0.0.1 로 TCP 를 확인하면 임시 서버 구간을 건너뛴다. 초기화가 오래 걸릴 수 있으므로 start_period 도 넉넉히 준다.
condition: service_started(기본값)는 컨테이너 시작만 확인하고, DB가 실제로 쿼리를 받을 준비가 됐는지는 보장하지 않는다. DB 연동이 있는 경우 반드시 condition: service_healthy와 healthcheck를 함께 사용한다.
healthcheck — 서비스 준비 상태 체크
services:
app:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 15s # 체크 간격
timeout: 5s # 타임아웃
retries: 5 # 실패 허용 횟수
start_period: 30s # 기동 유예 시간 (이 시간 동안은 실패해도 unhealthy 아님)3. docker compose 명령어
명령어 | 설명 |
|---|---|
| 서비스 시작 (이미지 없으면 빌드 또는 pull 포함) |
| 백그라운드 모드로 시작 |
| 강제로 이미지 재빌드 후 시작 |
| 서비스 중지 및 컨테이너 삭제 |
| 서비스 중지 + 볼륨도 함께 삭제 |
| 서비스 중지 + 빌드 이미지도 삭제 |
| 실행 중인 서비스 목록 및 헬스 상태 조회 |
| 전체 서비스 로그 확인 |
| 특정 서비스 로그 실시간 출력 |
| 타임스탬프 포함 로그 출력 |
| 서비스 중지 (컨테이너 유지) |
| 중지된 서비스 시작 |
| 서비스 재시작 |
| 실행 중인 서비스 컨테이너에 명령 실행 |
| 서비스 컨테이너를 새로 실행하여 명령 수행 |
| 최종 병합 설정 확인 (환경 변수 치환 결과 포함) |
| 서비스 이미지 내려받기 |
| 서비스 이미지 빌드 |
| 서비스를 N개로 수평 확장 |
4. 종합 실습 — Nginx + Spring Boot + MySQL 3-tier 구성
단순 Nginx + DB 조합을 넘어, 실무에 가까운 3-tier 구조를 직접 구성한다.
최종 아키텍처
[브라우저]
↓ :80 (유일하게 외부에 열린 포트)
┌──────────────────────────────┐
│ nginx (리버스 프록시) │
│ - 정적 파일 직접 서빙 │
│ - / → app:8080 프록시 │
└──────────────┬───────────────┘
↓ :8080 (내부 네트워크 전용)
┌──────────────────────────────┐
│ visit-counter (Spring Boot) │
│ - MySQL에 방문 횟수 저장 │
└──────────────┬───────────────┘
↓ :3306 (내부 네트워크 전용)
┌──────────────────────────────┐
│ db (MySQL 8.4) │
│ - 패스워드: Docker Secrets │
└──────────────────────────────┘Step 0. 프로젝트 구조 준비
visit-counter-compose/
├── docker-compose.yml
├── .env ← Git 제외 (실제 비밀값)
├── .env.example ← Git 포함 (키 이름만, 신규 팀원 온보딩용)
├── .gitignore
├── secrets/
│ ├── mysql_root_password.txt ← Docker Secrets 파일
│ └── mysql_user_password.txt
├── nginx/
│ └── nginx.conf
└── app/ ← visit-counter 앱 (MySQL 연동)
├── build.gradle
├── settings.gradle
├── Dockerfile
├── docker-entrypoint.sh ← Secrets 를 환경변수로 넘기는 스크립트
└── src/main/
├── java/com/example/
│ ├── VisitCounterApplication.java
│ ├── VisitCount.java
│ └── VisitCountRepository.java
└── resources/
└── application.yml# 디렉토리 생성 (macOS / Linux / WSL / Git Bash)
mkdir -p visit-counter-compose/{secrets,nginx,app/src/main/{java/com/example,resources}}
cd visit-counter-compose# Windows PowerShell — 중괄호 확장이 없으므로 경로를 하나씩 적는다
"secrets","nginx","app/src/main/java/com/example","app/src/main/resources" |
ForEach-Object { New-Item -ItemType Directory -Force -Path "visit-counter-compose/$_" | Out-Null }
cd visit-counter-composeStep 1. 비밀값 파일 준비
secrets/ 파일 생성
echo 'RootPassword!123' > secrets/mysql_root_password.txt
echo 'UserPassword!456' > secrets/mysql_user_password.txt# Windows PowerShell — echo 와 > 를 쓰지 않는다 (아래 경고 참고)
Set-Content -Path secrets/mysql_root_password.txt -Value 'RootPassword!123' -NoNewline -Encoding ascii
Set-Content -Path secrets/mysql_user_password.txt -Value 'UserPassword!456' -NoNewline -Encoding asciiWindows PowerShell 5.1 의 echo ... > 는 비밀번호 파일을 망가뜨린다. 리다이렉션(>)이 파일을 UTF-16 으로 쓰고, 맨 앞에 BOM 을, 끝에 CRLF 를 붙인다. Ab!1 을 써 보면 실제 바이트가 이렇다.
echo 'Ab!1' > a.txt → FF FE 41 00 62 00 21 00 31 00 0D 00 0A 00
Set-Content ... -Encoding ascii → 41 62 21 31화면에서 cat 으로 보면 멀쩡해 보이지만, 컨테이너가 읽는 값은 보이지 않는 바이트가 섞인 다른 비밀번호다. 위 Set-Content -NoNewline -Encoding ascii 를 쓰거나, WSL·Git Bash 에서 bash 명령을 그대로 쓴다.
여기서 큰따옴표를 쓰면 안 된다. echo "RootPassword!123" 을 터미널에 직접 입력하면 아래처럼 실패한다.
bash: !123: event not found대화형 bash·zsh 는 히스토리 확장(histexpand)이 켜져 있어 ! 뒤의 문자열을 "과거 명령어 불러오기"로 해석한다. 큰따옴표는 ! 를 보호하지 못하고 작은따옴표만 보호한다.
성가신 점은 실패 시점이 아니라 한참 뒤에 드러난다는 것이다. 파일이 아예 만들어지지 않은 채 진행하면 docker compose up 단계에서 시크릿 파일을 찾지 못한다는 엉뚱한 오류를 보게 된다. 스크립트 파일로 실행할 때와 PowerShell 에서는 히스토리 확장이 없어 큰따옴표로도 동작하므로, 교안을 따라 하다 이 차이에 걸리기 쉽다.
# 파일이 제대로 만들어졌는지 확인
cat secrets/mysql_root_password.txt
# RootPassword!123.env 파일 작성 — Git에 절대 커밋 금지
# .env
MYSQL_DATABASE=visitdb
MYSQL_USER=visituser.env.example 파일 작성 — Git에 포함, 신규 팀원 온보딩용
# .env.example
MYSQL_DATABASE=visitdb
MYSQL_USER=visituser.gitignore 작성
# .gitignore
.env
secrets/왜 .env.example이 필요한가? .env는 Git에 올리지 않으므로 새 팀원이 프로젝트를 clone하면 어떤 변수가 필요한지 알 수 없다. .env.example은 키 이름만 담아 Git에 포함시켜 cp .env.example .env 후 값만 채우면 바로 실행할 수 있게 안내한다.
Step 2. Spring Boot 앱 코드 (MySQL 연동)
visit-counter의 카운트를 MySQL DB에 저장하도록 확장한다.
app/settings.gradle
rootProject.name = 'visit-counter'이 파일이 없으면 빌드가 깨진다. 아래 Dockerfile 은 COPY settings.gradle build.gradle ./ 로 이 파일을 콕 집어 복사하므로, 파일이 없으면 그 자리에서 멈춘다.
ERROR: failed to calculate checksum of ref ...: "/settings.gradle": not found파일은 있는데 rootProject.name 줄이 빠져도 문제다. 그러면 Gradle 은 프로젝트 이름을 작업 디렉터리 이름에서 가져온다. Dockerfile 의 WORKDIR /build 때문에 산출물이 visit-counter-1.0.0.jar 가 아니라 build-1.0.0.jar 가 되고, 이번에는 JAR 를 복사하는 단계에서 "/build/build/libs/visit-counter-1.0.0.jar": not found 로 멈춘다.
app/build.gradle
plugins {
id 'org.springframework.boot' version '4.1.1'
id 'io.spring.dependency-management' version '1.1.7'
id 'java'
}
group = 'com.example'
version = '1.0.0'
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
repositories { mavenCentral() }
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
runtimeOnly 'com.mysql:mysql-connector-j'
}app/src/main/resources/application.yml
spring:
datasource:
url: ${SPRING_DATASOURCE_URL}
username: ${SPRING_DATASOURCE_USERNAME}
password: ${SPRING_DATASOURCE_PASSWORD} # 환경변수로 주입받음 (아래 참고)
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
server:
port: 8080Spring Boot는 _FILE 접미사를 기본 지원하지 않는다. Docker Secrets는 컨테이너 내부에 /run/secrets/파일명 형태로 마운트되므로, 앱 시작 시 해당 파일을 읽어 SPRING_DATASOURCE_PASSWORD 환경변수에 주입하는 방식을 사용한다. 아래 Dockerfile의 ENTRYPOINT 스크립트가 이 역할을 한다.
VisitCount.java — JPA 엔티티
package com.example;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "visit_count")
public class VisitCount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private long count = 0;
private LocalDateTime updatedAt = LocalDateTime.now();
public long getCount() { return count; }
public void setCount(long count) {
this.count = count;
this.updatedAt = LocalDateTime.now();
}
}VisitCountRepository.java
package com.example;
import org.springframework.data.jpa.repository.JpaRepository;
public interface VisitCountRepository extends JpaRepository<VisitCount, Long> {}VisitCounterApplication.java
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;
@SpringBootApplication
@RestController
public class VisitCounterApplication {
private final VisitCountRepository repo;
public VisitCounterApplication(VisitCountRepository repo) {
this.repo = repo;
}
public static void main(String[] args) {
SpringApplication.run(VisitCounterApplication.class, args);
}
@GetMapping("/")
public String visit() {
VisitCount vc = repo.findAll().stream().findFirst()
.orElseGet(() -> { VisitCount v = new VisitCount(); return repo.save(v); });
vc.setCount(vc.getCount() + 1);
repo.save(vc);
return "방문 횟수: " + vc.getCount() + "\n";
}
@GetMapping("/count")
public String count() {
long c = repo.findAll().stream().findFirst()
.map(v -> v.getCount()).orElse(0L);
return "현재 카운트: " + c + "\n";
}
@GetMapping("/reset")
public String reset() {
repo.findAll().stream().findFirst().ifPresent(v -> {
v.setCount(0); repo.save(v);
});
return "카운터를 초기화했습니다.\n";
}
}app/Dockerfile (멀티 스테이지 빌드 + Secrets 지원)
# ── 1단계: Gradle로 JAR 빌드 ──────────────────────────────
FROM gradle:8.14-jdk21 AS builder
WORKDIR /build
# 의존성 먼저, 소스는 나중에 — 이미지 생성 및 관리 장 6-4 의 캐시 구조
COPY settings.gradle build.gradle ./
RUN gradle dependencies --no-daemon > /dev/null
COPY src ./src
RUN gradle bootJar --no-daemon
# ── 2단계: JRE만 포함한 경량 실행 이미지 ─────────────────
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /build/build/libs/visit-counter-1.0.0.jar app.jar
# Secrets 파일을 읽어 환경변수로 변환하는 엔트리포인트 스크립트
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# 애플리케이션은 root 가 아닌 전용 계정으로 돌린다
RUN groupadd --system app && useradd --system --gid app --no-create-home app
USER app
EXPOSE 8080
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["java", "-jar", "app.jar"]USER app — 앱을 root 로 돌리지 않는다. 이 줄이 없으면 컨테이너 안의 java 는 root 다. 앱에 취약점이 있어 뚫리면 공격자도 컨테이너 안에서 root 가 된다. 이 앱은 8080 포트(1024 이상)를 쓰고 DB 는 네트워크로 붙으므로 root 일 이유가 없다. docker compose exec app id 로 확인하면 uid=999(app) 이 나온다.
리눅스 호스트에서는 Secrets 파일 권한을 확인한다. Compose 의 secrets: 는 호스트 파일을 그대로 붙이므로, 호스트에서 chmod 600 해 둔 파일은 컨테이너 안의 app 사용자가 읽지 못한다. 그러면 비밀번호가 비어 DB 접속이 실패한다. 실습에서는 기본 권한(644) 그대로 둔다.
app/docker-entrypoint.sh
#!/bin/sh
# Docker Secrets는 /run/secrets/<파일명> 형태로 마운트됨
# Spring Boot는 _FILE 접미사를 기본 지원하지 않으므로
# 파일을 읽어 SPRING_DATASOURCE_PASSWORD 환경변수에 직접 주입한다
if [ -f /run/secrets/mysql_user_password ]; then
export SPRING_DATASOURCE_PASSWORD=$(cat /run/secrets/mysql_user_password)
fi
# 전달받은 CMD 실행 (java -jar app.jar)
exec "$@"Windows 에서는 이 파일을 반드시 LF 로 저장한다. 메모장이나 편집기 기본값인 CRLF 로 저장하면 빌드는 성공하는데 실행만 아래 메시지로 실패한다.
exec /docker-entrypoint.sh: no such file or directory파일이 분명히 있는데도 "없다"고 나오는 이유는, 셔뱅 #!/bin/sh 끝의 \r 까지 인터프리터 경로로 읽혀 /bin/sh\r 을 찾기 때문이다. 오류 메시지가 원인을 전혀 알려주지 않아 찾기 어렵다.
VS Code 라면 우측 하단 CRLF 를 눌러 LF 로 바꾸고, Git 을 쓴다면 .gitattributes 에 다음을 넣는다.
*.sh text eol=lf엔트리포인트 스크립트 패턴: MySQL, PostgreSQL 등 공식 이미지는 _FILE 접미사 환경변수를 자체 지원한다. Spring Boot처럼 직접 지원하지 않는 앱에는 이 패턴을 적용하면 Secrets를 안전하게 주입할 수 있다.
이 스크립트가 막아 주는 것과 못 막는 것
스크립트는 파일을 읽어 환경 변수로 바꿔 java 에게 넘긴다. 그래서 docker inspect 의 Config.Env 에도, docker compose exec app env 에도 보이지 않는다. exec 는 컨테이너 설정의 환경 변수로 새 프로세스를 띄우기 때문이다. 하지만 스크립트가 넘긴 값은 실행 중인 java(PID 1) 의 환경에 그대로 있다.
docker compose exec app sh -c 'tr "\0" "\n" < /proc/1/environ | grep PASSWORD'
# SPRING_DATASOURCE_PASSWORD=UserPassword!456inspect 는 Docker 를 다룰 수 있는 사람 누구나 볼 수 있는 곳이고, 컨테이너 안의 프로세스 정보는 그보다 한 단계 안쪽이다. 노출 범위를 줄인 것이지 없앤 것은 아니다. (Git Bash 라면 명령 앞에 MSYS_NO_PATHCONV=1 을 붙인다.)
Spring Boot 다운 방법 — configtree
Spring Boot 는 _FILE 접미사는 모르지만 디렉터리 안의 파일 이름을 설정 키로, 내용을 값으로 읽는 기능(configtree)이 있다. 스크립트 없이 application.yml 에 한 줄만 쓰면 된다.
spring:
config:
import: optional:configtree:/run/secrets/이러면 /run/secrets/mysql_user_password 파일이 mysql_user_password 라는 설정값이 되고, password: ${mysql_user_password} 로 꺼내 쓸 수 있다. 파일 끝의 줄바꿈은 Spring 이 떼어 준다. 환경 변수를 거치지 않으므로 /proc/1/environ 에도 나오지 않는다.
이 교안은 Part 4에서 같은 이미지에 환경 변수로도 비밀번호를 넣어야 해서, 두 경로를 한 스크립트로 처리하는 엔트리포인트 방식을 쓴다.
Step 3. Nginx 리버스 프록시 설정
nginx/nginx.conf
upstream app {
server app:8080; # 서비스 이름으로 접근 (내부 DNS)
}
server {
listen 80;
# Spring Boot 앱으로 프록시
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}리버스 프록시를 쓰는 이유
앱 포트(8080)를 외부에 직접 노출하지 않아 보안 향상
추후 SSL(HTTPS) 인증서를 Nginx에서만 처리 가능 (SSL 종료)
정적 파일은 Nginx가 직접 서빙해 앱 서버 부하 감소
upstream블록에 서버를 추가하는 것만으로 로드밸런싱 가능
Step 4. docker-compose.yml 완성
services:
# ── Nginx : 유일하게 외부에 열린 포트 ───────────────────
nginx:
image: nginx:1.30
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
app:
condition: service_healthy # 앱 헬스체크 통과 후 시작
restart: unless-stopped
# ── Spring Boot 앱 ───────────────────────────────────────
app:
build:
context: ./app
# ports 없음 → 외부 미노출. nginx 가 http://app:8080 으로 접근한다.
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/${MYSQL_DATABASE}
SPRING_DATASOURCE_USERNAME: ${MYSQL_USER}
# SPRING_DATASOURCE_PASSWORD 는 entrypoint 스크립트가 Secrets 파일에서 읽어 주입
secrets:
- mysql_user_password
depends_on:
db:
condition: service_healthy # DB 헬스체크 통과 후 시작
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 40s # Spring Boot 기동 시간 여유
restart: unless-stopped
# ── MySQL ────────────────────────────────────────────────
db:
image: mysql:8.4
# ports 없음 → 외부 미노출 (보안)
env_file:
- .env # MYSQL_DATABASE, MYSQL_USER 주입
environment:
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password
MYSQL_PASSWORD_FILE: /run/secrets/mysql_user_password
secrets:
- mysql_root_password
- mysql_user_password
volumes:
- db-data:/var/lib/mysql
healthcheck:
# -h 127.0.0.1 로 TCP 를 확인한다. localhost 로 하면 아래 경고의 함정에 걸린다.
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 10s
timeout: 5s
retries: 10
start_period: 40s
restart: unless-stopped
volumes:
db-data:
secrets:
mysql_root_password:
file: ./secrets/mysql_root_password.txt
mysql_user_password:
file: ./secrets/mysql_user_password.txtMYSQL_ROOT_PASSWORD_FILE — MySQL 공식 이미지는 _FILE 접미사 환경변수를 지원한다. 일반 환경변수 대신 파일 경로를 지정하면 MySQL이 해당 파일에서 값을 읽는다. docker inspect로 환경변수를 조회해도 실제 비밀번호가 보이지 않는다.
MySQL 8.4로 올릴 때 자주 겪는 인증 오류
MySQL 8.4부터 mysql_native_password 인증 플러그인이 기본 비활성화됐다. 오래된 DB 툴이나 구버전 드라이버로 접속하면 아래 오류가 난다.
Authentication plugin 'mysql_native_password' cannot be loaded해결 순서
드라이버·툴을 최신 버전으로 올린다 (권장). 최근의
mysql-connector-j와 DBeaver·Workbench 최신 버전은 기본 인증 방식인caching_sha2_password를 지원한다. 위build.gradle처럼runtimeOnly 'com.mysql:mysql-connector-j'만 적으면 Spring Boot BOM 이 버전을 관리하므로 버전을 직접 적을 필요가 없다. 어떤 버전이 실제로 들어왔는지는app/에서docker run --rm -v "$PWD":/build -w /build gradle:8.14-jdk21 gradle dependencies --configuration runtimeClasspath --no-daemon으로 확인한다. 이 프로젝트에는 Gradle Wrapper(./gradlew)가 없으므로 빌드 때와 같은 Gradle 이미지를 쓴다.당장 올릴 수 없다면 컨테이너 실행 옵션으로 예전 플러그인을 켠다.
db:
image: mysql:8.4
command: --mysql-native-password=ON2번은 임시방편이다. caching_sha2_password가 보안상 더 강력하므로 가급적 1번으로 해결한다.
Step 5. 실행 및 헬스체크 흐름 확인
# 빌드 + 백그라운드 실행
docker compose up -d --build
# 서비스 상태 확인 (healthy가 될 때까지 관찰)
docker compose ps
# 예상 출력 (NAME / STATUS / PORTS 만 발췌 — 실제로는 아래 설명의 컬럼이 모두 나온다):
# NAME STATUS PORTS
# visit-counter-compose-db-1 Up 33 seconds (healthy) 3306/tcp, 33060/tcp
# visit-counter-compose-app-1 Up 12 seconds (healthy) 8080/tcp
# visit-counter-compose-nginx-1 Up 2 seconds 0.0.0.0:80->80/tcp, [::]:80->80/tcp실제 docker compose ps 는 NAME / IMAGE / COMMAND / SERVICE / CREATED / STATUS / PORTS 일곱 컬럼을 모두 출력한다. 위는 지금 볼 부분만 잘라낸 것이다. STATUS 는 Up 33 seconds (healthy) 처럼 기동 경과 시간과 헬스 상태를 함께 보여주므로, 여기서 (healthy) 가 뜨는 순서를 관찰하면 된다. 터미널이 좁아 줄이 밀리면 docker compose ps --format 'table {{.Name}}\t{{.Status}}\t{{.Ports}}' 로 필요한 컬럼만 볼 수 있다.
서비스 시작 순서가 자동으로 제어된다.
db 시작 → healthcheck 통과 →
app 시작 → healthcheck 통과 →
nginx 시작# 각 서비스 로그로 시작 순서 확인
docker compose logs --timestampsStep 6. 동작 및 보안 검증
접속 테스트
# Nginx를 통해 정상 접근
curl http://localhost/ # 방문 횟수: 1
curl http://localhost/ # 방문 횟수: 2
curl http://localhost/count # 현재 카운트: 2
# 앱 포트 직접 접근 — 실패해야 정상
curl http://localhost:8080 # Connection refused (외부 미노출)
# DB 포트 직접 접근 — 실패해야 정상
curl http://localhost:3306 # Connection refused (외부 미노출)80 포트(Nginx)로만 접근할 수 있다 — 앱의 8080은 외부에 열려 있지 않다
/actuator/health 가 UP 이어야 Nginx가 기동된다
Secrets 보안 검증 — docker inspect로 비밀번호 노출 여부 확인
# 환경변수 방식이었다면 Env 목록에 비밀번호가 노출되었을 것
docker inspect $(docker compose ps -q app | head -1) | grep -A 20 '"Env"'
# [
# "SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/visitdb",
# "SPRING_DATASOURCE_USERNAME=visituser",
# ... ← 비밀번호가 환경변수에 없음
# ]
# Secrets로 마운트된 파일 내용은 컨테이너 내부에서만 접근 가능
docker compose exec app cat /run/secrets/mysql_user_password
# UserPassword!456Git Bash(Windows)에서는 위 명령이 실패한다.
cat: 'C:/Program Files/Git/run/secrets/mysql_user_password': No such file or directoryMSYS 가 /run/... 을 윈도우 경로로 바꿔버리기 때문이다. 슬래시를 두 개 쓰면 변환을 막을 수 있다.
docker compose exec app cat //run/secrets/mysql_user_passwordPowerShell 이나 WSL 터미널에서는 원래 명령이 그대로 동작한다.
Step 7. 설정 검증 — docker compose config
# .env 변수와 secrets 경로가 올바르게 치환됐는지 확인
docker compose config
# 특정 서비스 설정만 확인
docker compose config --services
# nginx
# app
# db
# 환경변수 치환 결과 확인 (비밀번호 값은 _FILE 경로만 보여야 함)
docker compose config | grep -i passwordStep 8. 수평 확장 체험 — --scale
# app 서비스를 3개로 확장
docker compose up -d --scale app=3
# Nginx 가 늘어난 주소를 다시 조회하게 한다
docker compose restart nginx
# 3개의 app 컨테이너 확인
docker compose ps
# Nginx가 요청을 분산하는지 확인
for i in {1..12}; do curl -s -o /dev/null http://localhost/; done
# 어느 컨테이너가 처리했는지는 Nginx 로그의 upstream 주소로 확인한다
docker compose logs nginx | tail -n 12
# 다시 1개로 줄이기
docker compose up -d --scale app=1--scale 을 쓰려면 두 가지가 맞아야 한다.
container_name이 없어야 한다. 이름이 고정된 서비스는 두 번째 컨테이너를 만들 수 없어 확장이 실패한다. 위app서비스에container_name을 두지 않은 이유다.Nginx 를 다시 띄워야 분산된다.
upstream의 호스트 이름은 Nginx 가 기동할 때 한 번만 조회된다. 이미 떠 있는 Nginx 는 나중에 늘어난 컨테이너를 알지 못하므로 확장 뒤restart로 다시 조회하게 한다.
docker compose up -d --force-recreate nginx 는 쓰지 않는다. up 에 서비스 이름을 지정하면 의존 서비스가 compose 파일의 기본 수(1개)로 되돌아가, 방금 늘린 app-2·app-3 이 삭제된다.
Container visit-counter-compose-app-2 Removing
Container visit-counter-compose-app-3 Removingup 을 꼭 써야 한다면 확장 수를 함께 지정한다.
docker compose up -d --scale app=3 --force-recreate nginx어느 컨테이너가 처리했는지 보려면 Nginx 로그에 upstream 주소를 남긴다. 앱은 방문 횟수를 HTTP 응답 본문으로만 돌려주고 로그로 찍지 않으므로, 앱 로그를 뒤져서는 분산 여부를 알 수 없다. nginx/nginx.conf 최상단에 아래를 추가한다.
log_format upstreamlog '$remote_addr -> $upstream_addr "$request" $status';
access_log /var/log/nginx/access.log upstreamlog;요청을 12번 보내면 $upstream_addr 가 세 컨테이너의 IP 로 번갈아 찍힌다.
Step 9. 정리
# 컨테이너 중지 및 삭제 (볼륨 유지 — 데이터 보존)
docker compose down
# 볼륨까지 삭제 (데이터 초기화)
docker compose down -v
# 빌드 이미지까지 전부 삭제
docker compose down -v --rmi all추가 도전 과제
nginx.conf에/static/경로를 추가해 Nginx가 정적 파일을 직접 서빙하도록 설정해보자.docker compose watch(Compose v2.22+)를 사용해 소스 변경 시 자동으로 이미지가 재빌드되는 개발 환경을 구성해보자.MySQL 서비스에 초기 스키마 SQL 파일(
init.sql)을 바인드 마운트해 컨테이너 최초 실행 시 테이블이 자동 생성되도록 해보자.