본문 바로가기

프로젝트/Techfork

[26/02/21] 오늘의 개발 일지 - Cloudflare + Nginx + Docker Blue-Green 무중단 배포 구축기

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으로 제한합니다.