새 버전 컨테이너를 먼저 띄워 헬스체크를 통과시킨 뒤 Nginx 라우팅을 전환하고, 마지막에 이전 컨테이너를 내리는 Blue/Green 무중단 배포를 리눅스 호스트 한 대에 직접 구성한다.
학습 목표
Blue/Green 배포의 전환 흐름을 설명할 수 있다.
지금 트래픽을 받고 있는 쪽이 어디인지 판별해 배포 대상(blue ↔ green)을 자동으로 결정하는 스크립트를 작성할 수 있다.
Nginx 설정 파일 교체 +
nginx -t+nginx -s reload로 무중단 라우팅 전환을 수행할 수 있다.전환에 실패했을 때 이전 상태로 되돌리는 경로를 설계할 수 있다.
Part 4 의 무중단 배포 와 무엇이 다른가
Part 4 는 EC2 위에서 GitHub Actions 가 호스트에 설치된 Nginx(systemd)를 조작한다. 이 장은 호스트에 Nginx 도 DB 도 설치하지 않고 전부 컨테이너로 올린 뒤, 배포 스크립트를 사람이 직접 실행한다.
원리는 같고 도구가 다르다. 두 장을 모두 해보면 무엇이 본질(헬스체크 → 전환 → 정리)이고 무엇이 도구(Actions, systemd, Compose)인지 구분된다.
앱 코드가 두 벌인 이유도 여기 있다. 이 장은 데이터베이스를 MySQL 컨테이너로 띄우므로 Supabase 의 PostgreSQL 전환 이전 버전(MySQL 판) 을 쓴다. Part 4 를 먼저 했다면 그쪽 앱은 PostgreSQL 드라이버로 바뀌어 있으니, 이 장에서는 MySQL 판을 따로 준비한다.
1. 준비 사항
호스트 — Docker Engine 과 Compose 플러그인이 설치된 리눅스 한 대. Ubuntu 22.04 이상 또는 Amazon Linux 2023 을 기준으로 한다. 리눅스 호스트에 Docker Engine 을 설치하는 방법은 Docker 공식 문서를 참고한다.
docker --version
docker compose version메모리 2 GiB 이상이어야 한다. 이 장은 컨테이너를 넷까지 동시에 띄운다 — MySQL, Nginx, 그리고 배포 전환 순간에는 애플리케이션이 두 개다.
Part 4 의 EC2 실습은 t3.micro(1 GiB)로 진행하는데 그쪽은 데이터베이스가 Supabase(외부)이고 Nginx 도 컨테이너가 아니라 호스트 패키지다. 이 장은 DB 와 Nginx 까지 같은 호스트에 컨테이너로 올리므로 요구 사양이 다르다.
EC2 t3.small(2 GiB)에서 실측한 값이다.
시점 | 사용 메모리 |
|---|---|
DB + Nginx 만 | 782 MB |
평소 (앱 1개) | 1,017 MB |
전환 순간 (앱 2개) | 1,282 MB |
컨테이너별로는 MySQL 462 MB, 애플리케이션 260 MB, Nginx 12 MB 였다.
t3.micro(1 GiB)로는 할 수 없다. MySQL 하나에 애플리케이션 하나만 올려도 한계고, 전환하려고 두 번째 앱을 띄우는 순간 호스트가 응답을 멈춘다.
Amazon Linux 2023 은 dnf install docker 만으로는 Compose 가 들어오지 않는다.
sudo dnf install -y docker
sudo systemctl enable --now docker
docker --version # Docker version 25.0.14, build 0bab007
docker compose version # docker: 'compose' is not a docker command.AL2023 저장소의 docker 패키지에는 Compose 플러그인이 빠져 있다. 플러그인을 직접 내려받아 CLI 플러그인 경로에 둔다.
V=$(curl -s https://api.github.com/repos/docker/compose/releases/latest \
| grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
sudo mkdir -p /usr/local/lib/docker/cli-plugins
sudo curl -sSL "https://github.com/docker/compose/releases/download/${V}/docker-compose-linux-x86_64" \
-o /usr/local/lib/docker/cli-plugins/docker-compose
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
docker compose version # Docker Compose version v5.5.1Ubuntu 는 공식 저장소에서 설치할 때 docker-compose-plugin 패키지가 함께 들어오므로 이 절차가 필요 없다.
사용할 이미지
이미지 | 용도 |
|---|---|
| 트래픽을 blue ↔ green 으로 넘기는 리버스 프록시 |
| 애플리케이션 데이터베이스 |
| 서비스 이미지의 베이스 |
| JAR 빌드용 (호스트에 JDK 를 설치하지 않기 위해) |
애플리케이션 — Docker Compose 장에서 만든 visit-counter(MySQL 연동판) 를 그대로 쓴다. 그 장의 app/ 디렉터리를 이 호스트로 가져다 둔다.
포트
포트 | 무엇 |
|---|---|
80 | Nginx — 사용자가 접속하는 유일한 입구 |
8080 / 8081 | blue / green 컨테이너. |
2. 배포 흐름
JAR 준비 ─▶ deploy.sh
1) 서비스 이미지 빌드 (build/ 디렉터리만 컨텍스트로)
2) (선택) 레지스트리 업로드
3) 지금 nginx 가 보고 있는 색을 읽어 반대쪽을 대상으로 정한다
4) 쉬고 있는 쪽에 새 컨테이너 실행
5) 새 컨테이너 헬스체크 — /actuator/health 가 200
6) nginx 설정 교체 → nginx -t 검사 → reload
7) nginx(80) 를 통해 다시 200 확인
8) 잠깐 대기(드레인) 후 이전 컨테이너 제거핵심은 "새 컨테이너가 정상(HTTP 200)임을 확인한 뒤에만 트래픽을 전환한다" 는 점이다. 5번에서 실패하면 전환 자체를 하지 않으므로 기존 서비스가 그대로 살아 있고, 7번에서 실패하면 설정을 되돌린다. 어느 쪽이든 이전 컨테이너는 끝까지 살려둔다.
이 장에는 Jenkins 가 없다.
배포를 자동으로 트리거 하는 일(Jenkins, GitHub Actions)과 배포를 수행 하는 일(deploy.sh)은 별개다. 이 장의 주제는 뒤쪽이므로, 앞쪽은 빼고 스크립트를 직접 실행한다. 그래야 전환이 실패했을 때 로그를 바로 보면서 고칠 수 있다.
CI 도구에 이 스크립트를 물리는 방법은 12절에서, 실제 파이프라인 구성은 GitHub Actions로 자동 배포하기 에서 다룬다.
3. 사전 작업
3-1. 사용자 계정 도커 그룹 추가
sudo usermod -aG docker "$USER"
# 변경 사항 적용 (재로그인 또는 아래 명령어)
newgrp docker3-2. 도커 네트워크 생성
Compose 가 띄우는 DB·Nginx 와, deploy.sh 가 docker run 으로 띄우는 blue/green 컨테이너가 같은 네트워크에 있어야 서로 컨테이너 이름으로 통신할 수 있다. 양쪽에서 함께 쓰므로 네트워크는 미리 만들어 둔다.
docker network create dsa_service_network4. 디렉터리 만들기
작업 디렉터리는 /service/docker 하나다. 아래 블록을 순서대로 실행한다.
# 1) /service 와 /service/docker 를 한 번에 만든다
sudo mkdir -p /service/docker
# 2) 이후 작업을 sudo 없이 하기 위해 소유자를 현재 사용자로 넘긴다
sudo chown -R "$USER":"$USER" /service
# 3) 하위 디렉터리 생성
cd /service/docker
mkdir -p build config/nginx volumes/mysql완성된 구조는 다음과 같다.
/service/docker/
├── docker-compose.yml # DB + Nginx
├── .env # Compose 가 읽는 DB 비밀번호
├── service.env # blue/green 컨테이너에 넘길 접속 정보
├── deploy.sh # 배포 스크립트
├── deploy.log # 배포 기록 (자동 생성, 누적)
├── build/ # ← 도커 빌드 컨텍스트. 여기만 빌드에 들어간다
│ ├── Dockerfile-service
│ └── visit-counter-1.0.0.jar
├── config/nginx/ # ← 전환용 Nginx 설정 2개
│ ├── default-blue-service.conf
│ └── default-green-service.conf
└── volumes/
└── mysql/ # MySQL 데이터이 블록의 순서가 곧 함정 세 개를 피하는 순서다.
mkdir: cannot create directory ‘/service/docker’: No such file or directory-p 없이 sudo mkdir /service/docker 를 하면 상위 디렉터리 /service 가 없어 이렇게 끝난다.
mkdir: cannot create directory ‘volumes’: Permission deniedsudo mkdir 로 만든 디렉터리는 root 소유다. 그 안에서 일반 사용자로 mkdir 하면 막힌다. 2번 단계에서 소유자를 넘기는 이유가 이것이다.
chown: invalid user: ‘dsa:dsa’
chown: changing ownership of 'volumes': Operation not permitted없는 계정으로 chown 하면 앞쪽이, 계정이 있더라도 일반 사용자는 자기 파일의 소유자를 남에게 넘길 수 없어서 뒤쪽이 뜬다. 소유자 변경은 sudo 로, 대상은 "$USER"(지금 로그인한 계정) 로 한다. 실습을 위해 새 계정을 만들 필요는 없다.
왜 빌드 컨텍스트를 build/ 로 따로 두는가
docker build 는 컨텍스트로 지정한 디렉터리를 통째로 도커 데몬에 전송한다. 컨텍스트를 /service/docker 로 잡으면 volumes/mysql 안의 데이터베이스 파일까지 매번 전송·해시 대상이 된다. 데이터가 쌓이면 배포할 때마다 수 GB 를 읽게 되고, MySQL 이 만드는 소켓 파일이나 권한이 막힌 파일을 만나면 빌드가 시작도 못 하고 멈춘다.
필요한 파일만 담은 build/ 를 컨텍스트로 쓰면 이 문제가 애초에 생기지 않는다. 디렉터리를 나눌 수 없는 상황이라면 대신 .dockerignore 로 volumes/ 를 제외한다.
5. 애플리케이션 JAR 준비
배포할 대상은 Docker Compose 장의 visit-counter 다. 그 장에서 만든 app/ 디렉터리(즉 settings.gradle, build.gradle, src/ 가 들어 있는 디렉터리)를 호스트로 옮겨 둔다. 여기서 필요한 것은 JAR 하나뿐 이므로 그 장의 Dockerfile 과 docker-entrypoint.sh 는 쓰지 않는다.
# 예: ~/visit-counter/app 에 소스를 둔 경우
cd ~/visit-counter/app
ls
# build.gradle settings.gradle src호스트에 JDK 와 Gradle 을 설치하지 않고 컨테이너로 빌드한다.
docker run --rm \
-v "$PWD":/build -w /build \
gradle:8.14-jdk21 gradle bootJar --no-daemon산출물을 확인하고 빌드 컨텍스트로 복사한다.
ls -l build/libs/visit-counter-1.0.0.jar
cp build/libs/visit-counter-1.0.0.jar /service/docker/build/JAR 이름이 visit-counter-1.0.0.jar 가 아니라면 settings.gradle 을 확인한다.
rootProject.name = 'visit-counter' 이 없으면 Gradle 은 프로젝트 이름을 작업 디렉터리 이름에서 가져온다. 위 명령은 -w /build 로 동작하므로 산출물이 build-1.0.0.jar 가 되고, 뒤에서 이미지를 만들 때 이렇게 멈춘다.
ERROR: failed to calculate checksum of ref ...:
"/visit-counter-1.0.0.jar": not foundbuild/ 디렉터리는 root 소유로 생긴다. 컨테이너의 Gradle 이 root 로 실행되기 때문이다. 나중에 호스트에서 지우려 하면 이렇게 막힌다.
rm: cannot remove 'build/libs': Permission denied소유자를 되돌려 두면 이후 작업이 편하다.
sudo chown -R "$USER":"$USER" build6. 서비스 이미지 — Dockerfile-service
deploy.sh 가 빌드하는 서비스 이미지다. 5절에서 JAR 을 복사해 둔 /service/docker/build/ 에 이 파일 하나만 더 만든다.
cd /service/docker/build/service/docker/build/Dockerfile-service
FROM eclipse-temurin:21-jre
# curl: deploy.sh 가 컨테이너 안에서 헬스체크할 때 쓴다
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# 애플리케이션을 root 가 아닌 전용 계정으로 돌린다
RUN groupadd --system app && useradd --system --gid app --no-create-home app
WORKDIR /webservice
# 빌드 산출물 — 5절에서 build/ 로 복사해 둔 JAR
COPY visit-counter-1.0.0.jar app.jar
USER app
EXPOSE 8080
# exec form — java 가 PID 1 이 되어 docker stop 의 SIGTERM 을 직접 받는다
ENTRYPOINT ["java", "-jar", "/webservice/app.jar"]이 이미지에서 일부러 하지 않은 것들
로그를 파일로 쓰지 않는다. 애플리케이션은 표준 출력으로 찍고, 그것을
docker logs로 본다. 로그 파일이 커지는 문제는 컨테이너 안의 logrotate·cron 이 아니라 Docker 로그 드라이버로 막는다 —deploy.sh의--log-opt가 그것이다(Docker 환경 설정 3절).셸 스크립트로 앱을 감싸지 않는다. 앱을
nohup ... &로 백그라운드에 띄우고tail -f /dev/null로 컨테이너를 붙잡아 두는 예제가 흔하다. 그렇게 하면 앱이 죽어도 컨테이너는Up이고,docker stop의 SIGTERM 은 PID 1 인 셸이 받아 앱까지 가지 않으며,docker logs는 비어 버린다. 컨테이너 하나에는 프로세스 하나를, PID 1 은 앱 자신으로 둔다.root 로 돌리지 않는다. 앱이 뚫려도 컨테이너 안에서 할 수 있는 일을 줄인다(Dockerfile 레퍼런스의
USER).
그 결과 앱이 죽으면 컨테이너도 Exited 가 되고, 원인은 docker logs 에 그대로 남는다. 11절에서 일부러 실패시켜 확인한다.
curl 을 일부러 설치한다. deploy.sh 는 헬스체크를 docker exec <컨테이너> curl ... 로 한다. 호스트에 curl 이 있는지에 의존하지 않기 위해 확인 도구를 이미지 안에 넣는다. 베이스 이미지에 curl 이 들어 있다는 보장은 없다.
7. 데이터베이스와 Nginx
Compose 가 관리하는 것은 DB 와 Nginx 두 개뿐 이다. blue/green 컨테이너는 deploy.sh 가 docker run 으로 직접 띄운다. 배포마다 뜨고 지는 대상을 Compose 파일에 적어 두면, 파일에 적힌 상태와 실제 상태가 계속 어긋나기 때문이다.
7-1. 비밀값 파일 — .env
cd /service/docker
cat > .env <<'EOF'
MYSQL_ROOT_PASSWORD=RootPassword123
MYSQL_PASSWORD=UserPassword456
EOF
chmod 600 .env7-2. docker-compose.yml
/service/docker/docker-compose.yml
services:
database:
image: 'mysql:8.4'
container_name: 'dsa-mysql'
# ports 를 열지 않는다. 접근이 필요한 것은 같은 네트워크의 컨테이너뿐이다.
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=visitdb
- MYSQL_USER=visituser
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- TZ=Asia/Seoul
networks:
- dsa_service_network
volumes:
- ./volumes/mysql:/var/lib/mysql
healthcheck:
# -h 127.0.0.1 로 TCP 를 확인한다.
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 10s
timeout: 5s
retries: 10
start_period: 40s
restart: unless-stopped
nginx:
image: 'nginx:1.30'
container_name: 'dsa-nginx'
ports:
- '80:80'
environment:
- TZ=Asia/Seoul
networks:
- dsa_service_network
restart: unless-stopped
networks:
dsa_service_network:
external: true
name: dsa_service_networkexternal: true 는 네트워크를 Compose 가 만들지 않고 이미 있는 것을 쓴다는 뜻이다. 3-2 절의 docker network create dsa_service_network 를 건너뛰면 아래로 실패한다.
network dsa_service_network declared as external, but could not be foundMYSQL_DATABASE 와 MYSQL_USER 가 왜 필요한가. MYSQL_ROOT_PASSWORD 만 주면 MySQL 은 빈 서버로 뜬다. 데이터베이스도 애플리케이션용 계정도 없어서 visit-counter 가 접속하는 즉시 Unknown database 'visitdb' 로 죽는다. 첫 기동 때 이 두 값을 함께 주면 DB 와 계정이 자동으로 만들어진다.
첫 기동 때만 이다. volumes/mysql 에 이미 데이터가 있으면 초기화 과정을 통째로 건너뛴다. 값을 잘못 넣고 띄웠다면 컨테이너를 지우는 것만으로는 부족하고 volumes/mysql 을 비워야 한다.
7-3. 애플리케이션 접속 정보 — service.env
blue/green 컨테이너에 넘길 값이다. Compose 가 아니라 deploy.sh 의 docker run --env-file 이 읽는다.
cd /service/docker
cat > service.env <<'EOF'
SPRING_DATASOURCE_URL=jdbc:mysql://dsa-mysql:3306/visitdb
SPRING_DATASOURCE_USERNAME=visituser
SPRING_DATASOURCE_PASSWORD=UserPassword456
EOF
chmod 600 service.envSPRING_DATASOURCE_PASSWORD 는 .env 의 MYSQL_PASSWORD 와 같은 값 이어야 한다. 호스트 이름 dsa-mysql 은 DB 컨테이너의 이름이다. 같은 도커 네트워크 안에서는 컨테이너 이름이 그대로 호스트 이름이 된다.
--env-file 은 따옴표를 값의 일부로 본다.
SPRING_DATASOURCE_PASSWORD='UserPassword456'이렇게 적으면 비밀번호가 'UserPassword456' 이 되어 인증에 실패한다. 셸이 아니라 Docker 가 파일을 읽기 때문이다. 따옴표 없이 적는다.
8. Nginx 전환용 설정 파일
default.conf 를 blue용과 green용 2개 만들어 config/nginx/ 에 둔다. 배포할 때 deploy.sh 가 이 중 하나를 컨테이너 안으로 복사한다. 경로와 파일 이름이 스크립트와 정확히 맞아야 하므로 아래 명령으로 만든다.
cat > /service/docker/config/nginx/default-blue-service.conf <<'EOF'
upstream service {
server blue-service:8080;
}
server {
listen 80;
listen [::]:80;
server_name localhost;
location / {
proxy_pass http://service;
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;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
EOF
# green 용은 대상 이름 한 곳만 다르다. 손으로 고치지 말고 치환해서 만든다.
sed 's/blue-service/green-service/' \
/service/docker/config/nginx/default-blue-service.conf \
> /service/docker/config/nginx/default-green-service.conf
grep -n 'server .*-service' /service/docker/config/nginx/*.conf마지막 grep 의 출력은 다음과 같아야 한다.
/service/docker/config/nginx/default-blue-service.conf:2: server blue-service:8080;
/service/docker/config/nginx/default-green-service.conf:2: server green-service:8080;Nginx 설정은 볼륨으로 마운트하지 않고 deploy.sh 가 docker cp 로 밀어 넣는다. conf.d 디렉터리를 통째로 마운트하면 blue용·green용 설정이 동시에 로드되어 duplicate upstream "service" 오류가 난다.
대신 Nginx 컨테이너를 다시 만들면 설정이 이미지 기본값으로 돌아간다. docker compose up -d --force-recreate 등을 한 뒤에는 deploy.sh 를 한 번 더 실행해 현재 대상을 다시 지정해야 한다.
설정에 적힌 컨테이너가 없으면 Nginx 는 아예 뜨지 않는다.
nginx: [emerg] host not found in upstream "green-service" in /etc/nginx/conf.d/default.conf:2Nginx 는 upstream 의 호스트 이름을 설정을 읽는 시점에 해석한다. 그래서 deploy.sh 는 새 컨테이너가 뜨고 헬스체크를 통과한 뒤에 설정을 바꾸고, 바꾼 직후 nginx -t 로 검사한다. 이 오류를 리로드가 아니라 검사 단계에서 잡으려는 것이다.
9. 배포 스크립트 — deploy.sh
/service/docker/deploy.sh
#!/bin/bash
set -u
APP="visit-counter"
NETWORK="dsa_service_network"
NGINX="dsa-nginx"
BASE="/service/docker"
BUILD_DIR="$BASE/build"
NGINX_CONF_DIR="$BASE/config/nginx"
ENV_FILE="$BASE/service.env"
DEPLOY_LOG="$BASE/deploy.log"
# 전환 후 이전 컨테이너를 지우기 전에 기다릴 시간(초)
DRAIN_SECONDS=10
# 레지스트리에 올리고 싶을 때만 값을 준다.
# REGISTRY=docker.io/<내계정> ./deploy.sh
REGISTRY="${REGISTRY:-}"
# 로그는 덧붙인다(-a). 덮어쓰면 실행할 때마다 지워져,
# 정작 필요한 "직전 실패의 기록" 이 남지 않는다.
exec > >(tee -a "$DEPLOY_LOG")
exec 2>&1
echo "===== $(date '+%Y-%m-%d %H:%M:%S') 배포 시작 ====="
TAG=$(date +%Y%m%d%H%M%S)
# ── 1. 서비스 이미지 빌드 ────────────────────────────────────
# 컨텍스트는 build/ 하나뿐이다. /service/docker 를 통째로 넘기면
# volumes/ 아래의 MySQL 데이터까지 데몬으로 전송된다.
echo "[INFO] 이미지를 빌드한다: $APP:$TAG"
docker build -t "$APP:$TAG" -f "$BUILD_DIR/Dockerfile-service" "$BUILD_DIR" || {
echo "[ERROR] 이미지 빌드에 실패했다."; exit 1
}
# ── 2. (선택) 레지스트리 업로드 ──────────────────────────────
if [ -n "$REGISTRY" ]; then
echo "[INFO] $REGISTRY/$APP:$TAG 로 업로드한다."
docker tag "$APP:$TAG" "$REGISTRY/$APP:$TAG" || exit 1
docker push "$REGISTRY/$APP:$TAG" || {
echo "[ERROR] 업로드에 실패했다. docker login 을 했는지 확인한다."; exit 1
}
else
echo "[INFO] REGISTRY 가 비어 있어 업로드는 건너뛴다."
fi
# ── 3. 지금 서비스 중인 색을 nginx 설정에서 읽는다 ───────────
# 컨테이너 상태가 아니라 "트래픽이 실제로 어디로 가는가" 를 기준으로 정한다.
current=$(docker exec "$NGINX" cat /etc/nginx/conf.d/default.conf 2>/dev/null \
| grep -oE '(blue|green)-service' | head -n 1)
if [ "$current" = "blue-service" ]; then
new_target="green-service"; new_port=8081; old_target="blue-service"
else
new_target="blue-service"; new_port=8080; old_target="green-service"
fi
echo "[INFO] 현재 대상: ${current:-없음(첫 배포)} / 새로 띄울 대상: $new_target(:$new_port)"
# ── 4. 같은 이름의 컨테이너가 남아 있으면 지운다 ─────────────
if docker inspect "$new_target" > /dev/null 2>&1; then
echo "[INFO] 이전에 남은 $new_target 컨테이너를 제거한다."
docker rm -f "$new_target" > /dev/null || exit 1
fi
# ── 5. 새 컨테이너 실행 ──────────────────────────────────────
echo "[INFO] $new_target 컨테이너를 실행한다."
docker run -d \
--name "$new_target" \
--network "$NETWORK" \
-p "127.0.0.1:$new_port:8080" \
--env-file "$ENV_FILE" \
--log-opt max-size=10m --log-opt max-file=3 \
"$APP:$TAG" > /dev/null || {
echo "[ERROR] $new_target 실행에 실패했다."; exit 1
}
# ── 6. 새 컨테이너 헬스체크 ──────────────────────────────────
echo "[INFO] $new_target 헬스체크 (최대 60초)"
health=000
for i in $(seq 1 30); do
# 앱이 죽어 컨테이너가 Exited 가 되면 더 기다릴 이유가 없다.
if [ "$(docker inspect -f '{{.State.Running}}' "$new_target" 2>/dev/null)" != "true" ]; then
echo "[ERROR] $new_target 컨테이너가 종료됐다."
break
fi
# 아직 기동 중이거나 curl 이 실패하면 빈 문자열이 온다.
# 숫자 비교 대신 문자열 비교를 쓰고, 빈 값은 000 으로 바꿔 둔다.
health=$(docker exec "$new_target" \
curl -s -o /dev/null -w '%{http_code}' \
http://localhost:8080/actuator/health 2>/dev/null)
health="${health:-000}"
[ "$health" = "200" ] && break
echo "[INFO] 대기 중 ($i/30) - 현재 $health"
sleep 2
done
if [ "$health" != "200" ]; then
echo "[ERROR] $new_target 가 200 을 돌려주지 않는다($health). 전환하지 않는다."
# 진짜 원인은 긴 스택 트레이스 위쪽에 있다. 원인이 적히는 줄을 먼저 뽑는다.
echo "[ERROR] ---- 원인 ----"
docker logs "$new_target" 2>&1 | grep -m 4 -E 'Access denied|SQLState|FATAL' || true
echo "[ERROR] ---- 애플리케이션 로그 (마지막 15줄) ----"
docker logs --tail 15 "$new_target" 2>&1
echo "[ERROR] 원인을 더 볼 수 있도록 $new_target 컨테이너는 지우지 않는다."
echo "[ERROR] docker logs $new_target 2>&1 | less"
echo "[ERROR] 다음 배포가 4번 단계에서 이 컨테이너를 자동으로 치운다."
exit 1
fi
echo "[INFO] $new_target 준비 완료 (HTTP 200)"
# ── 7. nginx 설정 교체 → 검사 → 리로드 ──────────────────────
echo "[INFO] nginx 설정을 $new_target 로 바꾼다."
docker exec "$NGINX" cp /etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf.bak
rollback() {
echo "[WARN] nginx 설정을 이전 상태로 되돌린다."
docker exec "$NGINX" cp /etc/nginx/conf.d/default.conf.bak /etc/nginx/conf.d/default.conf
docker exec "$NGINX" nginx -s reload
docker rm -f "$new_target" > /dev/null
}
docker cp "$NGINX_CONF_DIR/default-$new_target.conf" \
"$NGINX:/etc/nginx/conf.d/default.conf" || {
echo "[ERROR] 설정 파일 복사에 실패했다. $NGINX_CONF_DIR 를 확인한다."
docker rm -f "$new_target" > /dev/null
exit 1
}
if ! docker exec "$NGINX" nginx -t; then
echo "[ERROR] 새 설정이 nginx 검사를 통과하지 못했다."
rollback
exit 1
fi
docker exec "$NGINX" nginx -s reload || {
echo "[ERROR] 리로드에 실패했다."; rollback; exit 1
}
# ── 8. nginx(80) 를 통해 실제로 응답하는지 확인 ──────────────
# nginx -s reload 가 0 을 돌려준 것은 "마스터에 시그널을 보냈다" 는 뜻일 뿐,
# 새 설정이 적용됐다는 증명이 아니다. 응답으로 확인한다.
echo "[INFO] nginx 를 통해 응답을 확인한다."
via=000
for i in $(seq 1 10); do
via=$(docker exec "$new_target" \
curl -s -o /dev/null -w '%{http_code}' \
"http://$NGINX/actuator/health" 2>/dev/null)
via="${via:-000}"
[ "$via" = "200" ] && break
sleep 2
done
if [ "$via" != "200" ]; then
echo "[ERROR] nginx 를 통한 응답이 $via 다. 전환을 취소한다."
docker logs --tail 20 "$NGINX"
rollback
exit 1
fi
echo "[INFO] nginx 를 통한 응답 확인 (HTTP 200)"
# ── 9. 이전 컨테이너 정리 ────────────────────────────────────
# 리로드 직후에도 예전 워커 프로세스가 처리 중인 요청이 남아 있다.
# 바로 지우면 그 요청들이 502 로 끝난다.
echo "[INFO] 처리 중인 요청이 끝나도록 ${DRAIN_SECONDS}초 기다린다."
sleep "$DRAIN_SECONDS"
if docker inspect "$old_target" > /dev/null 2>&1; then
echo "[INFO] 이전 컨테이너($old_target)를 내리고 제거한다."
# rm -f 는 SIGKILL 이다. stop 으로 SIGTERM 을 보내야 앱이 남은 요청을 마무리한다.
docker stop -t 30 "$old_target" > /dev/null
docker rm "$old_target" > /dev/null
fi
# 태그가 밀려난 이미지 정리
docker image prune -f --filter "dangling=true" > /dev/null
echo "===== $(date '+%Y-%m-%d %H:%M:%S') 배포 완료: $new_target ($APP:$TAG) ====="chmod +x /service/docker/deploy.sh실패한 컨테이너를 바로 지우면 원인을 영원히 못 본다.
docker logs 는 종료된 컨테이너의 로그도 보여준다. 단, 컨테이너가 남아 있을 때만이다. docker rm 하는 순간 로그도 함께 사라진다.
그래서 위 스크립트는 실패했을 때 마지막 로그를 출력하고, 컨테이너는 지우지 않는다. 앱이 죽어 Exited 상태인 컨테이너는 메모리를 쓰지 않는다. 남겨둔 컨테이너는 다음 배포의 4번 단계가 알아서 치우므로 쌓이지 않는다.
이전 컨테이너를 정리할 때는 반대로 docker stop 으로 정상 종료를 시킨다. docker rm -f 는 SIGKILL 이라 처리 중이던 요청이 그 자리에서 끊긴다. 서비스 이미지의 ENTRYPOINT 가 exec form 이라 SIGTERM 이 java 에게 바로 닿고, Spring Boot(3.4 이상)는 받아 둔 요청을 마저 처리한 뒤 내려간다.
배포 대상을 "blue 가 건강한가" 로 정하면 안 된다.
흔한 구현은 blue 의 헬스체크 결과를 보고 대상을 정한다. 그러면 blue 가 트래픽을 받고 있는데 일시적으로 응답하지 못하는 순간 에 스크립트가 blue 를 배포 대상으로 골라, 지금 서비스 중인 컨테이너를 먼저 지워버린다. 무중단을 만들려고 쓴 스크립트가 장애를 만든다.
기준은 "지금 트래픽이 어디로 가는가" 다. 그 답은 컨테이너가 아니라 Nginx 설정 이 갖고 있다. 3번 단계가 docker ps 가 아니라 default.conf 를 읽는 이유다.
멈춘 컨테이너를 docker ps -a 로 세면 숫자 비교에서 터진다.
docker ps -a 는 종료된 컨테이너도 찾아준다. 그 컨테이너에 docker exec 를 하면 아무것도 돌려주지 않으므로 헬스체크 변수는 빈 문자열 이 되고, 다음 비교에서 이렇게 끝난다.
deploy.sh: line 42: [: : integer expression expected게다가 [ ... ] 가 실패해도 스크립트는 계속 진행하므로, 엉뚱한 분기로 내려가 배포가 이어진다. 위 스크립트가 -ne 대신 = 로 문자열을 비교하고 빈 값을 000 으로 바꿔 두는 이유다.
헬스체크 엔드포인트는 /actuator/health 다. visit-counter 의 build.gradle 에 spring-boot-starter-actuator 가 들어 있고, Actuator 를 추가하면 /actuator/health 는 기본으로 노출된다. 컨트롤러를 따로 만들 필요가 없다.
데이터 소스가 있으면 이 엔드포인트가 DB 연결까지 확인 해 준다. DB 가 죽어 있으면 503 이 되므로, "앱은 떴는데 DB 를 못 붙은" 상태로 트래픽을 넘기는 사고를 막아준다.
10. 첫 배포와 무중단 확인
10-1. DB 와 Nginx 기동
cd /service/docker
docker compose up -d
docker compose psdatabase 의 상태가 healthy 가 될 때까지 기다린다. 첫 기동은 40초 안팎 걸린다.
NAME IMAGE STATUS
dsa-mysql mysql:8.4 Up 45 seconds (healthy)
dsa-nginx nginx:1.30 Up 45 seconds이 시점에 http://<호스트>/ 로 접속하면 Nginx 기본 페이지가 보인다. 아직 우리 설정이 들어가기 전이다.
배포 전 — 아직 upstream service 설정이 들어가기 전이다
10-2. 첫 배포
./deploy.sh마지막 줄이 배포 완료: blue-service 면 성공이다. 확인한다.
curl http://localhost/
# 방문 횟수: 1
docker ps --format '{{.Names}} {{.Status}}'
# blue-service Up ...
# dsa-mysql Up ... (healthy)
# dsa-nginx Up ...
docker exec dsa-nginx grep 'server .*-service' /etc/nginx/conf.d/default.conf
# server blue-service:8080;10-3. 두 번째 배포 — 끊기지 않는지 본다
터미널을 하나 더 열어 요청을 계속 보낸다.
while true; do
curl -s -o /dev/null -w "%{http_code} " http://localhost/
sleep 0.3
done원래 터미널에서 다시 배포한다.
cd /service/docker
./deploy.sh배포가 도는 동안 반복문 쪽을 지켜본다.
200 200 200 200 200 200 200 200 200 200 200 200 ...전환 순간에도 200 만 이어진다. t3.small 에서 실제로 돌려보면 전환을 가로질러 93회 연속 200, 502 와 000 은 0회 였다. 끝나면 대상이 바뀌어 있다.
docker exec dsa-nginx grep 'server .*-service' /etc/nginx/conf.d/default.conf
# server green-service:8080;
docker ps --filter name=-service --format '{{.Names}}'
# green-service ← blue 는 정리됐다배포 기록은 누적된다.
tail -n 30 /service/docker/deploy.log11. 실패하면 어떻게 되는가
일부러 실패시켜 본다. service.env 의 비밀번호를 틀리게 바꾸고 배포한다.
cd /service/docker
sed -i 's/^SPRING_DATASOURCE_PASSWORD=.*/SPRING_DATASOURCE_PASSWORD=wrong/' service.env
./deploy.sh새 컨테이너는 뜨지만 DB 에 붙지 못한 Spring Boot 가 기동을 포기하고 종료한다. 앱이 PID 1 이므로 컨테이너도 함께 Exited 가 되고, 스크립트는 그것을 보고 기다림을 끝낸다. 종료 코드는 1 이다.
[INFO] 대기 중 (4/30) - 현재 000
[ERROR] blue-service 컨테이너가 종료됐다.
[ERROR] blue-service 가 200 을 돌려주지 않는다(000). 전환하지 않는다.
[ERROR] ---- 원인 ----
WARN 1 --- [main] org.hibernate.orm.jdbc.error : HHH000247: ErrorCode: 1045, SQLState: 28000
WARN 1 --- [main] org.hibernate.orm.jdbc.error : Access denied for user 'visituser'@'172.18.0.5' (using password: YES)
[ERROR] ---- 애플리케이션 로그 (마지막 15줄) ----
...
Caused by: org.hibernate.HibernateException: Unable to determine Dialect without JDBC metadata
...
[ERROR] 원인을 더 볼 수 있도록 blue-service 컨테이너는 지우지 않는다.t3.small 에서 잰 이 실패는 12초 만에 끝났다. 앱이 죽으면 컨테이너가 Exited 가 되고, 스크립트가 그것을 보고 60초를 다 기다리지 않기 때문이다.
Hibernate 가 보여주는 Unable to determine Dialect 는 진짜 원인이 아니다. 접속에 실패해 DB 메타데이터를 못 읽었다는 결과일 뿐이다. 진짜 원인은 그보다 위에 있다. 이 실패의 로그는 150줄이 넘고 Access denied 줄은 위에서 20번째 줄 근처라, 마지막 몇 줄만 보면 끝내 보이지 않는다. 위 스크립트가 원인 줄을 따로 grep 하는 이유다. 손으로 찾을 때도 같다.
docker logs blue-service 2>&1 | grep -m1 'Access denied'SQLState: 28000 과 ErrorCode: 1045 는 MySQL 의 인증 실패 코드다.
연결 오류는 늘 스택 트레이스 위쪽에 있다.
그 사이에도 서비스는 멀쩡하다.
curl http://localhost/
# 방문 횟수: 5 ← 이전 컨테이너가 계속 응답한다
docker exec dsa-nginx grep 'server .*-service' /etc/nginx/conf.d/default.conf
# server green-service:8080; ← 설정도 그대로다비밀번호를 되돌리고 다시 배포하면 정상으로 돌아온다.
sed -i 's/^SPRING_DATASOURCE_PASSWORD=.*/SPRING_DATASOURCE_PASSWORD=UserPassword456/' service.env
./deploy.sh이 실습에서 확인할 것은 두 가지다.
첫째, 실패한 배포는 아무것도 바꾸지 않는다. Nginx 설정도, 서비스 중인 컨테이너도 그대로다. 되돌릴 것이 없으니 롤백 절차도 필요 없다.
둘째, 앱이 죽으면 컨테이너도 죽는다. docker ps -a 로 보면 blue-service 가 Exited (1) 이다. 컨테이너 상태가 곧 앱 상태이므로 상태만 봐도 무엇이 일어났는지 안다.
원인은 docker logs 에 있다. 컨테이너가 종료됐어도 지우기 전까지는 남아 있다.
docker ps -a --filter name=blue-service --format '{{.Names}} {{.Status}}'
# blue-service Exited (1) ...
docker logs --tail 100 blue-service배포 기록에도 같은 내용이 남는다.
tail -n 60 /service/docker/deploy.log12. CI 도구에 연결하려면 (선택)
여기까지가 이 장의 실습이다. 배포를 사람이 실행했지만, deploy.sh 자체는 누가 실행해도 같게 동작한다. CI 도구를 붙인다는 것은 이 스크립트를 대신 실행할 주체를 하나 두는 일이다.
GitHub Actions — 워크플로가 SSH 로 접속해
deploy.sh를 실행한다. 서버에 CI 도구를 설치하지 않아도 된다. 구성은 GitHub Actions로 자동 배포하기 에 있다.Jenkins — 서버 안에서 돌기 때문에 준비할 것이 더 많다. 아래에 정리한다.
Jenkins 컨테이너로 이 스크립트를 실행하려면
Jenkins 를 컨테이너로 띄워 호스트의 도커를 다루게 하려면 최소 네 가지가 필요하다. 공식 이미지를 그대로 쓰면 하나도 충족되지 않는다.
도커 CLI —
jenkins/jenkins:lts-jdk21에는docker명령이 없다. CLI 를 설치한 이미지를 따로 만들어야 한다.도커 소켓 —
/var/run/docker.sock을 마운트해야 컨테이너 안에서 호스트의 도커를 조작할 수 있다.소켓 접근 권한 — 소켓은 호스트의
docker그룹 소유다. 컨테이너의jenkins사용자(UID 1000)를 그 그룹 GID 에 넣어줘야 한다.작업 디렉터리 —
deploy.sh와build/,config/nginx/가 컨테이너 안에서도 같은 경로로 보여야 한다.
# 볼륨 디렉터리를 만들고, 호스트의 docker 그룹 GID 를 확인한다
mkdir -p /service/docker/volumes/jenkins
sudo chown -R 1000:1000 /service/docker/volumes/jenkins
getent group docker | cut -d: -f3 jenkins:
image: '<도커 CLI 를 설치해 직접 만든 이미지>'
container_name: 'dsa-jenkins'
ports:
- '48080:8080'
environment:
- TZ=Asia/Seoul
group_add:
- '<위에서 확인한 docker 그룹 GID>'
networks:
- dsa_service_network
volumes:
- ./volumes/jenkins:/var/jenkins_home
- /var/run/docker.sock:/var/run/docker.sock
- /service/docker:/service/docker초기 화면은 잠겨 있고, 잠금 해제 암호는 컨테이너 안에 있다.
docker exec dsa-jenkins cat /var/jenkins_home/secrets/initialAdminPasswordhttp://<호스트>:48080 에 접속해 이 값을 입력하고, 권장 플러그인 설치 → 관리자 계정 생성 → 작업(Freestyle) 생성 → 빌드 스텝에 /service/docker/deploy.sh 를 넣는 순서로 진행한다.
Jenkins 첫 화면 — 잠금 해제 암호는 컨테이너 안 파일에 있다
/var/run/docker.sock 마운트는 호스트 루트 권한을 넘겨주는 것과 같다. 그 소켓에 접근할 수 있으면 호스트의 아무 디렉터리나 마운트한 특권 컨테이너를 만들 수 있다. Jenkins 에 작업을 만들 수 있는 사람은 곧 호스트를 장악할 수 있는 사람이 된다.
학습용 서버라면 감수할 수 있지만, 공용 서버에서는 빌드 전용 사용자·별도 호스트·소켓 프록시 같은 대책 없이 이렇게 두지 않는다.
chown 1000:1000 인 이유 — Jenkins 공식 이미지는 컨테이너 안에서 UID 1000(jenkins 사용자)으로 동작한다. 호스트 볼륨 디렉터리의 소유자가 다르면 기동하면서 /var/jenkins_home 에 쓰지 못해 실패한다. 계정 이름이 아니라 숫자 UID 로 맞추는 이유는, 호스트에 jenkins 라는 계정이 있을 이유가 없기 때문이다.
-R 을 빼면 안 된다. 디렉터리 자신만 주인이 바뀌고 그 안의 파일들은 그대로 남는다. 빈 디렉터리라면 티가 나지 않지만, 이미 내용이 있는 디렉터리를 볼륨으로 붙이면 Jenkins 가 여전히 기동에 실패한다. 그룹까지 함께 지정(1000:1000)하는 것도 같은 이유다 — 컨테이너 안의 jenkins 사용자는 UID·GID 가 모두 1000이다.
13. 정리
cd /service/docker
# blue/green 컨테이너
docker rm -f blue-service green-service 2>/dev/null
# DB 와 Nginx
docker compose down
# 네트워크
docker network rm dsa_service_network데이터까지 지우려면 volumes/mysql 을 삭제한다. MySQL 이 root 소유로 만든 파일이 섞여 있으므로 sudo 가 필요하다.
sudo rm -rf /service/docker/volumes/mysql이 장에서 만든 구조의 한계
서버 한 대짜리 Blue/Green 이다. 서버 자체가 죽으면 대책이 없고, 배포 중에는 애플리케이션 컨테이너가 두 개라 메모리를 두 배로 쓴다.
데이터베이스는 한 벌뿐이다. 컬럼을 지우는 배포라면 전환 순간 구버전과 신버전이 같은 테이블을 동시에 본다. 스키마 변경은 "컬럼 추가 → 양쪽 호환되는 코드 배포 → 구버전 정리 → 컬럼 삭제" 처럼 여러 번에 나눠 진행해야 한다.
규모가 커지면 서버를 여러 대 두고 앞단에 로드 밸런서를 세운다. 원리는 같다 — 새 쪽을 띄우고, 준비된 것을 확인하고, 트래픽을 넘기고, 옛 쪽을 정리한다.