Docker를 사용한 CI/CD

WinG·2024년 7월 1일
0
post-thumbnail

Docker hub란?

우리는 어떤 프로그램이 필요할 때, 앱 스토어에서 프로그램을 다운받게 되고 해당 프로그램을 실행시키면 프로세스가 동작하는 방식으로 작동한다. Docker도 이와 비슷하다. 앱 스토어와 같이 우리는 필요한 소프트웨어를 docker hub에서 찾게 된다. 이렇게 도커 허브에서 다운로드한 소프트웨어 패키지를 image라고 부른다. 이미지는 실행 가능한 소프트웨어의 스냅샷이며 모든 설정과 종속성을 포함하고 있다. 그리고 이 image를 기반으로 실행되는 독립된 환경을 container(컨테이너)라고 부른다.

Dockerfile 작성

Dockerfile은 어플리케이션을 컨테이너화 하기 위한 과정을 기록한 파일으로 docker는 이를 통해서 image를 생성한다. 간단하게 말해, Dockerfile은 도커 이미지를 생성하기 위한 스크립트 파일로 여러 키워드를 사용해 작성하여 빌드를 보다 쉽게 수행할 수 있다.

FROM amd64/amazoncorretto:17
WORKDIR /app
COPY ./build/libs/docker-1.0.0-SNAPSHOT.jar /app/NOW-SOPT.jar
CMD ["java", "-Duser.timezone=Asia/Seoul", "-jar", "-Dspring.profiles.active=dev", "NOW-SOPT.jar"]

위의 Dockerfile 예시에서 사용된 명령어들을 살펴보자.

FROM 키워드 뒤에는 생성할 image의 base가 될 image를 선언한다. 주로 OS 이미지나 런타임 이미지를 지정한다. 해당 예시에서의 스크립트 코드는 x86-64 아키텍처용 Amazon Corretto 17 이미지를 기반으로 새 Docker 이미지를 빌드하겠다는 의미이다.

WORKDIR 는 명령어를 실행할 directory를 지정하는 키워드이다. Bash shell에서 cd 명령어와 같은 기능이라고 생각하면 된다.

COPY 는 호스트 환경의 파일이나 폴더를 이미지 안으로 복사하기 위해 사용한다.

호스트(host)가 뭔가요?
운영체제가 설치된 컴퓨터를 주인이라는 뜻에서 host라고 부른다. 그리고 이런 host에서 실행되는 각각의 격리된 실행 환경을 container라고 부른다.

CMD 는 컨테이너가 시작될 때 실행할 명령어를 설정한다. CMD 명령어는 Dockerfile에 하나만 있어야 하며, 여러개가 존재할 경우 마지막에 지정된 것이 사용된다.

deploy.sh 파일 작성

deploy.sh 파일은 배포 프로세스를 자동화하는 스크립트 파일이다. Sopt 세미나에서는 Docker와 Nginx를 사용하여 Blue-Green 배포 전략을 구현하는 방식으로 스크립트를 작성하였다.

변수 설정

nginx_config_path="/etc/nginx"   # Nginx 설정 파일 경로
all_port=("8080" "8081")   # 사용할 수 있는 모든 포트 지정
available_port=()    # 사용 가능한 포트를 저장할 배열
user_name=kgy    # Docker hub의 사용자 이름
server_name=nowsopt   # 서버 이름

docker_ps_output=$(docker ps | grep $server_name)  # 현재 실행 중인 Docker 컨테이너를 필터링
running_container_name=$(echo "$docker_ps_output" | awk '{print $NF}')   # 현재 실행 중인 컨테이너의 이름 추출
blue_port=$(echo "$running_container_name" | awk -F'-' '{print $NF}')  # 현재 실행 중인 컨테이너의 포트를 추출
web_health_check_url=/actuator/health

실행 중인 서버의 포트 확인

if [ -z "$blue_port" ]; then
    echo "> 실행 중인 서버의 포트: 없음"
else
    echo "> 실행 중인 서버의 포트: $blue_port"
fi

실행 가능한 포트 확인

# 현재 실행 중인 포트를 제외한 나머지 포트를 available_port 배열에 추가
for item in "${all_port[@]}"; do
    if [ "$item" != "$blue_port" ]; then
        available_port+=("$item")  
    fi
done

# 사용할 수 있는 포트가 없다면 스크립트 종료
if [ ${#available_port[@]} -eq 0 ]; then
    echo "> 실행 가능한 포트가 없습니다."
    exit 1
fi

# 사용할 수 있는 첫 번째 포트를 green_port로 설정
green_port=${available_port[0]}

Docker 이미지 다운로드 및 새로운 서버 실행

echo "----------------------------------------------------------------------"
echo "> 도커 이미지 pull 받기"
docker pull ${user_name}/${server_name}

echo "> ${green_port} 포트로 서버 실행"
echo "> docker run -d --name ${server_name}-${green_port} -v /app -p ${green_port}:8080 -e TZ=Asia/Seoul ${user_name}/${server_name}"
docker run -d --name ${server_name}-${green_port} -v /app -p ${green_port}:8080 -e TZ=Asia/Seoul ${user_name}/${server_name}
echo "----------------------------------------------------------------------"

생성되는 이미지를 컨테이너로 실행하기 위해서 run 커맨드를 사용하였다.

서버 상태 체크

# 서버가 제대로 실행되었는지 헬스 체크를 통해 확인한다
sleep 10
for retry_count in {1..10}  # 최대 10번 재시도
do
    echo "> 서버 상태 체크"
    echo "> curl -s http://localhost:${green_port}${web_health_check_url}"
    response=$(curl -s http://localhost:${green_port}${web_health_check_url})
    up_count=$(echo $response | grep 'UP' | wc -l)

    if [ $up_count -ge 1 ]
    then
        echo "> 서버 실행 성공"
        break
    else
        echo "> 아직 서버 실행 안됨"
        echo "> 응답 결과: ${response}"
    fi
    if [ $retry_count -eq 10 ]
    then  # 서버가 실행되지 않으면 컨테이너를 삭제하고 스크립트를 종료한다.
        echo "> 서버 실행 실패"
        docker rm -f ${server_name}-${green_port}
        exit 1
    fi
    sleep 5
done

Nginx 포트 스위칭

echo "----------------------------------------------------------------------"
echo "> nginx 포트 스위칭"
echo "set \$service_url http://127.0.0.1:${green_port};" | sudo tee ${nginx_config_path}/conf.d/service-url.inc
sudo nginx -s reload

sleep 1

서버 변경 확인

echo "----------------------------------------------------------------------"
response=$(curl -s http://localhost${web_health_check_url})
up_count=$(echo $response | grep 'UP' | wc -l)
if [ $up_count -ge 1 ]
then
    echo "> 서버 변경 성공"
else
    echo "> 서버 변경 실패"
    echo "> 서버 응답 결과: ${response}"
    exit 1
fi

기존 서버 중단 및 정리

if [ -n "$blue_port" ]; then
    echo "> 기존 ${blue_port}포트 서버 중단"
    echo "> docker rm -f ${server_name}-${blue_port}"
    sudo docker rm -f ${server_name}-${blue_port}
    docker rmi $(docker images -f "dangling=true" -q)
fi

Github Actions

Github Actions는 CI/CD를 자동화하기 위해 사용된다. GitHub Actions를 통해 코드가 변경될 때마다 자동으로 빌드하고 이미지를 생성하여 Docker Hub에 push하고 최종적으로 EC2 서버에 배포하는 일련의 과정을 자동화한다.

Workflow

Workflow는 하나 이상의 작업을 실행시키는 자동화된 프로세스이다. 이벤트가 발생했을 때 자동으로 실행되며 특정 시간마다 작동하게 할 수도 있다.

name: DOCKER-CD
on:
  push:
    branches: [ "main" ]

name은 워크플로우의 이름을 지정하고 on은 워크플로우를 트리거할 이벤트를 지정한다. 위의 sopt seminar 설정에서는 main 브랜치에 push 될 때, 트리거가 되도록 설정하였다.

Job & Step

Job은 workflow 안의 컨테이너 단위이다. 그리고 각 job내에서 step들을 수행하는 방식으로 동작한다. 각 step은 shell script 또는 action이 될 수 있다. 그리고 steps의 uses에서는 해당 step에서 사용할 action을 정의한다.

먼저 CI부터 살펴보자.

jobs:
  ci:  
    runs-on: ubuntu-22.04   # job을 실행시키는 환경 명시
    env:   # 환경변수 설정
      working-directory: .

    steps:
      - name: 체크아웃  # step의 이름
        uses: actions/checkout@v3  # 코드 체크아웃

      - name: Set up JDK 17  
        uses: actions/setup-java@v3   # jdk를 다운받고 캐싱해주는 Action
        with:
          distribution: 'corretto' # jdk를 제공하는 vender사 이름 
          java-version: '17'

      - name: application.yml 생성
        run: |
          mkdir -p ./src/main/resources && cd $_
          touch ./application.yml
          echo "${{ secrets.CD_APPLICATION }}" > ./application.yml
          cat ./application.yml
        working-directory: ${{ env.working-directory }}

      - name: 빌드
        run: |
          chmod +x gradlew
          ./gradlew build -x test
        working-directory: ${{ env.working-directory }}
        shell: bash

      - name: docker 로그인
        uses: docker/setup-buildx-action@v2.9.1  # Docker Buildx(Docker의 빌드 도구) 설정

      - name: login docker hub
        uses: docker/login-action@v2.2.0
        with:
          username: ${{ secrets.DOCKER_LOGIN_USERNAME }}
          password: ${{ secrets.DOCKER_LOGIN_ACCESSTOKEN }}

      - name: docker image 빌드 및 푸시
        run: |
          docker build --platform linux/amd64 -t /kgy/nowsopt .
          docker push kgy/nowsopt
        working-directory: ${{ env.working-directory }}

Github Actions에서 코드 체크아웃은 일반적으로 CI/CD 파이프라인의 첫 번째 단계로 수행된다. 이 action은 github 레포지토리의 내용을 workflow 실행환경으로 가져온다. 그 후, 설정한 java version과 vender사에 맞는 jdk를 다운받아준다.
보통 application.yml와 같은 파일은 보안 이유 때문에 github에 공개적으로 업로드하지 않는 것이 일반적이기 때문에 github에서 secrets로 등록하고 workflow가 실행될 때, 해당 secrets에 접근하여 application.yml 파일을 생성하도록 작성한다.
이렇게 빌드된 jar 파일을 도커 허브에 푸시한다.

  cd:
    needs: ci   # CI 작업이 성공적으로 완료되어야 CD 작업 실행
    runs-on: ubuntu-22.04

    steps:
      - name: docker 컨테이너 실행
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_KEY }}
          script: |
            cd ~
            ./deploy.sh

이어서 CD부분이다. 간단하다. 외부 서버에서 SSH로 접속해 배포 스크립트를 실행시킨다.

Flow

즉, 전체적인 흐름은 다음과 같다.

  1. 코드를 변경하고 main 브랜치에 push 함
    • Github Actions 워크플로우 트리거
  2. CI 작업
    • GitHub Actions가 actions/checkout@v3을 사용하여 코드를 체크아웃
    • JDK를 설정하고, 애플리케이션을 빌드
    • Docker 이미지를 빌드하고 Docker Hub에 push
  3. CD 작업
    • 배포 스크립트(deploy.sh) 실행
      • 도커허브에 푸시된 이미지를 EC2에서 pull 받아 서버를 실행시킴
profile
공부하는 감자😎

0개의 댓글