Nginx

Docker와 nginx를 이용하여 HTTPS 적용, 무중단 배포까지[2] - Nginx와 Docker, Github Action으로 무중단 배포하기

PlatinumeOlive 2024. 7. 2. 14:05

2024.06.28 - [Nginx] - Docker와 nginx를 이용하여 HTTPS 적용, 무중단 배포까지[1] - HTTPS 적용

 

Docker와 nginx를 이용하여 HTTPS 적용, 무중단 배포까지[1] - HTTPS 적용

저는 gcp에서 도커로 Spring, Mysql, Redis를 돌려서 사용중이고, HTTPS 연결을 위해 도메인을 구매하여 gcp자체에 nginx와 certbot을 설치하여 사용중이였습니다. 프로젝트에서 날씨 데이터(과거, 현재)를

100cblog.tistory.com

 

오늘은 이전 포스팅에 이어서, 무중단 배포를 구현해보도록 하겠습니다.

 

지금 하는 프로젝트가 아직은 실제 서비스 중인 단계는 아니지만, 새로 merge할때마다 배포한 서버가 멈추는게 아쉬워, 말로만 듣던 무중단 배포를 한번 경험해보고 싶어서 HTTPS을 적용하며 ngnix를 만진 김에, 같이 한번  해보기로 마음먹었습니다. 

무중단 배포란?

무중단 배포란, 기존에 운영중인 서비스를 중단하지 않고 새로운 버전의 서비스를 배포하는 것을 말합니다. 로드 밸런서를 통해 두개 이상의 인스턴스를 제어하며 배포하는 것입니다.

 

무중단 배포에는 인스턴스를 여러 개 만들어 현재 위치 배포 방식 으로 할 수도 있고, 블루 그린 배포 방식, 롤링, 카나리 등으로 진행할 수도 있습니다.

무중단 배포의 종류

  • 롤링 : 롤링 배포는 서비스 중인 인스턴스 중 하나를 로드밸런서에서 라우팅 하지 않도록 한 후 새 버전을 적용하여 라우팅 합니다. 이를 반복하여 모든 인스턴스에 새 버전을 배포하게 됩니다. 따라서 이 방식을 위해서는 서버가 2개 이상이여야 하며, 구버전과 신버전이 공존하기 때문에 호환성 문제가 발생할 수 있습니다.
  • 블루/그린 : 블루/그린 배포에서는, 운영중인 구버전과 동일하게 신 버전의 인스턴스를 만들고 완성되면 로드밸런서를 통해 모든 트래픽을 한번에 신 버전으로 전환합니다. 이 버전은 구버전이 그대로 있어 롤백이 쉽다는 장점이 있지만, 그만큼 시스템 자원은 두배로 필요합니다.
  • 카나비 : 소수의 유저들에게만 신 버전을 배포하여 테스트한 후 전체 배포합니다. 인스타그램이 이런 방식이 아닐까? 라는 생각이 듭니다. (친구와 내 인스타의 UI가 다른경우가 몇번 있었음) 또한 A/B테스트가 가능합니다.

앞서 설명에서는 두개 이상의 인스턴스가 있어야 한다고 되어있는데, 두개의 서버 인스턴스를 만들어 유지하는 것이 금액적으로도 부담이 되는 저같은 경우에는 서버 하나로 어떻게 무중단 배포를 구현할 수 있을까요?

 

방법은 도커를 이용하여 한 서버에 여러개의 인스턴스를 구현하는 것 입니다.

 

수정사항: 8080과 8081 두개의 포트에 진행할 예정입니다.

 

롤링 전략을 사용하기엔 WAS 서버가 두대 이상 필요한데, 저는 이 이상 서버 증설은 어려운 상황입니다. 카나비 테스트의 강점인 A/B는 현재 저에겐 불필요합니다. 따라서 저는 신속하고 빠른 롤백이 가능한 블루/그린 전략을 사용하여 도커에 서로 다른 인스턴스 두개를 구성하여 진행하겠습니다.

 

먼저 8080포트에 연결해뒀던 있던 기존 스프링 컨테이너가 동작중입니다. 이 버전을 그린이라고 가정합니다. 깃허브에서 merge를 통해 이벤트가 감지되면 github action이 작동하는데, 이때 새로운 버전의 스프링 앱은 새로운 컨테이너를 생성하여 구동하며 이것을 블루라고 가정합니다. 성공적으로 구동이 되었다고 판단하였을 때 트래픽을 8081포트에 연결한 새로운 서비스(블루)로 모두 보내도록 합니다.

 

정리하자면 아래와 같습니다.

  • 도커를 통해 새로운 버전의 서비스 실행
  • 실행이 확인되면, nginx의 설정파일을 신규 서비스를 바라보도록 수정
  • nginx를 재실행

이제 직접 구현해보겠습니다.

 

Dockerfile, docker-compose.yml

저는 도커 파일을 아래와 같이 준비하였습니다.

FROM openjdk:17
WORKDIR /app
# 빌드된 Spring Boot JAR 파일을 복사
COPY build/libs/server-0.0.1-SNAPSHOT.jar trippyj.jar

# JAR 파일 실행
CMD ["java", "-jar", "trippyj.jar", "--spring.profiles.active=prod-profile"]

 

이어서 도커 컴포즈 파일에 블루/그린 두가지의 컨테이너를 추가하였습니다. docker-compose.yml 파일은 서버에 docker-compose up -d 명령어를 실행할 디렉토리에 위치시킵니다. 저의 경우에는 gcp를 사용하고 있으며, /home/dh1010a 경로에 놓았습니다.

version: "3"
services:
  green:
    image: devyoung00/trippy-docker-repo
    container_name: trippy-green
    ports:
      - "8080:8080"
    networks:
      - trippy-network
    volumes:
      - trippy-app-vol:/root

  blue:
    image: devyoung00/trippy-docker-repo
    container_name: trippy-blue
    ports:
      - "8081:8080"
    networks:
      - trippy-network
    volumes:
      - trippy-app-vol:/root
    nginx:
    image: nginx:latest
    container_name: trippy-nginx
    networks:
      - trippy-network
    restart: unless-stopped
    volumes:
      - ./conf/nginx.conf:/etc/nginx/nginx.conf
      - ./conf/nginx1.conf:/etc/nginx/nginx1.conf
      - ./conf/nginx2.conf:/etc/nginx/nginx2.conf
      - /etc/letsencrypt:/etc/letsencrypt
      - /var/www/certbot:/var/www/certbot
    ports:
      - 80:80
      - 443:443
    command: '/bin/sh -c ''while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"'''

  certbot:
    image: certbot/certbot
    container_name: trippy-certbot
    networks:
      - trippy-network
    restart: unless-stopped
    volumes:
      - /etc/letsencrypt:/etc/letsencrypt
      - /var/www/certbot:/var/www/certbot
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"


networks:
  trippy-network:
    driver: bridge
volumes:
  trippy-app-vol:
  trippy-nginx-vol:
  trippy-certbot-vol:

 

이제 블루와 그린 두가지 컨테이너에 nginx 컨테이너까지 모두 구성할 준비를 마쳤습니다. 이제 무중단 배포를 가능하게하는 쉘 스크립트를 작성해보겠습니다.

 

nginx.conf 설정파일과 deploy.sh 스크립트

 

nginx가 새로운 스프링 컨테이너를 바라보게 하려면, nginx.conf 설정파일에서 설정을 바꾸어 주어야 합니다. nginx1.conf - 그린용 , nginx2.conf - 블루용 두가지 설정파일을 nginx에 덮어쓰기 하는 방식으로 구현하겠습니다.

 

nginx1.conf

user  nginx;
worker_processes  auto;

events {
    worker_connections  1024;
}

http {
    upstream trippy-spring {
        server trippy-blue:8080;
    }
    server {
        listen 80;
        server_name trippy-api.store;
        server_tokens off;

        location /.well-known/acme-challenge/ {
            root /var/www/certbot;
        }

        location / {
            return 301 https://$host$request_uri;
        }
    }

    server {
        listen 443 ssl;
        server_name trippy-api.store;

        ssl_certificate /etc/letsencrypt/live/trippy-api.store/fullchain.pem; # managed by Certbot
        ssl_certificate_key /etc/letsencrypt/live/trippy-api.store/privkey.pem; # managed by Certbot
        include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

        location / {
            proxy_pass http://trippy-spring/;
            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;
            
            # SSE settings
            proxy_buffering off;
            proxy_read_timeout 3600s;  # 1시간으로 설정
            proxy_send_timeout 3600s;  # 1시간으로 설정
            keepalive_timeout 3600s;   # 1시간으로 설정
            add_header Cache-Control no-cache;  # Disable caching
            add_header Connection keep-alive;   # Keep connection alive
        }
    }
}

 

nginx.conf는 위 파일과 내용이 동일하게 구성해도 됩니다.(어차피 덮어쓰기 할 예정). nginx2.conf 파일은 위 파일에서 아래 부분만 바꾸면 됩니다.

upstream trippy-spring {
    server trippy-green:8080;
}

 

green 컨테이너의 내부 8080 포트로 진입하도록 합니다.

 

이제 세가지 파일을 모두 이전 포스팅에서 위치해놨던 /home/dh1010a/conf/ 디렉토리에 위치시키면 됩니다.

 

추가한 내용

배포 환경에서 SSE 연결을 길게 유지하기 위해서는, nginx 설정을 변경해야 합니다. SSE 스트림이 장시간 열려 있어야 하므로, proxy_read_timeout을 충분히 길게 설정해야 합니다. 따라서 아래와 같이 코드를 추가해주었습니다.

 

location / {
    proxy_pass http://trippy-spring/;
    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;

    # 추가한 SSE settings
    proxy_buffering off;
    proxy_read_timeout 3600s;  # 1시간으로 설정
    proxy_send_timeout 3600s;  # 1시간으로 설정
    keepalive_timeout 3600s;   # 1시간으로 설정
    add_header Cache-Control no-cache;  # Disable caching
    add_header Connection keep-alive;   # Keep connection alive
}

deploy.sh

이 스크립트는 docker-compose.yml 파일이 있는 경로와 동일한 위치에 놓으면 됩니다. 

#!/bin/bash

IS_ORIGIN=$(sudo docker ps | grep green) # 현재 실행 중인 App이 Green인지 확인합니다.
NGINX_ID=$(sudo docker ps --filter "name=nginx" --quiet)
NGINX_CONFIG="/etc/nginx/nginx.conf"

if [ -z "$IS_ORIGIN" ]; then # green이 실행중이 아니라면
  echo "### BLUE => GREEN ###"

  echo "1. green container up"
  sudo docker-compose up -d green # green 컨테이너 실행

  while true; do
    echo "2. green health check..."
    sleep 3

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

  echo "3. reload nginx"
  sudo docker exec $NGINX_ID /bin/bash -c "cp /etc/nginx/nginx1.conf $NGINX_CONFIG"
  sudo docker exec $NGINX_ID /bin/bash -c "nginx -s reload" || { ERR_MSG='Failed to reload nginx'; exit 1; }

  echo "4. blue container down"
  sudo docker-compose stop blue

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

  echo "1. blue container up"
  sudo docker-compose up -d blue

  while true; do
    echo "2. blue health check..."
    sleep 3

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

  echo "3. reload nginx"
  sudo docker exec $NGINX_ID /bin/bash -c "cp /etc/nginx/nginx2.conf $NGINX_CONFIG"
  sudo docker exec $NGINX_ID /bin/bash -c "nginx -s reload" || { ERR_MSG='Failed to reload nginx'; exit 1; }

  echo "4. green container down"
  sudo docker-compose stop green
fi

 

자세한 설명은 생략하도록 하겠습니다. 대략 새로운 컨테이너 생성 -> 새로운 컨테이너가 정상 구동될때까지 대기 -> nginx 컨테이너에 접속하여 설정 변경 및 재시작 -> 기존 컨테이너 작동 정지 의 과정을 거칩니다.

 

기존 컨테이너는 롤백을 위해 그냥 실행시켜두는 경우도 많습니다만, 저는 아직 개발 단계이고 리소스를 아끼기 위해 기존 컨테이너를 정지시켰습니다.

 

Github action

gradle.yml

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle

name: CI/CD

on:
  push:
    branches: [ "main" ]
#  pull_request:
#    branches: [ "main" ]

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: Build with Gradle Wrapper
      run: |
          chmod +x gradlew 
          ./gradlew build

    ## 도커허브 로그인
    - name: Login to DockerHub
      uses: docker/login-action@v3
      with:
            username: ${{ secrets.DOCKER_USERNAME }}
            password: ${{ secrets.DOCKER_PASSWORD }}

    ## 웹 이미지 빌드 및 도커허브에 push
    - name: web docker build and push
      run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -t ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }}:latest .
          docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }}:latest
     ## docker compose up
    - name: executing remote ssh commands using password
      uses: appleboy/ssh-action@master
      with:
          host: ${{ secrets.HOST }}
          username: ${{secrets.USERNAME}}
          key: ${{ secrets.KEY }}
          script: |
            docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }}
            cd ~
            sudo bash deploy.sh
            sudo docker image prune -f

 

자세한 사용법은 다루지 않고 넘어가겠습니다. 여기서 중점적으로 봐야할 부분은 마지막입니다.

 

Mysql, Redis 등 필요한 컨테이너들은 새로운 버전 배포때마다 내렸다가 올릴 필요가 없기때문에, 서버에서 초기에 직접 실행시켜 주었습니다. 이후 sudo bash deploy.sh 명령어를 통해 무중단 배포를 실행하도록 하였습니다.

 

출처 및 참고

https://medium.com/@hello-every-one/nginx%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-feat-docker-ec85d93623d5

 

Nginx를 이용한 무중단 배포(feat. Docker)

github action을 통해 자동배포를 구현하였으나 배포중에 서비스가 멈추는게 아쉬워 무중단 배포를 해보고 싶었다. 다행히 nginx를 통해 서버 1대에서 구현할 수 있는 방법을 찾았고 내 환경에 맞추

medium.com

 

https://diary-blockchain.tistory.com/313

 

[Devops] spring boot 블루/그린 무중단 배포 (gitlab ci, docker, nginx)

spring boot로 무중단 배포를 이용하려고 한다. spring boot도 code deploy, elastic beanstalk 등등 무중단 배포 방법은 많다. 그중에서도 블루/그린 방법으로 docker를 이용하고 nginx도 공부할겸 nginx로 배포하려

blog.tetedo.com