장고 앱 CI/CD, 무중단 배포 구현기

Published
November 18, 2024
Last updated
Last updated December 3, 2024
Tistory
Category

개요

 

AS-IS

기존의 시스템은 다음과 같은 워크플로우로 CD를 진행했습니다.
notion image
다음 구조에서는 다음과 같은 부분에서 아쉬움을 느꼈습니다.
  1. 서버에서 직접 도커 이미지를 빌드
      • 서버 자원을 더 사용합니다
  1. 도커 컨테이너 종료후 빌드와 재실행
      • 빌드하는 동안 시간이 많이 걸려 서버가 다운타운이 길어집니다
      • 빌드후 서버 종료-재실행을 진행해 중단 시간을 줄여도, 여전히 무중단은 아닙니다
  1. Nginx 관련
      • Nginx가 같이 재시작하고 있어 무중단 배포가 어려운점
      • 그리고 도커에서 실행되고 있어 TLS 인증서 자동 갱신이 어려웠던 점
 
그리하여 해당 구조를 개선하기 위한 작업을 하고자 합니다.

TO-BE

도커 이미지 저장소

기존에 서버에서 도커 이미지를 직접 빌드했던 가장 큰 이유는 도커 이미지 저장소가 마땅하지 않았기 때문입니다.
가장 디폴트로 사용할 수 있는 Docker Hub은 무료 요금제에서 private 저장소를 1개만 제공하고 있습니다. 월 5~7$로 구독할 수 있으나, 그렇게 비싸진 않지만 고정으로 지불하기에는 부담으로 다가올 수 있는 가격이었습니다. Github에서 제공하는 저장소는 500MB의 제한이 있어 사용하기에 어려웠습니다.
그러나 1~2GB 저장소만을 정량제로 사용할 경우 보통 1GB당 월 0.1~0.25$ 으로 제공하는 서비스가 많이 있어 새롭게 이를 사용해보기로 했습니다.
해당 서비스를 제공하는 벤더는 AWS, Azure, GCP, Github등 다양하나 서버를 EC2로 새롭게 이전할 계획이 있어, AWS 연동성과 네트워크 비용을 고려해 AWS Elastic Container Registry를 사용하기로 결정했습니다.

Github Action

CI/CD 워크플로우는 Github Action을 사용하며, Self-hosted Runner보다는 ssh와 scp등을 사용하기로 결정했습니다. Self-hosted Runner는 사전에 서버 인스턴스에 설정해줘야 하는 것들이 많은 반면, ssh를 사용한다면 github action의 환경변수를 변경해주는 것만으로 쉽게 배포 서버 인스턴스를 교체해줄 수 있기 때문입니다.

Nginx와 무중단 배포

무중단 배포를 하기 위해 필요한 것은, 새로운 버전의 서버를 띄우고, 새로운 서버로 트래픽을 전환할 수 있어야 합니다. 이 때 이를 전환하기 위해 서버 앞단에서 서버로 트래픽을 전달해주는 하나의 레이어가 추가적으로 필요합니다.
이 레이어는 다양한 것을 채용할 수 있습니다.
  • AWS ELB
notion image
가장 일반적으로는 AWS ELB등의 로드밸런서를 앞단에 두는 것을 생각해볼 수 있습니다.
로드밸런서의 원래 주 역할은 트래픽이 많을 때 여러 인스턴스로 트래픽을 전달하는, 즉 Scale out 방식의 시스템 확장을 위한 도구입니다.
이 기능을 활용하여 새로운 버전의 서버를 띄우고 로드밸런서에서 해당 서버로 연결을 해주고, 기존의 서버는 연결을 해제하고 종료하는 방식으로 점진적인 서버 전환을 이루어낼 수 있습니다.
가장 정석적인 방법이라 생각이 되지만, 해당 기능만을 위해 ELB를 쓰기에는 ELB의 비용이 인스턴스 비용보다 더 나갈 수 있으니 주의해야 합니다.
 
  • Route 53 DNS
notion image
Route 53등의 DNS 서비스에서 DNS 레코드를 업데이트 하여 새로운 환경으로 트래픽을 전환할 수도 있습니다.
즉 api.wibaek.net으로 들어오는 트래픽을 기존의 x.x.x.1 서버에서 새로운 x.x.x.2 서버로 변경하는 방식입니다.
다만 모든 네임서버에서 레코드가 변경되는데는 오랜 시간이 걸릴 수 있는 등 문제가 있어 일반적으로 사용할 방법은 아닙니다.
 
  • AWS API Gateway
notion image
API Gateway도 앞단에서 트래픽을 라우팅해주는 역할을 하기에, 무중단 배포에 사용할 수 있습니다.
해당 작업을 진행하기 위해 수작업으로 연결된 API endpoint를 변경해줄 수도 있겠으나, stage 기능이나 REST API의 Canary 배포 기능을 사용해도 좋을 것 같습니다.
 
  • Nginx 사용
notion image
위에서 소개한 3가지의 방법은 모두 새로운 인스턴스를 띄우고 트래픽을 라우팅하는 방법이었습니다. 마지막 방법은 Nginx를 사용해 트래픽을 라우팅하는 방법입니다.
이는 Nginx의 리버시프록시 기능을 이용해 내부의 WAS로 트래픽을 라우팅하는 원리입니다. 외부 80, 443번 포트로 트래픽을 받아 내부 8001번 포트로 연결을 해주다가, 새로운 버전의 서버로 전환을 하고 싶을 때 내부 8002번 포트로 새로운 서버를 띄워 순간적으로 8002번으로 연결을 전환해주는 방식입니다.
또한 Nginx는 로드 밸런서 기능도 지원하기에, 이후 scale out을 할 때 메인 인스턴스를 Nginx + WAS용으로 구성, 서브 인스턴스들을 WAS로 구성한 후 메인 인스턴스에서 서브 인스턴스들로 트래픽을 분산시켜줄 수 있다는 것도 매력적으로 다가왔습니다.

서버 인스턴스 사전 구성에 대해서

이번에 CI/CD를 구성하며 세운 목표중 하나는 서버 인스턴스에 사전에 설정되어야 하는 것들을 최소화 하는 것이었습니다. 예를들면 서버에 미리 도커를 설치해두는 것을 사전 구성이라 할 수 있습니다.
이런 사전 구성을 최소화 하려했던 이유는, 한번 설정해두고 끝나는 일이기에 관리가 소홀해져 이후에 다시 설정할 때 문서화를 잘 해두지 않으면 이전 환경과 동일하게 맞추기 어려운 문제가 자주 발생하기 때문입니다. 또한 도커와 nginx 등만 간단히 설치해두는 설정이라면 해당 프로그램을 설치한 상태로 디스크 이미지를 떠서 반복해서 사용할 수도 있습니다.
그렇기에 환경변수등을 미리 서버에 설정해주는 일은 최대한 없도록 했습니다. AWS등의 클라우드에서 제공하는 기능을 사용하면 환경변수등을 설정하고 관리하기도 편하겠지만, 플랫폼 의존을 낮추기 위함입니다.
결론적으로 도커와 Nginx를 서버 인스턴스에 미리 설치해두고, Let’s Encrypt certbot을 이용해 SSL 인증서 자동 갱신을 해주는 것만 사전에 구성해두기로 했습니다.
이 이상의 shell script가 필요하거나, 환경변수가 필요하거나 한것들은 모두 Github Action workflow중에 파일을 전달하거나 주입하는 방식으로 진행했습니다.

알림

Github Actions를 이용해 CI/CD가 완료된 후 성공적으로 실행 되었는지 여부등을 슬랙 알람등으로 받는 기능도 추가하고자 합니다.

구현

ECR Registry 설정

 

서버 사전 구성

서버는 Ubuntu를 사용합니다.
sudo apt-get update
 
  • Docker, Docker compose 설치
sudo apt-get install ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo groupadd docker sudo usermod -aG docker $USER newgrp docker
docker를 설치해주고, docker를 관리자권한 없이 사용할 수 있게 도커 그룹에 사용자를 추가해줍니다.
 
  • nginx 설치
sudo apt-get install -y nginx
 
  • AWS cli 설치
sudo apt install unzip curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" unzip awscliv2.zip sudo ./aws/install
플랫폼 의존도를 최대한 낮추고 싶었으나, AWS ECR을 사용하려면 패키지를 설치해주는 것이 가장 간단하다고 보았고, 인스턴스 자체도 동일한 AWS의 EC2를 사용할 예정이니 어느정도 타협하고 넘어갔습니다.

Github Actions workflow

name: Build, Push to ECR, and Deploy on: push: branches: [release] workflow_dispatch: jobs: build-and-push: runs-on: ubuntu-latest outputs: REGISTRY: ${{ steps.login-ecr.outputs.registry }} steps: - name: Checkout the code uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.PAT_TOKEN }} - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ vars.AWS_REGION }} # ECR 로그인 - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 # Docker 이미지 빌드 및 푸시 - name: Build, tag, and push docker image to Amazon ECR env: REGISTRY: ${{ steps.login-ecr.outputs.registry }} REPOSITORY: ${{ secrets.ECR_REPOSITORY }} IMAGE_TAG: ${{ github.sha }} run: | docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG . docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG deploy: runs-on: ubuntu-latest needs: build-and-push steps: - name: Checkout the code uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.PAT_TOKEN }} - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ vars.AWS_REGION }} # 필요한 파일을 서버로 전송 - name: Copy files to server uses: appleboy/scp-action@v0.1.7 with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} port: ${{ secrets.EC2_SSH_PORT }} source: "./deploy/" target: /home/${{ secrets.EC2_USER }}/ - name: Create .env on server uses: appleboy/ssh-action@v0.1.5 with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} port: ${{ secrets.EC2_SSH_PORT }} script: | echo "REGISTRY=${{ secrets.ECR_REGISTRY }}" > /home/${{ secrets.EC2_USER }}/deploy/.env echo "REPOSITORY=${{ secrets.ECR_REPOSITORY }}" >> /home/${{ secrets.EC2_USER }}/deploy/.env echo "AWS_REGION=${{ vars.AWS_REGION }}" >> /home/${{ secrets.EC2_USER }}/deploy/.env echo "IMAGE_TAG=${{ github.sha }}" >> /home/${{ secrets.EC2_USER }}/deploy/.env echo "AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }}" >> /home/${{ secrets.EC2_USER }}/deploy/.env echo "AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}" >> /home/${{ secrets.EC2_USER }}/deploy/.env # 배포 실행 - name: Deploy via SSH uses: appleboy/ssh-action@v0.1.5 env: REGISTRY: ${{ needs.build-and-push.outputs.REGISTRY }} REPOSITORY: ${{ secrets.ECR_REPOSITORY }} AWS_REGION: ${{ vars.AWS_REGION }} IMAGE_TAG: ${{ github.sha }} with: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} port: ${{ secrets.EC2_SSH_PORT }} envs: REGISTRY, REPOSITORY, AWS_REGION, IMAGE_TAG script: | cd /home/${{ secrets.EC2_USER }}/deploy chmod +x deploy.sh ./deploy.sh

블루/그린 배포 실행 스크립트

#!/bin/bash set -e # 환경 변수 로드 source .env export AWS_ACCESS_KEY_ID export AWS_SECRET_ACCESS_KEY # AWS ECR 로그인 aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $REGISTRY # IMAGE_TAG는 GitHub Actions에서 전달된 값을 사용 IMAGE_TAG=${IMAGE_TAG} # 현재 활성화된 버전 확인 if [ -f active_version ]; then CURRENT_VERSION=$(cat active_version) else CURRENT_VERSION="green" # 초기 배포 시 기본값 fi # 다음 배포할 버전 결정 if [ "$CURRENT_VERSION" = "blue" ]; then NEW_VERSION="green" ACTIVE_PORT=8002 else NEW_VERSION="blue" ACTIVE_PORT=8001 fi # Docker Compose 환경 변수 설정 export REGISTRY export REPOSITORY export IMAGE_TAG # 새로운 이미지 풀 docker pull $REGISTRY/$REPOSITORY:$IMAGE_TAG # 새로운 서비스 시작 docker compose up -d app_${NEW_VERSION} # 헬스 체크 대기 echo "새로운 버전의 컨테이너 헬스 체크 중..." sleep 10 # 헬스 체크 수행 if ! curl -f http://localhost:${ACTIVE_PORT}/v1/health; then echo "새로운 컨테이너가 헬스 체크에 실패했습니다. 롤백합니다." docker compose stop app_${NEW_VERSION} exit 1 fi # Nginx 설정 업데이트 sed "s/\${ACTIVE_UPSTREAM}/app_${NEW_VERSION}/g" nginx.conf.template > nginx.conf sudo cp nginx.conf /etc/nginx/conf.d/default.conf sudo systemctl reload nginx # 이전 서비스 중지 docker compose stop app_${CURRENT_VERSION} # 활성화된 버전 업데이트 echo "${NEW_VERSION}" > active_version echo "${NEW_VERSION} 버전으로 배포 완료."
 

Nginx

upstream app_blue { server 127.0.0.1:8001; } upstream app_green { server 127.0.0.1:8002; } server { listen 80; server_name api.misiklog.com; location / { return 301 https://$host$request_uri; } } server { listen 443 ssl; server_name api.misiklog.com; ssl_certificate /etc/letsencrypt/live/blog.wibaek.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/blog.wibaek.net/privkey.pem; location / { proxy_pass http://${ACTIVE_UPSTREAM}; proxy_set_header Host $host:$server_port; 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; } location /static/ { alias /home/ubuntu/static/; } }
  • $host$request_uri
 
 

경험한 문제

Github Actions 워크플로우를 구성하며 GPT를 너무 맹신하다 발생한 오류가 많았습니다. 최신 정보는 특히나 많이 취약하니 직접 문서를 보고 설정하는것이 좋아 보입니다.

The workflow is not valid. .github/workflows/cd-prod.yml: A mapping was not expected

appleboy/ssh-action을 사용하며 환경변수 설정을 이상한 방식으로 해줘서 생긴 문제입니다
# 문제 발생 - name: Deploy via SSH uses: appleboy/ssh-action@v0.1.5 with: env: REGISTRY: ${{ needs.build-and-push.outputs.REGISTRY }} REPOSITORY: ${{ secrets.ECR_REPOSITORY }} AWS_REGION: ${{ vars.AWS_REGION }} IMAGE_TAG: ${{ github.sha }} # 수정 - name: Deploy via SSH uses: appleboy/ssh-action@v0.1.5 env: REGISTRY: ${{ needs.build-and-push.outputs.REGISTRY }} REPOSITORY: ${{ secrets.ECR_REPOSITORY }} AWS_REGION: ${{ vars.AWS_REGION }} IMAGE_TAG: ${{ github.sha }} with: envs: REGISTRY, REPOSITORY, AWS_REGION, IMAGE_TAG
 

scp 전송중 'tar: empty archive'

아래와 같이 다중 source를 설정해서 생긴 문제로 보입니다.
source: | ./docker-compose.yml ./deploy/nginx.conf.template ./deploy/deploy.sh
공식 문서에서는 다중 source를 한번에 설정해주는 예시가 없었기에 이를 해결하기 위해
  1. 여러번 반복해서 전송해준다
  1. 한 폴더에 넣어서 전송해준다
두 가지의 선택지중 한 폴더에 넣어 한번에 전송하는 방식을 선택했습니다.
 

error copy file to dest: ***, error message: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain

scp-action@v0.1.1를 사용하며 발생한 문제인데, v0.1.7로 바꿔주니 해결되었습니다. 물론 과거 버전에도 해결방법은 있겠지만 굳이 과거 버전을 사용할 필요가 없다고 생각합니다.
원래 가능하면 최신버전을 사용하려 했는데 GPT가 계속 코드를 수정하며 과거버전을 사용하기에, 과거버전이 안정적일 수 있다는 생각으로 방치한것이 문제였습니다.
 

err: bash: line x: ./deploy.sh: Permission denied

배포 스크립트 실행중 퍼미션 문제입니다.
워크플로우에 chmod +x 를 해주는 과정을 추가했습니다.
 

deploy.sh 실행중 문제

err: Unable to locate credentials. You can configure credentials by running "aws configure". err: Error: Cannot perform an interactive login from a non TTY device
expose를 통해 AWS 인증 정보를 환경변수로 설정해주었습니다.
 

Nginx 정적파일 403 Forbidden 문제

server { location /static/ { alias /home/ubuntu/static/; } }
nginx.conf
정적파일을 서빙할 때 Nginx에서 403 Forbidden 응답을 반환하는 문제. 문제는 다름아닌 폴더 권한에 있었다. nginx는 /home/ubuntu/static/에 있는 파일을 연결해주고 있는데 static 폴더의 권한은 제대로 접근 가능하게 설정이 되어 있으나, 상위 폴더인 ubuntu 폴더에서 others 권한에 read가 설정되어 있지 않아 발생한 문제였다.
drwxrwxr-x 6 ubuntu ubuntu 4096 Dec 1 00:00 static # 아래와 같이 /ubuntu 폴더에는 자신과 자신의 그룹 외의 others 에게는 아무 권한도 부여하지 않고 있다 drwxr-x--- 8 ubuntu ubuntu 4096 Dec 1 00:00 ubuntu
이를 해결하기 위해선 static 폴더 자체를 상위 폴더로 이전하거나, /ubuntu 폴더에 권한을 주는 방법이 있다. 이중 권장할 만한 방법은 static 폴더의 위치를 바꾸는 것이지만 이번에는 ubuntu 폴더에 755 권한을 부여해 해결했다.
$ sudo chmod 755 ubuntu/ $ ls -al drwxr-xr-x 8 ubuntu ubuntu 4096 Dec 1 00:00 ubuntu