배포의 모든 것 - 5. Blue-Green 무중단 배포 적용

2025. 5. 1. 20:42·프로젝트

 

🥲 길고 길었던 배포에 대한 드디어 마지막 게시물입니다.

이번 포스트에서는 기존에 해오던 실습에 도커를 통한 무중단 배포를 적용하면서 마무리를 해보도록 하겠습니다

 

Docker에 대해서 알아보자 - 1. Docker의 개념과 생태계 🔗

Docker에 대해서 알아보자 - 2. Dockerfile 🔗

Docker에 대해서 알아보자 - 3. docker build & docker compose 🔗

 

도커에 대한 개념은 따로 포스트로 정리해 두었습니다. 
따라서, 조금은 가볍게 설명하면서 속도감 있게 넘어갈 예정입니다. 개념을 잘 알고 계시다면 확인하지 않아도 좋습니다.

 

지금부터 진행하는 Blue-Green 무중단 배포 방식은 정답은 아닙니다..! 제가 어떻게 진행을 했는지 소개를 해드리겠습니다.

우선 저 같은 경우에는 로컬에서 MySQL을 이용해서 테스트용 DB를 사용하는데 H2를 사용하셔도 되고 자유입니다.

spring:
  profiles:
    active: local

---
spring:
  config:
    activate:
      on-profile: local
  datasource:
    username: (로컬 DB 유저명)
    password: (로컬 DB 비밀번호)
    url: jdbc:mysql://localhost:3306/(로컬 DB명)
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect
        show_sql: true
        format_sql: true
        use_sql_comments: true
        default_batch_fetch_size: 1000
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE
    org.springframework.jdbc.core: DEBUG

---
spring:
  config:
    activate:
      on-profile: prod
  datasource:
    username: (RDS 유저명)
    password: (RDS 비밀번호)
    url: jdbc:mysql://practicedb.cb6kacocu13f.ap-northeast-2.rds.amazonaws.com:3306/(RDS DB명)
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect
        show_sql: false
        format_sql: false
        use_sql_comments: false
        default_batch_fetch_size: 100
  servlet:
    multipart:
      max-file-size: 50MB
      max-request-size: 50MB
logging:
  level:
    org.hibernate.SQL: INFO

 

application-secret.yml을 다음과 같이 바꿔주겠습니다.

  • 기본적으로 local 프로필을 사용하며 이는 로컬 환경에 맞도록 설정이 되어 있습니다. 그리고 배포환경을 위한 prod 프로필을 하나 더 만들고 배포 환경에 맞도록 설정을 해주었습니다.

🔥 이제는 로컬에서도 어플리케이션을 돌릴 수 있습니다..!!!

📌 Dockerhub 세팅

다음으로 도커와 관련된 설정들을 해주겠습니다.

  • dockerhub에 가입을 해주세요, 저도 연습용 아이디를 하나 만들어 봤습니다.

  • Repository를 하나 만들어 주겠습니다. 
    Private Repository를 하나 만들 수 있는데 저는 이미지를 공유할 일이 없어서 private으로 진행하겠습니다.

  • 우측 상단에서 Account Setting으로 넘어가 주세요.

  • Personal Access Token을 하나 만들어 주겠습니다. 

  • 권한만 모든 권한을 설정해 주시고 나머지는 자유롭게 정해주세요!
    토큰은 잘 보관해 주세요... 잊어버리면 안 됩니다.

    토큰을 통해서 Repository에 이미지를 push 하고 다운로드할 수 있습니다.

📌 Github Secrets 세팅

  • 그리고 DOCKERHUB_USERNAME, DOCKERHUB_TOKEN(방금 발급받음), 그리고 DOCKERHUB_REPOSITORY_NAME을 Github Secrets에 등록해 주세요.

✅ DOCKER_COMPOSE 라는 secret도 만들어 주세요!

더보기
version: '3.8'

services:
  app_blue:
    image: practice0511/practice-repository:1.0.0
    container_name: app_blue
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/app/config/
    volumes:
      - /home/ubuntu/secret/application-secret.yml:/app/config/application-secret.yml:ro
    networks:
      - app_network

  app_green:
    image: practice0511/practice-repository:1.0.0
    container_name: app_green
    ports:
      - "8081:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/app/config/
    volumes:
      - /home/ubuntu/secret/application-secret.yml:/app/config/application-secret.yml:ro
    networks:
      - app_network

networks:
  app_network:
    driver: bridge

 

📌 AWS 세팅

💡 다음으로 AWS 서버에 몇 가지 setting을 해놓겠습니다. 먼저 필요한 것들을 세팅하고 나중에 전체적으로 다시 설명드리겠습니다.

mkdir /home/ubuntu/secret
  • AWS 서버에 접속해서 다음과 같이 secret 경로를 하나 만들어 주세요. (application-secret.yml 보관용 디렉토리)
sudo docker network create --driver bridge app_network
  • app_network라는 컨테이너 간 연결을 할 수 있는 네트워크를 만들어 주겠습니다.

sudo docker network ls 명령어를 통해서 만들어진 것을 확인할 수 있어요!

  • 처음 AWS에 접속하면 /home/ubuntu 경로 내부에 위치하게 되는데요, cd.. 을 두 번 입력해서 메인 경로까지 나가줍시다.

    그곳에서 cd etc/nginx 경로로 들어가 주세요.

 

sudo vim (파일명) 명령어를 통해서 blue.conf와 green.conf를 만들어 주세요. 둘의 차이는 proxy_pass를 제외하고 전부 동일합니다.

서버네임은 각자 맞게 해 주시면 되고요...

 

✅ blue.conf

더보기
#blue.conf

user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log;
# include /etc/nginx/modules-enabled/*.conf;

events {
        worker_connections 768;
        # multi_accept on;
}

http {


        server {
                listen 80;
                server_name potato-farm.shop;
                location / {
                        proxy_pass http://127.0.0.1:8080;
                        proxy_http_version 1.1;
                        proxy_set_header Connection "";
                        proxy_set_header Host $host;
                        proxy_set_header X-Real-IP $remote_addr;
                        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                           }
                }
        ##
        # Basic Settings
        ##

        sendfile on;
        tcp_nopush on;
        types_hash_max_size 2048;
        # server_tokens off;

        # server_names_hash_bucket_size 64;
        # server_name_in_redirect off;

        include /etc/nginx/mime.types;
        default_type application/octet-stream;

        ##
        # SSL Settings
        ##

        ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
        ssl_prefer_server_ciphers on;

        ##
        # Logging Settings
        ##

        access_log /var/log/nginx/access.log;

        ##
        # Gzip Settings
        ##

        gzip on;

        # gzip_vary on;
        # gzip_proxied any;
        # gzip_comp_level 6;
        # gzip_buffers 16 8k;
        # gzip_http_version 1.1;
        # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

        ##
        # Virtual Host Configs
        ##

        include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/*;
}

✅ green.conf

더보기
#green.conf

user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log;
# include /etc/nginx/modules-enabled/*.conf;

events {
        worker_connections 768;
        # multi_accept on;
}

http {


        server {
                listen 80;
                server_name potato-farm.shop;
                location / {
                        proxy_pass http://127.0.0.1:8081;
                        proxy_http_version 1.1;
                        proxy_set_header Connection "";
                        proxy_set_header Host $host;
                        proxy_set_header X-Real-IP $remote_addr;
                        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                           }
                }
        ##
        # Basic Settings
        ##

        sendfile on;
        tcp_nopush on;
        types_hash_max_size 2048;
        # server_tokens off;

        # server_names_hash_bucket_size 64;
        # server_name_in_redirect off;

        include /etc/nginx/mime.types;
        default_type application/octet-stream;

        ##
        # SSL Settings
        ##

        ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
        ssl_prefer_server_ciphers on;

        ##
        # Logging Settings
        ##

        access_log /var/log/nginx/access.log;

        ##
        # Gzip Settings
        ##

        gzip on;

        # gzip_vary on;
        # gzip_proxied any;
        # gzip_comp_level 6;
        # gzip_buffers 16 8k;
        # gzip_http_version 1.1;
        # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

        ##
        # Virtual Host Configs
        ##

        include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/*;
}

다음 명령어들을 입력해 주고...

(도커 권한 설정과 docker-compose 명령어 CLI 설치)

sudo usermod -aG docker ubuntu
sudo apt remove docker-compose -y
sudo curl -SL https://github.com/docker/compose/releases/download/v2.27.1/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
docker-compose version  # 버전 확인 (v2.x 출력돼야 함)

 

기존에 8080 포트에서 실행 중이던 기존의 앱을 꺼줍시다.

sudo lsof -i :8080

 

실행중이던 앱 확인 후에

sudo kill -9 (PID)

 

📌 IntelliJ 세팅

1️⃣ Deploy.yml 수정

더보기
name: CI/CD Pipeline

on:
  push:
    branches: [ develop ]
  pull_request:
    branches: [ develop ]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          install: true

      - name: Log in to DockerHub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Check Dockerfile or Dependency Changes
        id: check_changes
        run: |
          git fetch origin ${{ github.event.before }}
          echo "Changed files between commits:"
          changed_files=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }})
          echo "$changed_files"

          if echo "$changed_files" | grep -qE 'Dockerfile|build\.gradle|settings\.gradle|gradle\.properties|gradlew|gradle/wrapper/gradle-wrapper\.properties'; then
            echo "changed=true" >> $GITHUB_ENV
          else
            echo "changed=false" >> $GITHUB_ENV
          fi

      - name: Build & Push Dependency Cache
        if: env.changed == 'true'
        run: |
          docker buildx build \
            --platform linux/amd64 \
            --push \
            --file Dockerfile \
            --tag ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache \
            --cache-to type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache,mode=max \
            .

      - name: Build & Push Application Image
        run: |
          docker buildx build \
            --platform linux/amd64 \
            --push \
            --file Dockerfile \
            --tag ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:1.0.0 \
            --build-arg DEPENDENCY_IMAGE=${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache \
            --cache-from type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache \
            .

      - name: Create application-secret.yml from Secret
        run: |
          mkdir -p ./temp_secret
          echo "${{ secrets.APPLICATION_SECRET }}" > ./temp_secret/application-secret.yml
        shell: bash

      - name: Copy application-secret.yml to remote
        uses: appleboy/scp-action@v0.1.3
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          source: ./temp_secret/application-secret.yml
          target: /home/ubuntu/secret/

      - name: Write docker-compose.yml from secret
        run: |
          echo "${{ secrets.DOCKER_COMPOSE }}" > docker-compose.yml

      - name: Copy docker-compose.yml to remote
        uses: appleboy/scp-action@v0.1.3
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          source: ./docker-compose.yml
          target: /home/ubuntu/cicd/

      - name: Check if deploy.sh changed
        id: deploy_sh_changed
        run: |
          if git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -q "deploy.sh"; then
            echo "deploy_changed=true" >> $GITHUB_ENV
          else
            echo "deploy_changed=false" >> $GITHUB_ENV
          fi

      - name: Copy deploy.sh to remote
        if: env.deploy_changed == 'true'
        uses: appleboy/scp-action@v0.1.3
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          source: ./deploy.sh
          target: /home/ubuntu/cicd/

      - name: Deploy Blue-Green
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin
            docker pull ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker:1.0.0
            sudo chmod +x /home/ubuntu/cicd/deploy.sh
            sudo /home/ubuntu/cicd/deploy.sh

deploy.yml을 수정한 후에,

다음으로 3가지 파일을 root 경로에 새로 만들어 주세요!

  • .dockerignore
  • deploy.sh
  • Dockerfile

2️⃣ .dockerignore

더보기
# 기본적으로 무시할 것들
.git
.idea
.vscode
*.log
*.iml

# 빌드 결과물 무시
build/
target/

# 민감 파일 무시
src/main/resources/application-secret.yml

#도커 관련 파일
docker-compose.yml
Dockerfile

# 깃허브 파일
.github/

 

git ignore된 파일들은 github action에서 인식을 할 수 없기 때문에 docker ignore을 사용할 필요까지는 없을 수도 있습니다.

하지만, github action이 아닌 jenkins를 추가적으로 사용하거나 로컬 IntelliJ 터미널에서 직접 이미지를 만드는 경우 민감한 정보가 github가 추적하는 것과 별개로 docker가 인식해서 docker image에 포함될 수 있기 때문에 .dockerignore를 작성해 주겠습니다.

 

3️⃣ Deploy.sh

더보기
#!/bin/bash

cd /home/ubuntu/cicd

APP_NAME="practice"

# NGINX 설정 관련
NGINX_CONF_PATH="/etc/nginx"
BLUE_CONF="blue.conf"
GREEN_CONF="green.conf"
DEFAULT_CONF="nginx.conf"
MAX_RETRIES=3

# 활성화된 서비스 확인 및 스위칭 대상 결정
determine_target() {
  if docker-compose -f docker-compose.yml ps | grep -q "app_blue.*Up"; then
    TARGET="green"
    OLD="blue"
  elif docker-compose -f docker-compose.yml ps | grep -q "app_green.*Up"; then
    TARGET="blue"
    OLD="green"
  else
    TARGET="blue"  # 첫 실행 시 기본값
    OLD="none"
  fi

  echo "TARGET: $TARGET"
  echo "OLD: $OLD"
}

# 헬스체크 실패 시 롤백 처리
health_check() {
  local URL=$1
  local RETRIES=0
  local ORIGINAL_TARGET=$TARGET

  while [ $RETRIES -lt $MAX_RETRIES ]; do
    echo "Checking service at $URL... (attempt: $((RETRIES + 1)))"
    sleep 3

    CONTAINER_RUNNING=$(docker ps --filter "name=app_$TARGET" --format '{{.Names}}')

    if [ "$CONTAINER_RUNNING" = "app_$TARGET" ]; then
      echo "$TARGET container is running."
      return 0
    else
      echo "$TARGET container is not running."
    fi

    RETRIES=$((RETRIES + 1))
  done

  echo "Health check failed after $MAX_RETRIES attempts."
  echo "Rolling back to the original target: $ORIGINAL_TARGET"
  TARGET=$ORIGINAL_TARGET
  echo "Rolled back TARGET: $TARGET"
  echo "Failed health check for $TARGET container" > /home/ubuntu/cicd/health_check_failure.log
  exit 1
}

# NGINX 설정 스위칭 함수
switch_nginx_conf() {
  if [ "$TARGET" = "blue" ]; then
    sudo cp "${NGINX_CONF_PATH}/${BLUE_CONF}" "${NGINX_CONF_PATH}/${DEFAULT_CONF}"
  else
    sudo cp "${NGINX_CONF_PATH}/${GREEN_CONF}" "${NGINX_CONF_PATH}/${DEFAULT_CONF}"
  fi

  echo "Reloading NGINX configuration..."
  nginx -s reload
}

# 이전 컨테이너 종료 함수
down_old_container() {
  if [ "$OLD" != "none" ]; then
    echo "Stopping old container: $OLD"
    sudo docker stop "app_$OLD"
  fi
}

# 메인 실행 로직
main() {
  determine_target

  echo "Starting $TARGET container..."
  docker-compose -p $APP_NAME -f docker-compose.yml up -d "app_$TARGET"

  if [ "$TARGET" = "blue" ]; then
    health_check "http://127.0.0.1:8080/actuator/health"
  else
    health_check "http://127.0.0.1:8081/actuator/health"
  fi

  switch_nginx_conf
  down_old_container

  echo "Deployment to $TARGET completed successfully!"
  echo "Cleaning up dangling Docker images..."
  docker image prune -f
}

main

 

배포를 실행해 줄 스크립트 파일입니다.

" 이게 왜 근데 프로젝트 root에 있을까? "라고 생각하실 수도 있어요. 실제로 저도 그런 의문을 가지고 있었습니다. 
결과적으로 저희는 이 스크립트를 배포 서버로 복사를 해줄 예정입니다...!

이런 식으로 CI/CD를 작성하게 되면 스크립트를 서버가 아닌 로컬에서 수정을 해도 자동으로 배포가 된다는 편리함 + 사람들이 github repository만 보고도 배포 과정을 한눈에 볼 수 있다는 장점이 있습니다.

 

4️⃣ Dockerfile

더보기
# syntax=docker/dockerfile:1.4

# 의존성 레이어
FROM gradle:8.5-jdk17 AS dependencies
WORKDIR /build

COPY gradlew .
COPY gradle/wrapper/gradle-wrapper.jar gradle/wrapper/
COPY gradle/wrapper/gradle-wrapper.properties gradle/wrapper/
COPY build.gradle settings.gradle ./

RUN ./gradlew dependencies --no-daemon


# 빌드 레이어
FROM gradle:8.5-jdk17 AS builder
WORKDIR /build

COPY --from=dependencies /build /build
COPY src src

RUN ./gradlew build -x test --no-daemon

# 최종 레이어
FROM openjdk:17-jdk-slim

ENV TZ=Asia/Seoul
RUN ln -sf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ >/etc/timezone

WORKDIR /app

COPY --from=builder /build/build/libs/*.jar app.jar

ENTRYPOINT ["java", "-jar", "/app/app.jar"]

도커 이미지를 어떻게 만들지.. 레시피 같은 파일이죠?

 

5️⃣ application.yml 수정

더보기
spring:
  config:
    import: "optional:classpath:application-secret.yml,optional:file:/app/config/application-secret.yml"

jpa:
  hibernate:
    ddl-auto: update
  properties:
    hibernate:
      jdbc:
        time_zone: Asia/Seoul
      show_sql: true
      highlight_sql: true

logging:
  level:
    org.springframework.web: DEBUG
    org.springframework.web.client.DefaultRestClient: OFF

나중에 application-secret을 volume mount 해줄 예정인데 그것을 위한 설정을 반영했습니다.

 

이렇게 설정을 하고 나서,

AWS 서버에서 docker ps 명령어를 통해서 정상적으로 작동이 되는지 확인해 보세요!

📌  CI/CD 흐름 설명

name: CI/CD Pipeline

on:
  push:
    branches: [ develop ]
  pull_request:
    branches: [ develop ]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          install: true

      - name: Log in to DockerHub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Check Dockerfile or Dependency Changes
        id: check_changes
        run: |
          git fetch origin ${{ github.event.before }}
          echo "Changed files between commits:"
          changed_files=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }})
          echo "$changed_files"

          if echo "$changed_files" | grep -qE 'Dockerfile|build\.gradle|settings\.gradle|gradle\.properties|gradlew|gradle/wrapper/gradle-wrapper\.properties'; then
            echo "changed=true" >> $GITHUB_ENV
          else
            echo "changed=false" >> $GITHUB_ENV
          fi

      - name: Build & Push Dependency Cache
        if: env.changed == 'true'
        run: |
          docker buildx build \
            --platform linux/amd64 \
            --push \
            --file Dockerfile \
            --tag ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache \
            --cache-to type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache,mode=max \
            .

      - name: Build & Push Application Image
        run: |
          docker buildx build \
            --platform linux/amd64 \
            --push \
            --file Dockerfile \
            --tag ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:1.0.0 \
            --build-arg DEPENDENCY_IMAGE=${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache \
            --cache-from type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache \
            .


      - name: Create application-secret.yml from Secret
        run: |
          printf '%s\n' "${{ secrets.APPLICATION_SECRET }}" > application-secret.yml

      - name: Copy application-secret.yml to remote
        uses: appleboy/scp-action@v0.1.3
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          source: ./application-secret.yml
          target: /home/ubuntu/secret


      - name: Write docker-compose.yml from secret
        run: |
          echo "${{ secrets.DOCKER_COMPOSE }}" > docker-compose.yml

      - name: Copy docker-compose.yml to remote
        uses: appleboy/scp-action@v0.1.3
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          source: ./docker-compose.yml
          target: /home/ubuntu/cicd/

      - name: Check if deploy.sh changed
        id: deploy_sh_changed
        run: |
          if git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -q "deploy.sh"; then
            echo "deploy_changed=true" >> $GITHUB_ENV
          else
            echo "deploy_changed=false" >> $GITHUB_ENV
          fi

      - name: Copy deploy.sh to remote
        if: env.deploy_changed == 'true'
        uses: appleboy/scp-action@v0.1.3
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          source: ./deploy.sh
          target: /home/ubuntu/cicd/


      - name: Deploy Blue-Green
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin
            docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:1.0.0
            sudo chmod +x /home/ubuntu/cicd/deploy.sh
            sudo /home/ubuntu/cicd/deploy.sh

 

develop 브랜치에 push 또는 pull_request 마다 CI/CD 액션이 돌게 됩니다.

이 부분은 CI/CD 설계가 완료 된다면 push만 설정해주시면 됩니다. pull_request를 조건으로 설정하면 하나의 PR에 계속 push를 하면서 CI/CD가 잘 돌아가는지 테스트가 가능합니다.

 

다음으로는 step의 name에 따라서 진행되는 것을 확인하시면 됩니다.

 

1️⃣ 기본 설정

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          install: true

      - name: Log in to DockerHub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

 

  • JDK 설정
  • gradlew 권한 설정
  • Buildx 설정 ( docker buildx 기능을 사용하기 위한)
  • 도커 허브 로그인

배포를 하기 위한 기본 설정입니다. 크게 어려운 부분이 없죠?

2️⃣  이미지 캐시 적용

      - name: Check Dockerfile or Dependency Changes
        id: check_changes
        run: |
          git fetch origin ${{ github.event.before }}
          echo "Changed files between commits:"
          changed_files=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }})
          echo "$changed_files"

          if echo "$changed_files" | grep -qE 'Dockerfile|build\.gradle|settings\.gradle|gradle\.properties|gradlew|gradle/wrapper/gradle-wrapper\.properties'; then
            echo "changed=true" >> $GITHUB_ENV
          else
            echo "changed=false" >> $GITHUB_ENV
          fi

      - name: Build & Push Dependency Cache
        if: env.changed == 'true'
        run: |
          docker buildx build \
            --platform linux/amd64 \
            --push \
            --file Dockerfile \
            --tag ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache \
            --cache-to type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache,mode=max \
            .

 

이 부분은 조금 이해를 할 필요가 있습니다.

현재 저희는 다음과 같이 3개의 스테이지를 가지는 Dockerfile을 사용하고 있습니다.

 

Dockerfile

# syntax=docker/dockerfile:1.4

# 의존성 레이어
FROM gradle:8.5-jdk17 AS dependencies
WORKDIR /build

COPY gradlew .
COPY gradle/wrapper/gradle-wrapper.jar gradle/wrapper/
COPY gradle/wrapper/gradle-wrapper.properties gradle/wrapper/
COPY build.gradle settings.gradle ./

RUN ./gradlew dependencies --no-daemon


# 빌드 레이어
FROM gradle:8.5-jdk17 AS builder
WORKDIR /build

COPY --from=dependencies /build /build
COPY src src

RUN ./gradlew build -x test --no-daemon

# 최종 레이어
FROM openjdk:17-jdk-slim

ENV TZ=Asia/Seoul
RUN ln -sf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ >/etc/timezone

WORKDIR /app

COPY --from=builder /build/build/libs/*.jar app.jar

ENTRYPOINT ["java", "-jar", "/app/app.jar"]

 

대부분의 업데이트에서 소스파일의 내용은 바뀌게 되지만, 의존성에 대한 부분은 크게 바뀌지 않게 됩니다. 따라서, 의존성과 관련된 스테이지를 분리해서 캐싱을 하면 효율적으로 이미지를 빌드할 수 있습니다.

 

name: Check Dockerfile or Dependency Changes

따라서, 해당 단계에서는

  • Dockerfile 자체가 수정되었는지 -> 수정되었다면 레이어가 다 깨져서 다시 캐시 이미지를 만들어야 함.
  • 의존성이 바뀌었는지

를 확인하고 조건을 충족했다면...

name: Build & Push Dependency Cache

의존성을 캐싱하기 위한 이미지를 dependency-cache라는 태그를 붙여서 Repository로 전송하게 됩니다 (cache-to 사용).

따라서, docker repository에서는 어플리케이션 말고도 캐시를 위한 이미지를 보관하고 있습니다.

 

3️⃣ 어플리케이션 이미지 빌드

      - name: Build & Push Application Image
        run: |
          docker buildx build \
            --platform linux/amd64 \
            --push \
            --file Dockerfile \
            --tag ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:1.0.0 \
            --build-arg DEPENDENCY_IMAGE=${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache \
            --cache-from type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:dependency-cache \
            .

 

이제 어플리케이션에 대한 이미지를 빌드할 때에는 의존성에 대한 캐시를 가져와서 사용하기만 하고 갱신하지 않습니다.
캐시 이미지를 갱신하는 것은 시간이 오래 걸리기 때문에 꼭 갱신해 주어야만 하는 경우에만 2번 단계에서 갱신을 하는 것입니다.

 

4️⃣ 앱 실행에 필요한 파일 배포 서버로 전송하기

      - name: Create application-secret.yml from Secret
        run: |
          printf '%s\n' "${{ secrets.APPLICATION_SECRET }}" > application-secret.yml

      - name: Copy application-secret.yml to remote
        uses: appleboy/scp-action@v0.1.3
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          source: ./application-secret.yml
          target: /home/ubuntu/secret


      - name: Write docker-compose.yml from secret
        run: |
          echo "${{ secrets.DOCKER_COMPOSE }}" > docker-compose.yml

      - name: Copy docker-compose.yml to remote
        uses: appleboy/scp-action@v0.1.3
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          source: ./docker-compose.yml
          target: /home/ubuntu/cicd/

      - name: Check if deploy.sh changed
        id: deploy_sh_changed
        run: |
          if git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -q "deploy.sh"; then
            echo "deploy_changed=true" >> $GITHUB_ENV
          else
            echo "deploy_changed=false" >> $GITHUB_ENV
          fi

      - name: Copy deploy.sh to remote
        if: env.deploy_changed == 'true'
        uses: appleboy/scp-action@v0.1.3
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          source: ./deploy.sh
          target: /home/ubuntu/cicd/


      - name: Deploy Blue-Green
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.EC2_HOST }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin
            docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY_NAME }}:1.0.0
            sudo chmod +x /home/ubuntu/cicd/deploy.sh
            sudo /home/ubuntu/cicd/deploy.sh
  • application-secret.yml  :  DB 연결 정보 등등.. 당연히 런타임에 필요한 정보이기 때문에..? 당연히 Jar 파일과 함께 필요한 정보입니다. 그런데 이것을 소스파일과 함께 jar로 빌드하게 되면 민감한 정보가 어플리케이션의 image에 포함되기 때문에 절대로 포함시켜서는 안 됩니다. (이미지를 아예 공유하지 않을 것이라면 상관없지만 좋은 설계는 아닌 듯)

    따라서, application-secret.yml을 배포 서버로 전송하고 image를 실행할 때 mount 해줄 예정입니다.
  • docker-compose.yml :  image를 어떻게 실행을 시키고 container를 어떻게 관리할지 실행과 관련된 파일입니다. 이것 역시 배포 서버로 보내주어야 합니다.

    docker-compose.yml을 github secret이 아니라 root에 위치시키는 분들도 많이 있습니다.
    사실 저희도 private repository를 사용하기 때문에 노출되어도 상관은 없지만, 범용적인 상황을 고려해서 secret으로 관리하고 배포 서버로 전송해 주었습니다.
  • deploy.sh : 배포를 실행하는 스크립트이기 때문에 배포 서버에 있어야 하는 건 당연합니다.

    그런데 name: Check if deploy.sh changed라는 단계가 추가적으로 있는 것을 확인하실 수 있습니다. deploy.sh는 secret으로 관리하지 않기 때문에 코드에 변화가 있는지 확인할 수 있고... 배포 서버로 파일을 보내는 것 자체도 10초 정도 걸리는 작업이기 때문에 deploy.sh에 변화가 있는 경우에만 전송하도록 설정을 해주었습니다.

최종적으로 마지막 단계  Deploy Blue-Green에서는 docker repository에 올려둔 이미지를 다시 다운로드하고 deploy.sh 스크립트를 실행하면서 끝이 납니다. 

 

5️⃣ Deploy.sh 쉘 스크립트

#!/bin/bash

cd /home/ubuntu/cicd

APP_NAME="practice"

# NGINX 설정 관련
NGINX_CONF_PATH="/etc/nginx"
BLUE_CONF="blue.conf"
GREEN_CONF="green.conf"
DEFAULT_CONF="nginx.conf"
MAX_RETRIES=3

# 활성화된 서비스 확인 및 스위칭 대상 결정
determine_target() {
  if docker-compose -f docker-compose.yml ps | grep -q "app_blue.*Up"; then
    TARGET="green"
    OLD="blue"
  elif docker-compose -f docker-compose.yml ps | grep -q "app_green.*Up"; then
    TARGET="blue"
    OLD="green"
  else
    TARGET="blue"  # 첫 실행 시 기본값
    OLD="none"
  fi

  echo "TARGET: $TARGET"
  echo "OLD: $OLD"
}

# 헬스체크 실패 시 롤백 처리
health_check() {
  local URL=$1
  local RETRIES=0
  local ORIGINAL_TARGET=$TARGET

  while [ $RETRIES -lt $MAX_RETRIES ]; do
    echo "Checking service at $URL... (attempt: $((RETRIES + 1)))"
    sleep 3

    CONTAINER_RUNNING=$(docker ps --filter "name=app_$TARGET" --format '{{.Names}}')

    if [ "$CONTAINER_RUNNING" = "app_$TARGET" ]; then
      echo "$TARGET container is running."
      return 0
    else
      echo "$TARGET container is not running."
    fi

    RETRIES=$((RETRIES + 1))
  done

  echo "Health check failed after $MAX_RETRIES attempts."
  echo "Rolling back to the original target: $ORIGINAL_TARGET"
  TARGET=$ORIGINAL_TARGET
  echo "Rolled back TARGET: $TARGET"
  echo "Failed health check for $TARGET container" > /home/ubuntu/cicd/health_check_failure.log
  exit 1
}

# NGINX 설정 스위칭 함수
switch_nginx_conf() {
  if [ "$TARGET" = "blue" ]; then
    sudo cp "${NGINX_CONF_PATH}/${BLUE_CONF}" "${NGINX_CONF_PATH}/${DEFAULT_CONF}"
  else
    sudo cp "${NGINX_CONF_PATH}/${GREEN_CONF}" "${NGINX_CONF_PATH}/${DEFAULT_CONF}"
  fi

  echo "Reloading NGINX configuration..."
  nginx -s reload
}

# 이전 컨테이너 종료 함수
down_old_container() {
  if [ "$OLD" != "none" ]; then
    echo "Stopping old container: $OLD"
    sudo docker stop "app_$OLD"
  fi
}

# 메인 실행 로직
main() {
  determine_target

  echo "Starting $TARGET container..."
  docker-compose -p $APP_NAME -f docker-compose.yml up -d "app_$TARGET"

  if [ "$TARGET" = "blue" ]; then
    health_check "http://127.0.0.1:8080/actuator/health"
  else
    health_check "http://127.0.0.1:8081/actuator/health"
  fi

  switch_nginx_conf
  down_old_container

  echo "Deployment to $TARGET completed successfully!"
  echo "Cleaning up dangling Docker images..."
  docker image prune -f
}

main

이 쉘 스크립트는 배포 환경으로 복사가 되어있죠?

맨 아래 main 로직에서부터 설명을 드리겠습니다.

 

가장 먼저 determine_target이 실행됩니다 -> "다음으로 어떤 컨테이너는 띄워주어야 하는가?" 판단하는 단계입니다.

# 활성화된 서비스 확인 및 스위칭 대상 결정
determine_target() {
  if docker-compose -f docker-compose.yml ps | grep -q "app_blue.*Up"; then
    TARGET="green"
    OLD="blue"
  elif docker-compose -f docker-compose.yml ps | grep -q "app_green.*Up"; then
    TARGET="blue"
    OLD="green"
  else
    TARGET="blue"  # 첫 실행 시 기본값
    OLD="none"
  fi

  echo "TARGET: $TARGET"
  echo "OLD: $OLD"
}

 

app_blue(블루 컨테이너)가 실행되고 있다면, 다음 목표(Target)는 green이고 내려주어야 할 컨테이너(Old)는 blue가 됩니다.

app_green(그린 컨테이너)가 실행되고 있다면 다음 목표(Target)는 blue고 내려주어야 할 컨테이너(Old)는 green이 됩니다.

 

둘 다 아니라면, 가장 최초 실행 상황으로 blue를 띄우고 내릴 컨테이는 없다는 설정입니다.

 

다음으로 main함수에서는 목표에 맞는 컨테이너를 띄우고 헬스체크(잘 실행이 되는지)를 검사합니다.

# 헬스체크 실패 시 롤백 처리
health_check() {
  local URL=$1
  local RETRIES=0
  local ORIGINAL_TARGET=$TARGET

  while [ $RETRIES -lt $MAX_RETRIES ]; do
    echo "Checking service at $URL... (attempt: $((RETRIES + 1)))"
    sleep 3

    CONTAINER_RUNNING=$(docker ps --filter "name=app_$TARGET" --format '{{.Names}}')

    if [ "$CONTAINER_RUNNING" = "app_$TARGET" ]; then
      echo "$TARGET container is running."
      return 0
    else
      echo "$TARGET container is not running."
    fi

    RETRIES=$((RETRIES + 1))
  done

  echo "Health check failed after $MAX_RETRIES attempts."
  echo "Rolling back to the original target: $ORIGINAL_TARGET"
  TARGET=$ORIGINAL_TARGET
  echo "Rolled back TARGET: $TARGET"
  echo "Failed health check for $TARGET container" > /home/ubuntu/cicd/health_check_failure.log
  exit 1
}

 

컨테이너가 실행이 잘 되는지 3번까지 확인을 하며, 잘 실행되지 않을 경우 exit을 실행합니다.

 

다음 단계는 switch_nginx_conf입니다.

# NGINX 설정 스위칭 함수
switch_nginx_conf() {
  if [ "$TARGET" = "blue" ]; then
    sudo cp "${NGINX_CONF_PATH}/${BLUE_CONF}" "${NGINX_CONF_PATH}/${DEFAULT_CONF}"
  else
    sudo cp "${NGINX_CONF_PATH}/${GREEN_CONF}" "${NGINX_CONF_PATH}/${DEFAULT_CONF}"
  fi

  echo "Reloading NGINX configuration..."
  nginx -s reload
}

이전에 blue.conf와 green.conf를 미리 만들어 두었죠? 
이는 blue는 8080 포트에서 실행이 되기 때문에 80포트로 들어온 요청을 8080포트로 넘겨주는 blue.conf를 사용해야 하고,
green은 8081포트에서 실행이 되기 때문에 80포트로 들어온 요청을 8081포트로 넘겨주는 green.conf를 사용해야 하기 때문입니다.

이에 따라서, Target에 맞는 컨테이너를 위한 conf파일을 nginx.conf로 복사하고 nginx를 reload하게 되면 nginx가 트래픽을 처리해 주는 설정이 변경되어 반영됩니다.

지금 단계에서는 구 컨테이너는 아직 실행되는 상태이지만, 모든 요청이 새로운 올라온 컨테이너로 스위칭된 상태입니다!

# 이전 컨테이너 종료 함수
down_old_container() {
  if [ "$OLD" != "none" ]; then
    echo "Stopping old container: $OLD"
    sudo docker stop "app_$OLD"
  fi
}

 

따라서, 다음으로는 down_old_conatiner를 통해서 이전에 실행되던 예전 컨테이너를 내려줍니다.

echo "Deployment to $TARGET completed successfully!"
echo "Cleaning up dangling Docker images..."
docker image prune -f

 

 

마지막으로!!
사용되지 않는 이미지를 정리해 주는데요, 저 같은 경우는 1.0.0으로 tag를 고정했기 때문에..?
같은 태그를 사용하는 이미지가 다운로드되면 구버전의 이미지는 tag가 없어진 채로 사용되지 않는 이미지가 계속 배포 서버에 쌓이는 문제가 발생했었습니다.

따라서, 그런 이미지들을 청소해 주는 코드를 추가했습니다.

 

이상으로 배포와 관련된 게시물을 마무리하겠습니다..!! ㅠㅠ

'프로젝트' 카테고리의 다른 글

테스트 코드는 어떻게 작성해야 할까? - 좋은 테스트 코드 작성 TMI  (1) 2025.07.01
JPA 최적화는 어떻게 해야할까?  (0) 2025.05.28
Clokey 프로젝트 리펙토링 - Github Actions & Docker 최적화  (0) 2025.04.29
Docker에 대해서 알아보자 - 3. docker build & docker compose  (0) 2025.04.26
Docker에 대해서 알아보자 - 2.Dockerfile  (1) 2025.04.26
'프로젝트' 카테고리의 다른 글
  • 테스트 코드는 어떻게 작성해야 할까? - 좋은 테스트 코드 작성 TMI
  • JPA 최적화는 어떻게 해야할까?
  • Clokey 프로젝트 리펙토링 - Github Actions & Docker 최적화
  • Docker에 대해서 알아보자 - 3. docker build & docker compose
potato-farm
potato-farm
개발 혼자 공부하기
  • potato-farm
    감자밭
    potato-farm
  • 전체
    오늘
    어제
    • 분류 전체보기 (27)
      • ETC (2)
      • 알고리즘 (0)
      • Java (0)
      • DB (2)
      • Spring (0)
      • 프로젝트 (15)
      • Server (3)
      • CS (0)
        • 운영체제 (0)
      • Infra (4)
        • IAC (1)
        • AWS (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • hELLO· Designed By정상우.v4.10.3
potato-farm
배포의 모든 것 - 5. Blue-Green 무중단 배포 적용
상단으로

티스토리툴바