[EC2 배포] Github Actions + Nginx + Docker를 이용해서 CI/CD 구축하기

당근박쥐·2024년 6월 27일
2

개요

68번의 시도끝에 완성해버렸다...
docker, github actions, nginx 모두 처음이라 사용법을 익히는데 많은 시간과 노력이 소모되었는데, 지금 생각해보니, 낯설었을뿐 생각보다 쉬우니 차근차근 공부하면 어려울 부분은 없다 생각합니다!

그럼 본론으로 들어가, 제가 구성한 아키텍처는 다음과 같습니다.

  • git push -> Github Actions에 의해 배포 스크립트가 실행되고 Blue-Green 무중단 배포가 진행됩니다.

CI/CD 구축하기

1. EC2 인스턴스 생성하기

EC2에 대한 내용은 다음 포스팅을 참고해주세요.
특히, 프리티어라면 꼭 swap파일을 생성해주세요!
https://velog.io/@carrotbat410/EC2-Spring-Boot-%EC%84%9C%EB%B2%84-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0

추가적인 참고사항은 다음과 같습니다.

  • 인스턴스 생성할 때 기본 볼륨(=용량)은 8GB입니다. 30GB까지는 무료이므로 25GB정도로 용량을 세팅합니다. swap, docker image를 당겨오다 보니, 넉넉한 용량이 필요합니다.
  • 인바운드 그룹 설정에서 8080 ~ 8084번 포트는 추가하지 않았습니다.
    80번 포트로의 요청이 들어오면 nginx를 통해 떠있는 컨테이너로 요청을 전달하기 떄문에 8080 ~ 8084포트에 대한 인바운드 그룹 규칙을 추가할 필요가 없었습니다.

2. Nginx 세팅

2-1 Nginx 설치

$ sudo apt update

$ sudo apt install nginx

아래 명령어를 실행하여 정상적으로 실행 중인지 확인할 수 있습니다.
$ systemctl status nginx 

또한 브라우저 주소창에 서버의 ip 혹은 도메인 이름을 입력하여 아래와 같은 기본 Nginx 랜딩 페이지가 표시되는지 확인합니다.

위 랜딩 페이지가 표시된다면 Nginx가 정상적으로 동작하고 있다는 뜻입니다.

2-2 Nginx 세팅

Blue-Green 배포를 위해 미리 설정 파일을 수정하겠습니다.

다음 명령어로 관리자 권한을 얻은 다음.
$ sudo -s

upstream.conf파일을 생성합니다.
$ vi /etc/nginx/upstream.conf

upstream.conf

upstream app_servers {
    #ip_hash; (기본 분배 알고리즘은 round robin입니다.)
    server 127.0.0.1:8081; # App1 컨테이너
    server 127.0.0.1:8082; # App2 컨테이너
    server 127.0.0.1:8083; # App3 컨테이너
    server 127.0.0.1:8084; # App4 컨테이너
}

sites-available/default파일에 다음 내용을 추가해줍니다.

$ vi /etc/nginx/sites-available/default

default

server {
    listen 8080 default_server;
    listen [::]:8080 default_server;

    root /var/www/html;
    index index.html index.htm index.nginx-debian.html;

    server_name _;

    location / {
        proxy_pass http://app_servers;  # Blue-Green 배포를 위한 proxy_pass 설정
        proxy_set_header Host $host;	# Blue-Green 배포를 위한 
        proxy_set_header X-Real-IP $remote_addr;	# Blue-Green 배포를 위한 
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Blue-Green 배포를 위한 
        proxy_set_header X-Forwarded-Proto $scheme;	# Blue-Green 배포를 위한 
    }
}

nginx 기본 설정 파일에 다음 내용을 추가해줍니다.

vi /etc/nginx/nginx.conf

nginx.conf

user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
    worker_connections 768;
}

http {
    sendfile on;
    tcp_nopush on;
    types_hash_max_size 2048;
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;

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

    gzip on;

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
    include /etc/nginx/upstream.conf;  # Blue-Green 배포를 위한 추가 설정 파일
}

다음 명령어를 실행합니다.

nginx 재시작
$ systemctl restart nginx

제대로 실행되고 있는지 확인.
$ systemctl status nginx

nginx 세팅 끝!

3. Docker 세팅

다음 명령어를 사용해서, docker, docker compose를 설치하겠습니다.

3-1. Docker 설치

# 패키지 업데이트 및 필요한 패키지 설치.
sudo apt-get update

sudo apt-get install \
    apt-transport-https \
    ca-certificates \
    curl \
    software-properties-common

# Docker의 공식 GPG 키 추가.
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

# Docker 저장소를 추가.
sudo add-apt-repository \
   "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
   $(lsb_release -cs) \
   stable"

# 패키지 데이터베이스 업데이트.
sudo apt-get update

# Docker CE 설치.
sudo apt-get install docker-ce docker-ce-cli containerd.io

# Docker를 실행 중인 사용자 그룹에 추가.
# root 권한을 가지고 실행하는 것은 권장되지 않으므로, 사용자를 docker group에 포함시켜주면 된다.
($USER 환경 변수는 현재 로그인한 사용자 아이디를 나타내므로 그대로 입력하면 된다.) 
출처: https://technote.kr/369
sudo usermod -aG docker ${USER}

# /var/run/docker.sock 파일의 권한을 666으로 변경하여 그룹 내 다른 사용자도 접근 가능하게 변경 
(github actions에서 docker-compose접근시 /var/run/docker.sock에 대해서 permission denied이 
발생하여 이 명령어 추가함)
sudo chmod 666 /var/run/docker.sock

# Docker 설치 확인.
docker --version

3-2 Docker-Compose 설치

# Docker Compose의 최신 버전을 다운로드.
sudo curl -L "https://github.com/docker/compose/releases/download/$(curl -s https://api.github.com/repos/docker/compose/releases/latest | grep 'tag_name' | cut -d\" -f4)/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

# 실행 권한 추가.
sudo chmod +x /usr/local/bin/docker-compose

# Docker Compose 설치 확인.
docker-compose --version

3-3 docker-compose.yml 파일 만들기

docker-compose.yml파일을 작성합니다.

$ vi /home/ubuntu/docker-compose.yml

docker-compose.yml

services:
  app1:
    container_name: container1
    image: carrotbat410/spring-lol-repository:latest
    ports:
      - "8081:8080"
    depends_on:
      - mysql-db
    networks:
      - spring-lol-network

  app2:
    container_name: container2
    image: carrotbat410/spring-lol-repository:latest
    ports:
      - "8082:8080"
    depends_on:
      - mysql-db
    networks:
      - spring-lol-network

  app3:
    container_name: container3
    image: carrotbat410/spring-lol-repository:latest
    ports:
      - "8083:8080"
    depends_on:
      - mysql-db
    networks:
      - spring-lol-network

  app4:
    container_name: container4
    image: carrotbat410/spring-lol-repository:latest
    ports:
      - "8084:8080"
    depends_on:
      - mysql-db
    networks:
      - spring-lol-network

  mysql-db:
    container_name: mysql-db
    image: mysql:latest
    environment:
      MYSQL_DATABASE: lol_prod
      MYSQL_ROOT_PASSWORD: tmpPassword
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    networks:
      - spring-lol-network

volumes:
  mysql-data:

networks:
  spring-lol-network:
    external: true
    driver: bridge

본인 상황에 맞게 적절하게 작성해주시면 됩니다.

추가 작업으로 저는 mysql이미지를 먼저 불러오고 컨테이너를 띄운후, 아래명령어들로 접속 권한에 대해 설정을 진행했습니다.

$ docker exec -it {container_id} bash

$ mysql

$ CREATE DATABASE IF NOT EXISTS lol_prod;
$ CREATE USER 'root'@'%' IDENTIFIED BY 'tmpPassword';
$ GRANT ALL PRIVILEGES ON lol_prod.* TO 'root'@'%';
$ FLUSH PRIVILEGES;

4. 배포 스크립트 생성

배포 스크립트를 생성해줍니다.
git push를 하면 Github Actions가 EC2에 접속해 배포 스크립트를 실행해줄겁니다.

vi /home/ubuntu/deploy.sh

deploy.sh

#!/bin/bash

IS_APP1=$(docker ps | grep container1)
MAX_RETRIES=20

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

    REQUEST=$(curl $URL)
    if [ -n "$REQUEST" ]; then
      echo "health check success"
      return 0
    fi

    RETRIES=$((RETRIES+1))
  done;

  echo "Failed to check service after $MAX_RETRIES attempts."
  return 1
}

if [ -z "$IS_APP1" ];then
  echo "### App3 App4 => APP1 App2 ###"

  echo "1. App1 이미지 받기"
  docker-compose pull app1

  echo "2. App1 App2 컨테이너 실행"
  docker-compose up -d app1 app2

  echo "3. health check"
  if ! check_service "http://127.0.0.1:8081" || ! check_service "http://127.0.0.1:8082"; then
    echo "APP1 또는 APP2 health check 가 실패했습니다."
    exit 1
  fi

  echo "4. APP3 APP4 컨테이너 내리기"
  docker-compose stop app3 app4
  docker-compose rm -f app3 app4

else
  echo "### App1 App2 => App3 App4 ###"

  echo "1. App3 이미지 받기"
  docker-compose pull app3

  echo "2. App3 App4 컨테이너 실행"
  docker-compose up -d app3 app4

  echo "3. health check"
  if ! check_service "http://127.0.0.1:8083" || ! check_service "http://127.0.0.1:8084"; then
    echo "APP3 또는 APP4 health check 가 실패했습니다."
    exit 1
  fi

  echo "4. APP1 APP2 컨테이너 내리기"
  docker-compose stop app1 app2
  docker-compose rm -f app1 app2
fi

저장후 아래 명령어를 실행합니다.

권한 부여
$ chmod +x ./deploy.sh

작성된 배포 스크립트는 다음과 같이 동작합니다.

  1. 위에서 작성한 docker-compose.yml내용을 토대로 컨테이너를 띄웁니다.
  2. health check를 진행합니다.(3초마다, 20번 시도.)
  3. health check가 성공하면, 기존에 떠있던 컨테이너를 내립니다.

5. Github Actions 세팅

Github Actions의 workflow를 구성해줍니다.
./github/workflows/ci-cd.yml

name: CI/CD Pipeline

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

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

      - name: Set up Local MySQL for Test
        uses: mirromutth/mysql-action@v1.1
        with:
          host port: 3306
          container port: 3306
          mysql database: 'lol-test'
          mysql root password: 'tmpPassword'

      - name: Create application.properties for test
        run: |
          mkdir -p src/main/resources
          echo "${{ secrets.APPLICATION_PROPERTIES }}" > src/main/resources/application.properties
          echo "${{ secrets.APPLICATION_PROPERTIES_PROD }}" > src/main/resources/application-prod.properties
          echo "${{ secrets.APPLICATION_PROPERTIES_SECRET }}" > src/main/resources/application-secret.properties

#GitHub Actions에서 steps.<step_id>.conclusion은 해당 단계의 결과를 나타냄.
# conclusion 값은 GitHub Actions 자체에서 할당하며, 가능한 값 종류는 success / failure / cancelled / skipped 이 있음.
      - name: Test with Gradle
        id: gradle-test
        run: SPRING_PROFILES_ACTIVE=secret ./gradlew test

      - name: Build with Gradle
        if: steps.gradle-test.conclusion == 'success'
        run: SPRING_PROFILES_ACTIVE=secret ./gradlew build

      - name: Login to Docker Hub
        if: steps.gradle-test.conclusion == 'success'
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}

      - name: Build Docker image
        if: steps.gradle-test.conclusion == 'success'
        run: docker build . -t carrotbat410/spring-lol-repository

      - name: Push Docker image
        if: steps.gradle-test.conclusion == 'success'
        run: docker push carrotbat410/spring-lol-repository

      - name: Deploy to Server
        if: steps.gradle-test.conclusion == 'success'
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ubuntu
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            /home/ubuntu/deploy.sh
            docker rmi -f $(docker images -f "dangling=true" -q)
#          tag:none인 이미지 지우는 명령어

github repository -> Setting에서 필요한 github actions secret들을 추가합니다.

ci-cd.yml의 내용은 다음과 같습니다.

  1. github actions runner위에 스프링 서버를 띄우기 위해 세팅을 해줍니다.(JDK, DB)
  2. 테스트코드를 실행후, 테스트에 성공하면 다음 과정을 진행합니다.
  3. 빌드 파일을 만들고, docker hub에 docker image를 업로드합니다.
  4. EC2 인스턴스에 접속하여, deploy.sh를 실행합니다.

6. 결과 확인

main 브랜치에 git push하게 되면 CI/CD가 진행됩니다.

EC2 인스턴스에 접속해 아래 명령어로 컨테이너가 제대로 작동하는지 확인합니다.

$ docker ps
$ docker ps 결과
CONTAINER ID   IMAGE                                       COMMAND                  CREATED        STATUS        PORTS                                                  NAMES
2391f1e45c5d   carrotbat410/spring-lol-repository:latest   "java -jar -Dspring.…"   23 hours ago   Up 23 hours   0.0.0.0:8082->8080/tcp, :::8082->8080/tcp              container2
9019a209b7e4   carrotbat410/spring-lol-repository:latest   "java -jar -Dspring.…"   23 hours ago   Up 23 hours   0.0.0.0:8081->8080/tcp, :::8081->8080/tcp              container1
609ff1e0a69c   mysql:latest                                "docker-entrypoint.s…"   24 hours ago   Up 24 hours   0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp   mysql-db

spring 서버에 접속 로그가 찍히도록 기능을 추가한 뒤 본인의 주소로 요청을 여러번 보내면

public ip + 포트 80 (포트 80번은 생략 가능)

2개의 컨테이너에 요청을 분산시키는 것을 확인할 수 있습니다!

마무리

아직 부족한 부분이 있습니다.
health check에서 단순히 root endpoint인 127.0.0.1을 확인했는데, 이건 "스프링이 켜지긴 했다." 라는 뜻입니다. 데이터베이스 연결 상태, 외부 서비스와의 통신 상태 등 필요한 부분을 health check하는 개선된 과정이 필요합니다.

참고자료

https://technote.kr/369
https://jaehyeon48.github.io/nginx/configure-nginx-on-ubuntu-2004/
https://yeonyeon.tistory.com/52
https://velog.io/@hooni_/Blue-Green-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC
https://www.joinc.co.kr/w/man/12/proxy
https://github.com/occidere/TIL/issues/116

profile
Starting the day with "git pull," it's good for mental health.

0개의 댓글