Part 1. Docker 기본 과정

Docker Compose

학습 목표

  • 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.30

docker compose 방식 — 단 한 줄

docker compose up -d

Compose를 쓰면 실행 순서, 네트워크, 볼륨 생성이 자동으로 처리되고, 설정이 파일로 관리되어 팀 공유와 재현이 쉬워진다.

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.4

build — Dockerfile로 이미지 직접 빌드

services:
  app:
    build:
      context: ./app
      dockerfile: Dockerfile

ports — 무엇을 외부에 열 것인가

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 는 두 가지 일을 한다 — 헷갈리기 쉽다.

${변수} 치환

env_file:

누가 읽나

Compose 자신이 YAML 을 해석할 때

Compose 가 읽어 컨테이너에 넘긴다

어떤 파일

프로젝트 폴더의 .env (자동)

env_file: 에 적은 파일

결과

YAML 안의 ${MYSQL_DATABASE} 가 값으로 바뀐다

파일의 모든 변수가 컨테이너 환경 변수가 된다

아래 종합 실습의 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

저장 방식

환경 변수

/run/secrets/ 파일로 마운트

docker inspect 노출

노출됨

노출 안 됨

컨테이너 내 접근

$변수명

cat /run/secrets/파일명

주 사용처

개발 환경

운영 환경

volumes — 볼륨 또는 디렉토리 마운트

services:
  nginx:
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro  # 읽기 전용 바인드 마운트
  db:
    volumes:
      - db-data:/var/lib/mysql   # Named Volume

restart — 컨테이너 재시작 정책

옵션

설명

no

재시작하지 않음 (기본값)

always

항상 재시작

on-failure

오류 종료 시에만 재시작

unless-stopped

수동 중지 전까지 항상 재시작

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: 40s

MySQL 헬스체크를 -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 명령어

명령어

설명

docker compose up

서비스 시작 (이미지 없으면 빌드 또는 pull 포함)

docker compose up -d

백그라운드 모드로 시작

docker compose up --build

강제로 이미지 재빌드 후 시작

docker compose down

서비스 중지 및 컨테이너 삭제

docker compose down -v

서비스 중지 + 볼륨도 함께 삭제

docker compose down --rmi all

서비스 중지 + 빌드 이미지도 삭제

docker compose ps

실행 중인 서비스 목록 및 헬스 상태 조회

docker compose logs

전체 서비스 로그 확인

docker compose logs -f [서비스명]

특정 서비스 로그 실시간 출력

docker compose logs --timestamps

타임스탬프 포함 로그 출력

docker compose stop [서비스명]

서비스 중지 (컨테이너 유지)

docker compose start [서비스명]

중지된 서비스 시작

docker compose restart [서비스명]

서비스 재시작

docker compose exec [서비스명] [명령]

실행 중인 서비스 컨테이너에 명령 실행

docker compose run [서비스명] [명령]

서비스 컨테이너를 새로 실행하여 명령 수행

docker compose config

최종 병합 설정 확인 (환경 변수 치환 결과 포함)

docker compose pull [서비스명]

서비스 이미지 내려받기

docker compose build [서비스명]

서비스 이미지 빌드

docker compose up --scale 서비스=N

서비스를 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-compose

Step 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 ascii

Windows 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: 8080

Spring 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!456

inspect 는 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.txt

MYSQL_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

해결 순서

  1. 드라이버·툴을 최신 버전으로 올린다 (권장). 최근의 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 이미지를 쓴다.

  2. 당장 올릴 수 없다면 컨테이너 실행 옵션으로 예전 플러그인을 켠다.

  db:
    image: mysql:8.4
    command: --mysql-native-password=ON

2번은 임시방편이다. 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 --timestamps

Step 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 (외부 미노출)
Nginx를 통한 접속

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!456

Git Bash(Windows)에서는 위 명령이 실패한다.

cat: 'C:/Program Files/Git/run/secrets/mysql_user_password': No such file or directory

MSYS 가 /run/... 을 윈도우 경로로 바꿔버리기 때문이다. 슬래시를 두 개 쓰면 변환을 막을 수 있다.

docker compose exec app cat //run/secrets/mysql_user_password

PowerShell 이나 WSL 터미널에서는 원래 명령이 그대로 동작한다.

Step 7. 설정 검증 — docker compose config

# .env 변수와 secrets 경로가 올바르게 치환됐는지 확인
docker compose config

# 특정 서비스 설정만 확인
docker compose config --services
# nginx
# app
# db

# 환경변수 치환 결과 확인 (비밀번호 값은 _FILE 경로만 보여야 함)
docker compose config | grep -i password

Step 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  Removing

up 을 꼭 써야 한다면 확장 수를 함께 지정한다.

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

추가 도전 과제

  1. nginx.conf에 /static/ 경로를 추가해 Nginx가 정적 파일을 직접 서빙하도록 설정해보자.

  2. docker compose watch(Compose v2.22+)를 사용해 소스 변경 시 자동으로 이미지가 재빌드되는 개발 환경을 구성해보자.

  3. MySQL 서비스에 초기 스키마 SQL 파일(init.sql)을 바인드 마운트해 컨테이너 최초 실행 시 테이블이 자동 생성되도록 해보자.

마지막 수정 2026년 10월 22일