Spring Boot 애플리케이션을 GitHub Actions CI/CD 파이프라인과 Blue-Green 전략으로 무중단 배포하는 전체 과정을 정리합니다.
전체 아키텍처 개요
[사용자] → [Cloudflare CDN/DNS] → [Nginx (리버스 프록시)] → [Spring Boot App (Blue or Green)]
↓
[MySQL] [Redis] [Elasticsearch]
배포 흐름을 한 문장으로 요약하면 이렇습니다.
GitHub Push → CI(빌드/테스트) → CD(Docker 이미지 빌드 & Push) → 서버 SSH 접속 → Blue-Green 배포 스크립트 실행 → 헬스체크 → Nginx upstream 전환 → 구버전 컨테이너 제거
1. Docker 네트워크와 내장 DNS 리졸버
Blue-Green 배포의 모든 것은 Docker 네트워크 위에서 돌아갑니다. 이 구조를 이해하지 못하면 배포 스크립트의 동작 원리를 제대로 파악할 수 없으니, 인프라 구성에 앞서 먼저 짚고 넘어가겠습니다.
1-1. 외부 네트워크(external network)를 쓰는 이유
TechFork의 모든 Docker Compose 파일은 techfork-network라는 외부(external) 네트워크를 공유합니다.
# 모든 docker-compose 파일에 공통으로 들어있는 설정
networks:
techfork-network:
external: true
일반적으로 docker compose up을 하면 Compose가 프로젝트명_default라는 네트워크를 자동 생성합니다. 문제는 각 Compose 파일(infra, blue, green, dev)이 별도의 프로젝트로 실행되기 때문에, 자동 생성 네트워크를 쓰면 각각 격리된 네트워크가 4개 생긴다는 것입니다.
# external: true가 없으면 이렇게 됩니다
techfork-infra_default → MySQL, Redis, ES, Nginx만 연결
techfork-blue_default → Blue 앱만 연결 (DB 접근 불가!)
techfork-green_default → Green 앱만 연결 (DB 접근 불가!)
techfork-dev_default → Dev 앱만 연결 (DB 접근 불가!)
external: true로 선언하면 Compose가 네트워크를 생성하지 않고, 이미 존재하는 techfork-network에 연결합니다. 이 네트워크는 배포 스크립트에서 미리 만들어둡니다.
# deploy.sh Step 1
docker network create techfork-network 2>/dev/null || true
결과적으로 모든 컨테이너가 하나의 네트워크 안에 들어가게 되어, 컨테이너 이름으로 자유롭게 통신할 수 있습니다.
techfork-network (172.18.0.0/16)
├── techfork-mysql (172.18.0.2)
├── techfork-redis (172.18.0.3)
├── techfork-elasticsearch (172.18.0.4)
├── techfork-nginx (172.18.0.5)
├── techfork-app-blue (172.18.0.6) ← 현재 활성
└── techfork-app-green (172.18.0.7) ← 다음 배포 대상
1-2. Docker 내장 DNS (127.0.0.11)
Docker는 사용자 정의 네트워크(user-defined network)에 내장 DNS 서버를 제공합니다. 주소는 127.0.0.11이며, 같은 네트워크에 있는 모든 컨테이너가 이 DNS를 통해 이름으로 서로를 찾을 수 있습니다.
예를 들어 Nginx 컨테이너 안에서 techfork-app-blue를 호출하면 이런 일이 벌어집니다.
[Nginx 컨테이너]
curl http://techfork-app-blue:8080/actuator/health
↓
/etc/resolv.conf → nameserver 127.0.0.11
↓
Docker 내장 DNS가 "techfork-app-blue"를 172.18.0.6으로 해석
↓
172.18.0.6:8080으로 HTTP 요청 전달
container_name과 aliases 모두 DNS에 등록됩니다. TechFork에서는 둘 다 활용하고 있습니다.
# docker-compose.infra.yml — aliases로 짧은 이름 등록
services:
mysql:
container_name: techfork-mysql
networks:
techfork-network:
aliases:
- mysql # "mysql"로도 접근 가능
elasticsearch:
container_name: techfork-elasticsearch
hostname: techfork-elasticsearch
networks:
techfork-network:
aliases:
- elasticsearch # "elasticsearch"로도 접근 가능
Spring Boot application.yml에서 spring.datasource.url=jdbc:mysql://mysql:3306/techblog처럼 짧은 alias를 쓸 수 있는 이유가 바로 이것입니다. container_name 전체를 쓸 필요 없이 alias만으로 충분합니다.
1-3. DNS 해석 시점의 함정과 Nginx의 대응
Docker 내장 DNS가 있으니 만사형통일 것 같지만, Nginx는 DNS 해석 시점이 특수해서 함정이 있습니다.
Nginx의 upstream 블록과 proxy_pass는 설정 로드 시점(startup 또는 reload)에 한 번만 DNS를 해석합니다. 해석 결과를 캐싱해두고 이후에는 갱신하지 않습니다.
이게 왜 문제냐면, Blue-Green 배포에서 아직 Green 컨테이너가 안 떠있는 상태에서 Nginx를 시작하면 이렇게 됩니다.
# upstream.conf에 이렇게 써있는데
upstream springapp {
server techfork-app-green:8080; ← 아직 없는 컨테이너
}
# Nginx 시작 시
nginx: [emerg] host not found in upstream "techfork-app-green" in ...
# → Nginx 기동 실패!
TechFork에서는 이 문제를 두 가지 방법으로 해결합니다.
방법 1: 더미 upstream (배포 스크립트)
배포 스크립트에서 인프라를 기동하기 전에, 임시로 localhost를 가리키는 upstream을 넣어둡니다.
# deploy.sh — Nginx 크래시 방지
cat > "$UPSTREAM_CONF" <<EOF
upstream springapp {
server 127.0.0.1:9999; # 존재하지 않는 포트지만 DNS 해석은 성공
}
EOF
IP 주소는 DNS 해석이 필요 없으므로 Nginx가 정상 기동됩니다. 이후 Blue/Green 컨테이너가 뜨고 헬스체크가 통과하면, 실제 컨테이너를 가리키도록 교체합니다.
방법 2: 변수 + resolver (Dev 서버)
Dev 환경은 좀 다른 접근을 씁니다. Dev 컨테이너는 항상 떠있다는 보장이 없기 때문에, resolver 디렉티브를 사용합니다.
server {
listen 80;
server_name dev.techfork.shop;
location / {
resolver 127.0.0.11 valid=30s; # Docker 내장 DNS 사용
set $dev_upstream http://techfork-app-dev:8080; # 변수에 담기
proxy_pass $dev_upstream; # 변수로 전달
}
}
핵심은 proxy_pass에 직접 호스트명을 쓰지 않고 변수($dev_upstream)를 경유한다는 것입니다. Nginx는 변수가 포함된 proxy_pass를 만나면 요청 시점마다(runtime) DNS를 해석합니다. resolver 127.0.0.11 valid=30s는 Docker 내장 DNS를 사용하되, 결과를 30초간 캐싱하라는 뜻입니다.
이 조합 덕분에 dev 컨테이너가 없어도 Nginx는 정상 기동되고, 컨테이너가 나중에 뜨면 자동으로 연결됩니다. 다만 컨테이너가 없는 동안 요청이 오면 502 Bad Gateway가 뜹니다.
두 방식의 차이를 정리하면 이렇습니다.
| upstream 방식 | (Blue/Green) resolver + 변수 방식 (Dev) | |
| DNS 해석 시점 | 설정 로드 시(startup/reload) | 매 요청 시(runtime) |
| 호스트 없을 때 | Nginx 기동 실패 | 기동 성공, 요청 시 502 |
| 성능 | DNS 조회 1회 (캐싱) | 매 요청마다 조회 (valid로 캐싱) |
| 용도 | 안정적인 프로덕션 트래픽 | 가용성 불확실한 개발 환경 |
1-4. 헬스체크에서 DNS가 동작하는 방식
배포 스크립트의 헬스체크가 Docker DNS를 직접 활용하는 장면을 다시 보겠습니다.
health_check() {
local container="$1" # e.g., "techfork-app-green"
docker exec techfork-nginx curl -sf "http://${container}:8080/actuator/health"
}
docker exec techfork-nginx로 Nginx 컨테이너 안에서 curl을 실행합니다. Nginx 컨테이너의 /etc/resolv.conf에는 nameserver 127.0.0.11이 설정되어 있으므로, techfork-app-green이라는 이름이 Docker DNS를 통해 정상적으로 해석됩니다.
호스트 머신에서 직접 curl을 하지 않는 이유는, Docker 내장 DNS는 컨테이너 내부에서만 동작하기 때문입니다. 호스트에서 curl http://techfork-app-green:8080을 하면 DNS 해석이 실패합니다.
1-5. 네트워크 alias vs container_name vs hostname
Docker Compose에서 이름을 설정하는 방법이 세 가지인데, 각각 역할이 다릅니다.
services:
elasticsearch:
container_name: techfork-elasticsearch # Docker 데몬 레벨 이름
hostname: techfork-elasticsearch # 컨테이너 내부 hostname
networks:
techfork-network:
aliases:
- elasticsearch # DNS alias (짧은 이름)
container_name은 Docker 전체에서 유일해야 합니다. docker ps, docker exec 등에서 사용되고, 같은 네트워크 안에서 DNS로도 해석됩니다.
hostname은 컨테이너 내부에서 hostname 명령을 쳤을 때 나오는 값으로, ES 클러스터의 node.name 같은 용도로 사용됩니다.
aliases는 네트워크 스코프의 DNS 별칭으로, 여러 네트워크에 다른 alias를 줄 수 있습니다. 짧은 이름을 쓰고 싶을 때 유용합니다.
2. 인프라 구성: Docker Compose 멀티 프로젝트 전략
전체 인프라를 4개의 Docker Compose 파일로 분리해서 관리합니다. 핵심은 인프라 컨테이너(DB, 캐시, 검색엔진)와 애플리케이션 컨테이너를 완전히 분리하는 것입니다.
| 파일 | 역할 | Docker Compose 프로젝트명 |
| docker-compose.infra.yml | MySQL, Redis, ES, Nginx | techfork-infra |
| docker-compose.blue.yml | Spring Boot (Blue) | techfork-blue |
| docker-compose.green.yml | Spring Boot (Green) | techfork-green |
| docker-compose.dev.yml | Spring Boot (Dev 환경) | techfork-dev |
이렇게 분리하면 애플리케이션 배포 시 인프라 컨테이너에 전혀 영향을 주지 않습니다. Blue를 내리고 Green을 올릴 때 MySQL이나 Elasticsearch가 재시작되는 일이 없죠.
모든 컨테이너는 techfork-network라는 외부 Docker 네트워크를 공유합니다.
docker network create techfork-network
2-1. 인프라 컴포즈 (docker-compose.infra.yml)
services:
mysql:
image: mysql:8.0
container_name: techfork-mysql
restart: always
environment:
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
- MYSQL_DATABASE=techblog
- MYSQL_USER=techfork
- MYSQL_PASSWORD=${DB_PASSWORD}
- TZ=Asia/Seoul
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --innodb-buffer-pool-size=2G
volumes:
- mysql-data:/var/lib/mysql
networks:
techfork-network:
aliases:
- mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: techfork-redis
restart: always
command:
redis-server
--requirepass ${REDIS_PASSWORD}
--maxmemory 1gb
--maxmemory-policy allkeys-lru
--save ""
--appendonly no
--rename-command KEYS ""
--rename-command FLUSHALL ""
--rename-command FLUSHDB ""
volumes:
- redis-data:/data
networks:
techfork-network:
aliases:
- redis
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.18.0
container_name: techfork-elasticsearch
restart: always
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- "ES_JAVA_OPTS=-Xms8g -Xmx8g"
volumes:
- elasticsearch-data:/usr/share/elasticsearch/data
networks:
techfork-network:
aliases:
- elasticsearch
ulimits:
memlock:
soft: -1
hard: -1
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
nginx:
image: nginx:stable-alpine
container_name: techfork-nginx
restart: always
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
networks:
techfork-network:
aliases:
- nginx
networks:
techfork-network:
external: true
volumes:
mysql-data:
external: true
name: deploy_mysql-data
redis-data:
external: true
name: deploy_redis-data
elasticsearch-data:
external: true
name: deploy_elasticsearch-data
여기서 주목할 포인트들이 있습니다.
Redis 보안 설정 — KEYS, FLUSHALL, FLUSHDB 명령어를 rename-command로 비활성화했습니다. 프로덕션에서 KEYS * 같은 명령이 실행되면 Redis가 블로킹되기 때문에 아예 차단하는 게 안전합니다.
Elasticsearch ulimits — memlock을 -1(무제한)로 설정해야 ES가 메모리를 스왑하지 않고 안정적으로 동작합니다. start_period: 60s로 ES 기동 시간을 충분히 확보했습니다.
외부 볼륨 — external: true로 선언해서 docker compose down 시에도 데이터가 절대 날아가지 않습니다.
2-2. 애플리케이션 컴포즈 (Blue / Green)
Blue와 Green의 구조는 동일하고, 컨테이너 이름만 다릅니다.
# docker-compose.blue.yml
services:
app-blue:
image: ${DOCKER_IMAGE}:${BRANCH}
container_name: techfork-app-blue
restart: always
environment:
- JAVA_OPTS=-Xms2g -Xmx2g
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE}
- DB_URL=${DB_URL}
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_PASSWORD=${REDIS_PASSWORD}
# ... 기타 환경변수
volumes:
- ~/keys:/app/keys:ro
networks:
techfork-network:
aliases:
- app-blue
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
techfork-network:
external: true
healthcheck를 Docker 레벨에서 정의해놨지만, 실제 배포 시에는 Nginx 컨테이너에서 직접 curl로 체크합니다. Docker 헬스체크는 docker ps에서 상태 확인용이고, 배포 스크립트의 헬스체크가 실제 트래픽 전환 판단 기준입니다.
3. Nginx 설정: 리버스 프록시 + Cloudflare 연동
3-1. upstream 동적 전환의 핵심
Blue-Green 배포의 핵심은 Nginx의 upstream.conf 파일을 동적으로 교체하는 것입니다.
# nginx/conf.d/upstream.conf — 배포 스크립트가 이 파일을 덮어씁니다
upstream springapp {
server techfork-app-blue:8080 fail_timeout=0;
}
배포 시 이 파일의 내용이 techfork-app-green:8080으로 바뀌고, nginx -s reload로 무중단 전환됩니다. Docker 네트워크 안에서 컨테이너 이름으로 직접 통신하기 때문에 포트 매핑 없이도 동작합니다.
3-2. Cloudflare Real IP 복원
Cloudflare를 거치면 모든 요청의 소스 IP가 Cloudflare 서버 IP로 바뀝니다. 실제 클라이언트 IP를 복원하기 위해 Cloudflare IP 대역을 set_real_ip_from으로 등록합니다.
# nginx/conf.d/default.conf
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
real_ip_header CF-Connecting-IP;
server {
listen 80;
server_name api.techfork.shop techfork.shop;
location / {
proxy_pass http://springapp;
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 $http_x_forwarded_proto;
}
location /nginx-health {
access_log off;
return 200 "ok";
}
}
real_ip_header CF-Connecting-IP가 핵심입니다. Cloudflare는 원본 클라이언트 IP를 CF-Connecting-IP 헤더에 담아서 보내주는데, 이 설정이 없으면 Spring Boot의 HttpServletRequest.getRemoteAddr()에서 항상 Cloudflare IP가 찍힙니다.
💡 이 프록시 헤더들(X-Real-IP, X-Forwarded-For, X-Forwarded-Proto)이 TLS 종료 지점에 따라 어떻게 달라지는지, Nginx에서 직접 TLS를 처리할 때와 비교한 별도 글을 작성했습니다. → TLS 종료 지점에 따른 Nginx 프록시 헤더 완전 정복
3-3. Dev 환경 분리
dev 서버는 별도 서브도메인(dev.techfork.shop)으로 분리했습니다. resolver 127.0.0.11은 Docker 내장 DNS로, 아직 뜨지 않은 dev 컨테이너에 대해서도 Nginx가 크래시하지 않도록 변수 방식으로 우회합니다.
server {
listen 80;
server_name dev.techfork.shop;
location / {
resolver 127.0.0.11 valid=30s;
set $dev_upstream http://techfork-app-dev:8080;
proxy_pass $dev_upstream;
}
}
이 트릭은 실무에서 꽤 유용합니다. 일반적으로 Nginx는 시작 시점에 upstream을 DNS 해석하는데, 해당 호스트가 없으면 바로 에러가 납니다. set 변수 + resolver 조합으로 런타임에 해석하게 만들면 이 문제를 피할 수 있습니다.
4. Cloudflare + Terraform DNS 관리
DNS 레코드를 Terraform으로 코드화했습니다. 인프라를 재구성하더라도 DNS 설정이 자동으로 따라갑니다.
# cloudflare.tf
resource "cloudflare_record" "main" {
zone_id = var.cloudflare_zone_id
name = "@"
content = aws_instance.app.public_ip
type = "A"
ttl = 1 # Auto (Cloudflare 관리)
proxied = true # Cloudflare CDN/보안 활성화
}
resource "cloudflare_record" "api" {
zone_id = var.cloudflare_zone_id
name = "api"
content = aws_instance.app.public_ip
type = "A"
ttl = 1
proxied = true
}
resource "cloudflare_record" "ssh" {
zone_id = var.cloudflare_zone_id
name = "ssh"
content = aws_instance.app.public_ip
type = "A"
ttl = 300
proxied = false # SSH는 프록시 OFF (직접 연결)
}
proxied = true인 레코드는 Cloudflare CDN을 경유하면서 DDoS 방어, SSL 종료, 캐싱 등의 혜택을 받습니다. 반면 SSH 접속용 레코드는 proxied = false로 직접 연결해야 합니다. Cloudflare 프록시는 HTTP/HTTPS만 지원하기 때문입니다.
SSL 구성: Cloudflare에서 SSL 종료를 처리하므로 Nginx는 80번 포트(HTTP)만 리스닝합니다. 사용자 ↔ Cloudflare 구간은 HTTPS, Cloudflare ↔ Nginx 구간은 HTTP인 구조입니다. Cloudflare의 SSL 모드를 "Flexible" 또는 "Full"로 설정하면 됩니다.
5. CI 파이프라인 (GitHub Actions)
5-1. CI — 빌드 & 테스트
# .github/workflows/ci.yml
name: CI - Build and Test
on:
pull_request:
branches: [ develop, main ]
push:
branches: [ develop, main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: Build with all tests
run: ./gradlew build --no-daemon
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: tech-fork-jar
path: build/libs/*.jar
retention-days: 3
빌드된 JAR를 artifact로 업로드합니다. CD 워크플로우가 이 artifact를 다운로드해서 Docker 이미지를 빌드하는 구조로, CI와 CD 간의 의존성을 artifact를 통해 깔끔하게 연결합니다.
5-2. CD — Docker 이미지 빌드 & 배포
# .github/workflows/cd.yml
name: CD - Deploy to Server
on:
workflow_run:
workflows: ["CI - Build and Test"]
types: [completed]
branches: [ develop, main ]
jobs:
docker-build:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: actions/checkout@v6
- name: Download JAR from CI
uses: actions/download-artifact@v7
with:
name: tech-fork-jar
path: build/libs/
run-id: ${{ github.event.workflow_run.id }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
push: true
tags: |
${{ env.DOCKER_IMAGE }}:${{ github.event.workflow_run.head_branch }}
${{ env.DOCKER_IMAGE }}:${{ github.event.workflow_run.head_branch }}-${{ github.event.workflow_run.head_sha }}
platforms: linux/arm64로 ARM 아키텍처 이미지를 빌드합니다. 서버가 ARM 기반(Oracle Cloud Ampere 등)이기 때문입니다. QEMU를 통한 크로스 컴파일로 GitHub Actions(x86)에서 ARM 이미지를 만듭니다.
태그 전략은 두 가지를 동시에 사용합니다. 브랜치명 태그는 항상 최신 빌드를 가리키는 롤링 태그이고, 브랜치명-커밋해시 태그는 특정 버전을 추적할 수 있는 고정 태그입니다.
5-3. CD — 서버 배포 실행
deploy:
needs: [docker-build]
runs-on: ubuntu-latest
steps:
- name: Copy deployment files to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
source: "docker/,scripts/deploy.sh"
target: "~/deploy/"
- name: Deploy with blue-green strategy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
command_timeout: 10m
envs: >-
DOCKER_IMAGE,BRANCH,SPRING_PROFILES_ACTIVE,DB_URL,DB_PASSWORD,...
script: |
cd ~/deploy
chmod +x scripts/deploy.sh
./scripts/deploy.sh
배포 파일(docker-compose, nginx 설정, 배포 스크립트)을 SCP로 서버에 전송한 뒤, SSH로 접속해서 배포 스크립트를 실행합니다. 환경변수는 GitHub Secrets에서 관리하고, envs로 SSH 세션에 주입합니다.
6. Blue-Green 배포 스크립트 상세 분석
이 스크립트가 전체 무중단 배포의 핵심입니다. 12단계로 구성됩니다.
#!/bin/bash
set -euo pipefail
set -euo pipefail — 에러 발생 시 즉시 중단, 미정의 변수 사용 금지, 파이프라인 에러 전파. 배포 스크립트에서는 반드시 넣어야 합니다. 중간에 실패한 걸 모르고 넘어가면 재앙이 됩니다.
Step 1-2. 락(Lock) 획득 & 인프라 기동
# 동시 배포 방지를 위한 디렉토리 락
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
log "ERROR: Another deployment is already running. Exiting."
exit 1
fi
trap cleanup EXIT
# .env 파일 생성 (SSH로 주입된 환경변수에서 추출)
env | grep -E '^(DOCKER_IMAGE|BRANCH|SPRING_PROFILES_ACTIVE|DB_|REDIS_|...)' > "${DOCKER_DIR}/.env"
chmod 600 "${DOCKER_DIR}/.env"
mkdir을 락으로 사용하는 건 shell 스크립트에서 원자적(atomic) 락을 구현하는 고전적인 방법입니다. flock보다 심플하고 크로스 플랫폼에서 동작합니다. trap cleanup EXIT로 스크립트가 어떻게 종료되든 락을 반드시 해제합니다.
# Nginx 크래시 방지를 위한 더미 upstream 설정
cat > "$UPSTREAM_CONF" <<EOF
upstream springapp {
server 127.0.0.1:9999; # 아무도 없는 포트
}
EOF
docker compose $COMPOSE_INFRA up -d
인프라를 기동할 때 중요한 트릭이 있습니다. Nginx가 upstream.conf에 정의된 호스트를 시작 시점에 해석하는데, 아직 Blue/Green 컨테이너가 없으면 Nginx가 기동 자체를 실패합니다. 그래서 임시로 아무 곳도 가리키지 않는 127.0.0.1:9999를 넣어놓습니다. 502가 뜨긴 하지만 Nginx 자체는 살아있죠.
Step 3-5. 색상 결정 & 새 컨테이너 기동
ACTIVE_COLOR=$(get_active_color) # .active-color 파일에서 읽기
if [ -z "$ACTIVE_COLOR" ]; then
TARGET_COLOR="blue" # 첫 배포면 blue
else
TARGET_COLOR=$(get_target_color "$ACTIVE_COLOR") # blue↔green 토글
fi
docker compose $TARGET_COMPOSE pull # 최신 이미지 가져오기
docker compose $TARGET_COMPOSE up -d # 새 컨테이너 시작
현재 활성 색상을 .active-color 파일로 관리합니다. 파일 시스템 기반이라 단순하지만 확실합니다.
Step 6. 헬스체크
health_check() {
local container="$1"
for i in $(seq 1 "$HEALTH_CHECK_RETRIES"); do
if docker exec techfork-nginx curl -sf \
"http://${container}:8080/actuator/health" > /dev/null 2>&1; then
return 0
fi
sleep "$HEALTH_CHECK_INTERVAL"
done
return 1
}
Nginx 컨테이너 안에서 curl로 새 앱 컨테이너의 /actuator/health를 호출합니다. 같은 Docker 네트워크 안에 있으므로 컨테이너 이름으로 직접 통신 가능합니다. 30회 × 5초 = 최대 2분 30초를 기다립니다. Spring Boot가 기동되는 데 충분한 시간입니다.
헬스체크 실패 시 자동 롤백합니다.
if health_check "$TARGET_CONTAINER"; then
log "Target container is healthy"
else
log "ROLLBACK: Stopping failed container..."
docker compose $TARGET_COMPOSE down
exit 1
fi
Step 7-8. Nginx upstream 전환 & Grace Period
switch_upstream() {
local container=$(get_container_name "$1")
cat > "$UPSTREAM_CONF" <<EOF
upstream springapp {
server ${container}:8080 fail_timeout=0;
}
EOF
docker exec techfork-nginx nginx -t # 설정 문법 검증
docker exec techfork-nginx nginx -s reload # 무중단 리로드
}
switch_upstream "$TARGET_COLOR"
sleep 10 # 진행 중인 요청 처리 대기
nginx -t로 설정 파일 문법을 먼저 검증합니다. 이것 없이 바로 reload하면 잘못된 설정으로 Nginx가 죽을 수 있습니다. nginx -s reload는 기존 worker를 graceful하게 종료하므로, 처리 중인 요청을 끊지 않습니다. 추가로 sleep 10으로 안전 마진을 둡니다.
Step 9-12. 구버전 제거 & 정리
# 이전 컨테이너 종료
if [ -n "$ACTIVE_COLOR" ]; then
docker compose $OLD_COMPOSE down
fi
# 활성 색상 기록
echo "$TARGET_COLOR" > "$STATE_FILE"
# Dev 컨테이너도 배포 (실패해도 non-blocking)
docker compose $COMPOSE_DEV pull
docker compose $COMPOSE_DEV up -d
# 미사용 이미지 정리
docker image prune -af
구버전 컨테이너를 내린 뒤, dev 환경도 함께 배포합니다. dev 헬스체크는 실패해도 전체 배포를 중단하지 않습니다(non-blocking). 마지막으로 docker image prune -af로 디스크를 정리합니다.
7. Dockerfile
FROM eclipse-temurin:17-jre
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
eclipse-temurin:17-jre를 베이스로 사용합니다. JDK가 아니라 JRE인 이유는 운영 환경에서 컴파일할 일이 없기 때문입니다. 이미지 크기가 상당히 줄어듭니다. curl은 헬스체크용으로 설치합니다.
JAVA_OPTS를 환경변수로 분리한 것이 포인트입니다. Docker Compose의 environment에서 -Xms2g -Xmx2g 같은 JVM 옵션을 주입할 수 있어서, 이미지 재빌드 없이 메모리 설정을 조정할 수 있습니다.
8. 서버 초기 설정 (Cloud Init)
EC2/OCI 인스턴스가 처음 생성될 때 실행되는 초기화 스크립트입니다.
# Swap 메모리 (4GB) — Elasticsearch 안정성 필수
fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo 'vm.swappiness=1' >> /etc/sysctl.conf
# Elasticsearch 필수 커널 파라미터
echo 'vm.max_map_count=262144' >> /etc/sysctl.conf
sysctl -w vm.max_map_count=262144
# Docker 설치 & 네트워크 생성
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
docker network create techfork-network
vm.max_map_count=262144는 Elasticsearch 운영의 필수 조건입니다. 이 값이 낮으면 ES가 시작 시 에러를 뿜습니다. vm.swappiness=1은 가능한 한 스왑을 쓰지 않겠다는 설정으로, ES 성능에 중요합니다.
9. 전체 배포 타임라인
실제 배포가 일어나는 순서를 시간 순으로 정리하면 이렇습니다.
개발자가 develop 브랜치에 Push
CI 워크플로우 시작 — Gradle 빌드 & 테스트
CI 성공 → JAR artifact 업로드
CD 워크플로우 트리거 (workflow_run)
Docker 이미지 빌드 & DockerHub Push (ARM64 크로스 빌드)
SCP로 배포 파일 서버 전송
SSH 접속 → deploy.sh 실행
.env 생성, 인프라 컨테이너 확인
새 색상 컨테이너 Pull & Start
헬스체크 통과 (Spring Boot 기동 완료)
upstream.conf 교체 → nginx reload
✅ 트래픽 전환 완료! (무중단)
10초 Grace Period
구버전 컨테이너 종료
Dev 컨테이너 배포
이미지 정리 → 배포 완료
Push부터 완료까지 약 5분입니다.
마무리
이 구성의 장점을 정리하면 다음과 같습니다.
무중단 보장 — Blue-Green 전략으로 트래픽 전환 시점에 끊김이 없습니다. Nginx reload가 기존 커넥션을 유지하면서 새 설정을 적용하기 때문입니다.
자동 롤백 — 헬스체크 실패 시 새 컨테이너를 즉시 내리고, 기존 컨테이너가 그대로 서비스합니다. upstream이 아직 전환되지 않은 상태이므로 사용자는 아무것도 모릅니다.
인프라 독립 — Docker Compose 프로젝트를 분리해서 앱 배포가 DB나 ES에 영향을 주지 않습니다.
코드로서의 인프라 — Terraform으로 Cloudflare DNS, 서버 프로비저닝을 관리하고, Docker Compose로 컨테이너 구성을 코드화했습니다.
보안 — Cloudflare 프록시로 원본 서버 IP를 숨기고, 환경변수는 GitHub Secrets로 관리하며, .env 파일 권한은 600으로 제한합니다.
'프로젝트 > Techfork' 카테고리의 다른 글
| [26/02/22] 오늘의 개발 일지 - Logback MDC, 파일 롤링, 비동기 스레드 MDC 전파 (0) | 2026.03.02 |
|---|---|
| [26/02/21] 오늘의 개발 일지 - Apple 키 파일 보안 취약점 해소와 github actions 의존성 최신화 (0) | 2026.02.21 |
| [26/02/18] 오늘의 개발 일지 - Cloudflare 환경에서 Nginx 프록시 헤더 잘 사용하기 (0) | 2026.02.21 |
| [26/02/05] 오늘의 개발 일지 - 오프라인 평가 프레임워크 구축 및 추천 시스템 네이티브 kNN 전환 (0) | 2026.02.06 |
| [26/01/28] 오늘의 개발 일지 - 웹 Apple 소셜 로그인 구현 (0) | 2026.01.29 |