Docker+Nginx+Github Actions를 활용한 Spring 서버 무중단 배포 작업

서론

아직 서비스에 무중단 배포가 적용되지 않아서, 상용 서버에서 배포할 시에 30초~1분 정도의 다운타임이 존재했다. 이전에는 사람이 없는 시간대에 맞춰서 배포를 진행했으나 급한 이슈나 기능 추가 요청이 없어서 무중단 배포를 구현하기로 했다.

현재 프로젝트에서 CI/CD툴로 Github Actions만 사용하고 있어, Github Actions와 Nginx 설정을 활용해서 무중단 배포를 진행했다.

무중단 배포의 3가지 종류

  1. 블루그린 배포(Blue/Green Deployment)

    블루그린 배포는 무중단 배포의 가장 대표적인 방식 중 하나이다.

    블루그린 배포를 위해서는 두 개의 환경이 필요한데, 기존 서비스가 동작하는 ‘블루’환경과 새로운 업데이트 버전이 동작하는 ‘그린’환경이다.

    블루그린 배포를 적용하면, 새로운 업데이트 버전이 그린 환경에서 먼저 동작하게 되며, 이후에 블루 환경에서 동작하던 기존 버전이 순차적으로 그린 환경으로 이전된다. 이렇게 하면, 사용자는 언제나 정상적인 서비스를 이용할 수 있게 된다.

  2. 카나리아 배포(Canary Deployment)

    카나리아 배포는 블루그린 배포와 비슷한 방식으로, 새로운 업데이트 버전을 먼저 일부 사용자에게 노출시키는 방식이다. 이를 통해, 새로운 업데이트 버전에 대한 사용자들의 반응을 먼저 확인하고, 문제가 발생할 경우 빠르게 대처할 수 있다.

  3. 폴링 배포(Polling Deployment)

    폴링 배포는 기존 서비스와 새로운 업데이트 버전을 번갈아가며 동작시키는 방식이며, 이를 위해서는, 두 개의 버전이 동시에 운영되어야 하며, 사용자의 요청이 들어오면 이를 번갈아가며 처리하게 된다.

블루그린 배포 선택

https://image.samsungsds.com/kr/insights/zero_downtime_img03.jpg?queryString=20230718021607

우리는 소규모 서비스이기 때문에, 블루그린 배포 방식을 채택했다. 블루그린 배포는 구현이 비교적 간단하고, 무중단 배포를 구현하는 데 필요한 환경 또한 두 개만 유지하면 되기 때문에 유지보수가 용이하다는 장점이 있었고, 참고할 수 있는 레퍼런스가 많았다. 또한, 새로운 업데이트 버전을 미리 배포할 필요가 없기 때문에, 배포 시간을 유연하게 조절할 수 있기에 블루그린 방식의 배포를 채택했다.

1. application.yaml 수정

---
server:
  port: 8882

spring:
  config:
    activate:
      on-profile: dev-green # 기본 프로파일 설정
---
server:
  port: 8881

spring:
  config:
    activate:
      on-profile: dev-blue # 기본 프로파일 설정

블루/그린 배포에서 한 서버 인스턴스로 구현하기 위해서는, 구버전과 신버전이 존재하므로 적어도 2개의 포트가 존재해야한다. 원래 하나만 존재하던 포트를 2개로 늘렸고, 어떤 포트로 실행할지 구분해주기 위해서 dev-green dev-blue 로 프로파일 이름을 구분해주었다.

2. docker-compose.yml 작성

version: "3"
services:
  green:
    container_name: green
    image: *****/*****:${IMAGE_TAG}
    environment:
      - SPRING_PROFILES_ACTIVE=dev-green,mail,sms,redis,jwt,s3,payment
    ports:
      - "8882:8882"
    logging:
      driver: "awslogs"
      options:
        awslogs-region: "ap-northeast-2"
        awslogs-group: "******"
        awslogs-stream: "******"

    networks:
      - redis_redis_cluster

  blue:
    container_name: blue
    image: *****/*****:${IMAGE_TAG}
    environment:
      - SPRING_PROFILES_ACTIVE=dev-blue,mail,sms,redis,jwt,s3,payment
    ports:
      - "8881:8881"
    logging:
      driver: "awslogs"
      options:
        awslogs-region: "ap-northeast-2"
        awslogs-group: "******"
        awslogs-stream: "******"

networks:
  redis_redis_cluster:
    external: true

docker-compose.yml 빌드에는 사용하지 않고 배포용으로만 사용하므로, 빌드에 대한 부분은 작성하지 않았다.

green과 blue 컨테이너를 지정하고, 이에 따른 포트도 연결해주었다. 프로젝트에서는 Redis cluster를 사용하고 있어서 이를 연결해주는 부분도 추가하였다.

3.EC2 > 보안그룹 > 인바운드 규칙에서 PORT 추가

  • 8882 포트는 이미 열어두던 포트였으므로 8881 포트만 추가로 열어주기

4. nginx 설정

  • /etc/nginx/conf.d 에서 변수 설정
/etc/nginx/conf.d/app$ ls
service-url-blue.inc  service-url-green.inc  service-url.inc
/etc/nginx/conf.d/app$ cat service-url-blue.inc
set $service_url http://localhost:8881
/etc/nginx/conf.d/app$ cat service-url-green.inc
set $service_url http://localhost:8882
  • /etc/nginx.conf
server {
    include /etc/nginx/conf.d/app/service-url.inc;
    listen 80;
    listen [::]:80;

    server_name localhost;

    location / {
         proxy_pass $service_url;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
         proxy_set_header Host $http_host;
    }
}

한 컨테이너가 올라가면 다른 컨테이너를 종료하는 방식을 취하면서 설정을 위와 같이 정해주었다. green/blue에 따라 $service_url이 달라져야 하므로, green이 올라가면 service-url.inc 파일에 service-url-green.inc 파일을 덮어쓰고, blue가 올라가면 마찬가지 방식으로 덮어씌우는 방식을 택했다.

5. deploy.sh 작성

#!/bin/bash

IS_GREEN=$(docker ps | grep green)
IMAGE_TAG=$1
DOCKER_USERNAME=$2

if [ -z "$IMAGE_TAG" ]; then
  echo "ERROR: Image tag argument is missing."
  exit 1
fi

if [ -z "$IS_GREEN"  ];then # blue라면

  echo "### BLUE => GREEN ###"

  echo "1. get green image"
  docker-compose -f /home/ubuntu/docker/docker-compose.yml pull green # green으로 이미지를 내려받습니다.

  echo "2. green container up"
  docker-compose -f /home/ubuntu/docker/docker-compose.yml up -d green # green 컨테이너 실행
  while [ 1 = 1 ]; do
  echo "3. green health check..."
  sleep 3

  REQUEST=$(curl http://127.0.0.1:8882) # green으로 request
    if [ -n "$REQUEST" ]; then # 서비스 가능하면 health check 중지
            echo "health check success"
            break ;
            fi
  done;

  echo "4. reload nginx"
  sudo cp /etc/nginx/conf.d/app/service-url-green.inc /etc/nginx/conf.d/app/service-url.inc
  sudo nginx -s reload

  echo "5. blue container down"
  docker-compose -f /home/ubuntu/docker/docker-compose.yml stop blue
else
  echo "### GREEN => BLUE ###"

  echo "1. get blue image"
  docker-compose -f /home/ubuntu/docker/docker-compose.yml pull blue

  echo "2. blue container up"
  docker-compose -f /home/ubuntu/docker/docker-compose.yml up -d blue

  while [ 1 = 1 ]; do
    echo "3. blue health check..."
    sleep 3
    REQUEST=$(curl http://127.0.0.1:8881) # blue로 request

    if [ -n "$REQUEST" ]; then # 서비스 가능하면 health check 중지
      echo "health check success"
      break ;
    fi
  done;

  echo "4. reload nginx"
  sudo cp /etc/nginx/conf.d/app/service-url-blue.inc /etc/nginx/conf.d/app/service-url.inc
  sudo nginx -s reload

  echo "5. green container down"
  docker-compose -f /home/ubuntu/docker/docker-compose.yml stop green
fi

컨테이너 종류에 따라 변수를 설정하는 방식은 위 deploy.sh 파일에서 설정해주었다. 핵심은

  1. 현재 가동중인 컨테이너가 green인지 blue인지 확인
  2. PUSH된 이미지를 가져와서 실행시키기
  3. 헬스체크를 하면서 새 컨테이너가 동작하는 지 확인
  4. green/blue인지에 따라 nginx.conf의 변수를 설정하고 reload
  5. 구 컨테이너 정지

IMAGE_TAG DOCKER_USERNAME와 같은 변수는 이미지 태그와 관련되어 필요했던 값이기에 따로 변수 설정을 해서 추가해주었다.

Github Actions workflow 수정

deploy:
    needs: build-image
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: EC2로 파일 보내기
        uses: appleboy/scp-action@master
        with:
          username: ubuntu
          host: ${{ secrets.DEV_HOST }}
          key: ${{ secrets.DEV_PRIVATE_KEY }}
          port: 22
          source: "deploy.sh,docker-compose.yml"
          target: "/home/ubuntu/docker"

      - name: 배포
        uses: appleboy/ssh-action@master
        with:
          username: ubuntu
          host: ${{ secrets.DEV_HOST }}
          key: ${{ secrets.DEV_PRIVATE_KEY }}
          envs: GITHUB_SHA
          script: |
            export IMAGE_TAG=${GITHUB_SHA::7}
            export DOCKER_USERNAME=${{ secrets.DOCKER_USERNAME }}
            chmod 777 /home/ubuntu/docker/deploy.sh
            /home/ubuntu/docker/deploy.sh $IMAGE_TAG $DOCKER_USERNAME

docker 이미지 빌드 부분은 수정하지 않고, 배포 부분만 수정했다. appleboy의 scp-action을 사용하여 Github Repository에 존재하는 deploy.sh와 docker-compose.yml 파일을 EC2 서버에 전송하고, deploy.sh를 실행시키는 코드이다. 이전 코드에서는 Docker 이미지를 빌드하고 푸시한 후 바로 실행시키는 과정이 존재했다면, 무중단 배포를 위해서 작성해둔 deploy.sh를 실행한다.

정리

Docker, Nginx, Github Actions를 이용한 Spring 서버 무중단 배포 작업과 블루/그린 배포 방식을 채택하게 된 과정까지 설명했다. 이 방식의 장점으로는 배포 시간을 유연하게 조절할 수 있다는 것이 있으며, 블루/그린 배포를 구현하는 데 필요한 환경이 두 개만 유지되면 되기 때문에 유지보수가 용이하다는 이점이 있었다. 구현 방법으로는 application.yaml 수정, docker-compose.yml 작성, nginx 설정, deploy.sh 작성 등의 작업을 수행하였으며, 마지막으로 Github Actions workflow를 수정하여 배포 부분을 업데이트하였다. 위 과정을 통해 무중단 배포를 성공적으로 구현 할 수 있었다.

Docker+Nginx+Github Actions를 활용한 Spring 서버 무중단 배포 작업

2 thoughts on “Docker+Nginx+Github Actions를 활용한 Spring 서버 무중단 배포 작업

  1. 안녕하세요! 해당 게시글 보면서 무중단 배포 연습하고 있습니다!
    다름이 아니고 공부하면서 따라하고 있는데 지금 ec2 인스턴스 내에서 헬스체크가 안되고 있는 상황입니다… ㅠㅠ curl: (52) Empty reply from server 오류가 뜨고 있는데 이에 대한 해결방법이 있을까요?

    1. 답변이 늦어져서 죄송합니다! curl: (52) Empty reply from server 오류는 통신하는 포트의 서버가 아직 연결되지 않아서 발생하는 오류입니다. 성공하게 되면 ✅ Successfully executed commands to all host. 와 같이 Github Actions 상에 나오는데, 시간이 지나고 계속 연결이 안된다면
      1. EC2 인스턴스에서 인바운드 규칙에서 포트를 열여주었는지
      2. /etx/nginx conf.d 에서 service-url.inc 파일에 현재 업로드하려는 포트(blue/green)과 알맞게 열어져 있는지
      3. appplication.yaml 또는 appplication.properties 파일이 제대로 설정되어 있는지
      4. docker-compose.yaml 파일에 문제가 없는지
      체크해주시면 될 것 같습니다! 그래도 안됐다면 댓글 남겨주세요 ㅎㅎ..

댓글 남기기

Scroll to top