2024년 8월 6일 작성한 글입니다.

 

서비스를 개발하며 벼르고 있던 작업 중 하나였습니다. 구현 작업이 느슨해질 때 쯤 작업했었네요. 이 작업은 개발/배포 환경에 영향을 많이 받다보니 외부 코드를 차용하지 못하고 직접 코드를 많이 타이핑했었던 기억이 남아있습니다..

1. 문제 인식 | “어? 서버 오류났어요”

사실 ‘무중단 배포’ 구축 작업 자체는 워낙 유명한 CD 작업이기 때문에, 문제가 발생하기 전에도 ‘이건 나중에 꼭 작업해야지’ 라고 인식하고 있던 작업이였습니다.

직접적으로 해당 작업 필요성에 대해 필요성을 느끼게 된 건 배포 작업이 잦아지면서부터였습니다. 기능 개발과 버그 픽스가 빠르게 이루어지며 배포 주기가 짧아지고, 특히 테스트를 위해 개발 서버에 변경 사항을 반영할 때 문제가 두드러졌습니다.

2. 문제 상황 | “아 배포중입니다”

실 예시를 들자면.. 테스트를 위해 개발 서버에 자동 배포 중 다운타임이 약 1분 간 발생하게 되는데 이때 앱 개발자분께서 “어? 서버 오류났어요” 라고 말씀하시는 상황입니다.

그럴 때마다 항상 “아 죄송해요 지금 배포중입니다” 라고 말씀드리고 넘겼지만 가끔 잡담하느라 배포중인 걸 나도 깜빡했을 때는 진짜 가슴이 철렁하기도 하고, 앱 개발자분이 자꾸 서버 에러를 경험하는 일이 발생하니 죄송스럽기도 하더라구요.(서버 배포때문에 작업이 딜레이되는 경우도 있으니) 이런 불필요한 커뮤니케이션 증가 + 개발 작업 딜레이 + 클라이언트 단에서의 다운타임과 같은 문제를 해결하기 위해 3월 초 ‘무중단 배포’ 작업을 진행하기로 했습니다.

사실 ‘무중단 배포’는 클라이언트가 서버 점검 상황 시 체감하는 다운타임 문제를 해결하기 위해 사용하는 방법인데 저희 팀의 경우엔 서비스에 실 이용자가 많지 않다보니 개발 환경에서 문제가 더 체감이 되었네요

3. 해결 방법 탐색 과정

무중단 배포를 구축하는 개념은 크게 ‘Rolling’, ‘Blue/Green’, ‘Canary’ 이렇게 크게 세가지로 분류됩니다. 이 중 서비스 API 안정성 확보를 위해 Blue/Green 방식을 채택했습니다. 서버 API가 추가되고 변경되는 일이 적지않다보니 Blue/Green 환경이 적합하다고 판단했습니다.

배포 방법은 크게 세 가지로 나뉩니다.

  1. 서버 단위 로드 밸런싱
  2. 가동 서버를 2대로 하여 차례대로 업데이트를 진행하는 방식입니다. 가장 단순하지만 서버 비용이 2배로 든다는 단점이 있습니다.
  3. 내부 포트 포워딩
  4. 외부 트래픽을 서버의 리버스 프록시에서 받은 후 내부 어플리케이션으로 다시 포워딩하는 방식입니다.
  5. 프로세스 매니저
  6. node.js 에는 pm2 라는 프로세스 매니저가 있습니다. 간단히 설명하자면 싱글 쓰레드로 구동되는 node.js 어플리케이션을 멀티 쓰레드로 구동할 수 있도록 해주는 프로세스 매니저입니다. 이 매니저를 사용하여 여러 개로 실행되는 프로세스를 하나씩 업데이트하는 방식입니다.

이 중 2번 내부 포트 포워딩을 선택했습니다. 현재 Docker 를 통해 서버를 구동하고 있기 때문에 다른 방법들에 비해 환경 구축이 가장 간단하고 추가 비용이 발생하지않는다는 점 때문이였습니다.

4. 해결 내용

레거시 github actions 배포 코드 먼저 보겠습니다.

deploy:
    needs: build
    name: Deploy
    runs-on: [develop]
    steps:
    - name: Login to ghcr
    uses: docker/login-action@v1
    with:
    registry: ghcr.io
    username: 
    password: 
    - name: Docker run
    run: |
        docker stop ${{ env.NAME }} && docker rm ${{ env.NAME }} && docker rmi ${{ env.DOCKER_IMAGE }}:latest
        docker run -d -p 80:3000 --name ${{ env.NAME }} --restart always ${{ env.DOCKER_IMAGE }}:latest

build 단계에서 push 받은 image 를 컨테이너로 구동하는 방식입니다.

기존에는 github actions 에서 직접 컨테이너를 구동시키면 됐지만 이젠 작업해야할 사항이 많아졌습니다. 포트 포워딩, 구동 컨테이너 지정 등..

일단 포트 포워딩을 위해 리버스 프록시 nginx 를 설치했습니다. 그리고 blue / green 상태에 따라 각자 다른 포트로 포워딩 시켜야하기 때문에 nginx.blue.conf 파일과 nginx.green.conf 파일을 만들었습니다.

// /etc/nginx/nginx.blue.conf

user nginx;
worker_processes auto;
pid /run/nginx.pid;

include /etc/nginx/modules-enabled/*.conf;

events {
    worker_connections  1024;
}

http {
    include mime.types;
    client_max_body_size 10m;

    upstream backend {
        server localhost:8081; # GREEN - 8080 포트로 연결합니다.
        # BLUE 설정 시 server 127.0.0.1:8081; 로 변경합니다.
    }

    server {
        listen 80; # or another port that NGINX will listen on

        location / {
            proxy_pass http://backend; # Upstream 이름을 사용합니다.
            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 $scheme;
        }
    }
}
// /etc/nginx/nginx.green.conf

user nginx;
worker_processes auto;
pid /run/nginx.pid;

include /etc/nginx/modules-enabled/*.conf;

events {
    worker_connections  1024;
}

http {
    include mime.types;
    client_max_body_size 10m;

    upstream backend {
        server localhost:8080; # GREEN - 8080 포트로 연결합니다.
        # BLUE 설정 시 server 127.0.0.1:8081; 로 변경합니다.
    }

    server {
        listen 80; # or another port that NGINX will listen on

        location / {
            proxy_pass http://backend; # Upstream 이름을 사용합니다.
            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 $scheme;
        }
    }
}

그 다음 각 상태에 따라 위 nginx conf 파일을 적용하고 컨테이너를 구동시킬 쉘 스크립트를 구성했습니다.

user nginx;
worker_processes auto;
pid /run/nginx.pid;

include /etc/nginx/modules-enabled/*.conf;

events {
    worker_connections  1024;
}

http {
    include mime.types;
    client_max_body_size 10m;

    upstream backend {
        server localhost:8080; # GREEN - 8080 포트로 연결합니다.
        # BLUE 설정 시 server 127.0.0.1:8081; 로 변경합니다.
    }

    server {
        listen 80; # or another port that NGINX will listen on

        location / {
            proxy_pass http://backend; # Upstream 이름을 사용합니다.
            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 $scheme;
        }
    }
}

[ec2-user@ip-172-31-57-168 ~]$ cat deploy_zero_down_time.sh
#!/bin/bash

IS_GREEN=$(docker ps | grep green)
DEFAULT_CONF="/etc/nginx/nginx.conf"

if [ -z $IS_GREEN]; then

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

        #docker image pull
        docker pull ghcr.io/eightbloom/hwadam-client-server-dev:latest

        #docker tag 변경 latest->recent
        docker tag ghcr.io/eightbloom/hwadam-client-server-dev:latest ghcr.io/eightbloom/hwadam-client-server-dev:recent-green

        #green 컨테이너 실행
        docker run -d -p 8080:3000 --name floom-client-server-dev-green --restart always ghcr.io/eightbloom/hwadam-client-server-dev:recent-green

        #green 컨테이너 상태 확인
        while [ 1 = 1 ]; do
        echo "3. green health check..."
                sleep 3

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

        #nginx가 green 컨테이너를 향하도록 설정 변경 및 리로드
        echo "4. reload nginx"
        sudo cp /etc/nginx/nginx.green.conf /etc/nginx/nginx.conf
        sudo nginx -s reload

        echo "5. blue container down"
        docker stop floom-client-server-dev-blue
        docker rm floom-client-server-dev-blue

        echo "6. delete not used image"
        docker rmi ghcr.io/eightbloom/hwadam-client-server-dev:latest
        docker rmi ghcr.io/eightbloom/hwadam-client-server-dev:recent-blue
else
        echo "###GREEN => BLUE###"
        #docker image pull
        docker pull ghcr.io/eightbloom/hwadam-client-server-dev:latest

        #docker tag 변경 latest->recent
        docker tag ghcr.io/eightbloom/hwadam-client-server-dev:latest ghcr.io/eightbloom/hwadam-client-server-dev:recent-blue

        #blue 컨테이너 실행
        docker run -d -p 8081:3000 --name floom-client-server-dev-blue --restart always ghcr.io/eightbloom/hwadam-client-server-dev:recent-blue

        #blue 컨테이너 상태 확인
        while [ 1 = 1 ]; do
        echo "3. blue health check..."
                sleep 3

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

        #nginx가 blue 컨테이너를 향하도록 설정 변경 및 리로드
        echo "4. reload nginx"
        sudo cp /etc/nginx/nginx.blue.conf /etc/nginx/nginx.conf
        sudo nginx -s reload

        echo "5. green container down"
        docker stop floom-client-server-dev-green
        docker rm floom-client-server-dev-green

        echo "6. delete not used image"
        docker rmi ghcr.io/eightbloom/hwadam-client-server-dev:latest
        docker rmi ghcr.io/eightbloom/hwadam-client-server-dev:recent-green

fi

이제 마지막으로 github actions 에 배포를 해당 쉘 스크립트를 실행하도록 하면 끝입니다.

deploy:
    needs: build
    name: Deploy
    runs-on: [develop]
    steps:
    - name: Login to ghcr
    uses: docker/login-action@v1
    with:
    registry: ghcr.io
    username: 
    password: 
    - name: Docker run
    run: |
        sudo chmod 755 /home/ec2-user/deploy_zero_down_time.sh
        /home/ec2-user/deploy_zero_down_time.sh

5. 개선 결과

배포할 떄 발생했던 1~2초 텀이 참 불편했는데 속이 후련했습니다. 그리고 개발 서버 배포 중 체감되는 일시적 오류와 같은 개발 환경에서의 불편함이 많이 개선되었습니다. 무엇보다 서버 오류에 대한 제보를 듣는 빈도가 낮아져서 가장 좋았습니다