개발

Github actions, Docker image, Docker hub 를 활용한 CI/CD 과정

모달조아 2023. 6. 1. 15:58

개요

과거에 팀 프로젝트를 진행하며 일주일 정도 수동으로 배포하며 불편하고 아주 비효율적이었던 경험이 있다. 그래서 항상 새롭게 프로젝트를 진행하면 배포 과정을 자동화하는데, 이번 개인 프로젝트에서 진행했던 그 과정을 정리하고자 한다.

 

아키텍처 결정

서버

배포할 서버는 AWS EC2 를 활용하였다. 개인이 가진 컴퓨터를 활용하여 서버를 구축할 수도 있겠지만, 여분의 컴퓨터가 없는 나는 고려하지 않았다. 클라우드 서버 중에서 Naver Cloud 와 AWS 중에 고민을 하였는데, 일단 1년간 무료로 사용할 수 있는 AWS 를 사용하기로 하였다.

 

CI 툴

Github actions 를 이용하였다. Jenkins 와 Github actions 간에 고민을 했지만, Jenkins 는 추가적인 설치가 필요하고 Github actions 를 이용하면 소스 코드와 함께 Github 에서 한번에 관리할 수 있다는 점에서 관리 포인트를 줄이고자 Github actions 를 선택하였다. 또한 이미 사용해봤기에 추가적인 러닝 커브도 없다.

 

배포 방식 결정

총 아래의 세 가지 방식 중에서 고민하였다.

  • 빌드 파일을 AWS S3 에 올리고 CodeDeploy 를 사용하는 방식
  • 빌드 파일을 DockerHub 에 올리고 CodeDeploy 를 사용하는 방식
  • 빌드 파일을 DockerHub 에 올리고 EC2 에서 pull 받아서 사용하는 방식

결론을 먼저 말하자면, 제일 마지막인 빌드 파일을 DockerHub 에 올리고 EC2 에서 pull 받아서 사용하는 방식을 선택하였다. 크게 2가지 이유이다. 비용 문제와 AWS 의존성을 낮추기 위함이다.

첫번째 비용 문제는 현재 AWS 프리티어를 이용하고 있는데 S3 가 월별 표준 스토리지 5GB까지, GET 요청 20,000건, PUT 요청 2,000건 무료이다. 생각보다 넉넉한 양은 아니다. 특히 용량 5GB 가 여러 jar 파일을 보관하기에는 부족하다고 생각하였다. 그리고 진행하고 있는 개인 프로젝트 특성 상 이미지 처리를 많이 하고 이 또한 S3 에서 하고 있기에 여러모로 S3 를 사용하는 것은 부담스러웠다.

두번째 AWS 의존성을 낮추고자 한 이유는 추후에 온프레미스 환경 혹은 다른 클라우드로 바꿀 수도 있기 때문이다. 프리티어 혜택이 끝나거나 프리티어 EC2 로 아쉬운 경우에 변경 가능성이 있기에 의존성을 낮추고 싶었다.

그래서 아래와 같은 흐름으로 진행하게 되었다.

Github 에 소스코드를 push 하면 Github actions 를 통해 빌드하고 그 빌드된 파일을 바탕으로 docker 이미지를 만든다. 그리고 그 이미지를 docker hub 에 올린다. Github actions 의 서버에서 SSH 로 EC2 에 접속하여 EC2 에서 docker hub 에 올린 이미지를 pull 받아 실행한다.

이때, EC2 에서 실행에 필요한 도커 컨테이너들을 docker-compose 를 활용하여 실행하므로 미리 EC2 에 docker 와 docker-compose 를 설치해놓았다.

또, 현재 EC2 에는 도커 엔진에 대한 접근 권한이 없으므로 권한을 설정해줘야한다. 권한을 추가할 그룹을 생성해주고, 그룹 안에 사용자(ubuntu) 를 추가해주었다.

sudo groupadd docker
sudo usermod -aG docker $USER

 

구현 코드

Dockerfile

빌드한 프로젝트 파일을 감싸는 이미지를 만들어야하는데, 이 Dockerfile 을 기반으로 만든다. 한번 Dockerfile 의 내용을 살펴보자.

FROM eclipse-temurin:17
ARG JAR_FILE=build/libs/seat-view-reviews-0.0.1-SNAPSHOT.jar
ENV DB_URL=${DB_URL} \
    DB_USERNAME=${DB_USERNAME} \
    DB_PASSWORD=${DB_PASSWORD} \
    ACCESS_KEY=${ACCESS_KEY} \
    SECRET_KEY=${SECRET_KEY}
COPY ${JAR_FILE} seat-view-reviews.jar
ENTRYPOINT ["nohup", "java", "-jar", "seat-view-reviews.jar", ">", "/dev/null", "2>", "/dev/null", "&"]
  • FROM
    • 도커 이미지의 베이스 이미지를 지정한다.
    • 도커 이미지는 베이스 이미지를 바탕으로, 새로운 이미지를 쌓아가면서 만들어진다.
    • 프로젝트가 Java 17 로 작성되었으므로 베이스 이미지로 사용하였다.
  • ARG
    • 도커 이미지 빌드 시에 사용되는 인자를 정의한다.
    • 여기서는 프로젝트 빌드 파일을 가리키고 있다.
  • ENV
    • docker run 시에 사용되는 환경변수를 정의한다.
    • 도커 이미지에 외부에서 알면 안되는 환경변수가 포함되면 안된다. 포함되게 되면 이미지를 받은 누구나 그 환경변수를 알게된다. 그러므로 외부에서 환경변수를 주입하도록 해주었다.
  • COPY
    • 호스트의 디렉토리를 도커 이미지 파일 시스템으로 복사한다.
    • 아까 위에서 ARG 로 정의했던 jar 파일을 복사하였다.
  • ENTRYPOINT
    • 해당 도커 이미지를 바탕으로 컨테이너가 실행될 때 항상 실행될 명령어를 정의한다.
    • 이 명령어로 실행된 프로세스가 종료되면 컨테이너도 종료된다.
    • jar 파일 실행 시 프로세스를 백그라운드로 유지하고 표준 출력 및 표준 오류를 무시하도록 하였다.

 

docker-compose.yml

version: '3'
services:
  db:
    container_name: seat-view-db
    image: mysql:8.0.32
    ports:
      - "3306:3306"
    env_file:
      - ../env/docker-compose.env
    environment:
      TZ: Asia/Seoul
    volumes:
      - seat-view:/var/lib/mysql
      - ./my.cnf:/etc/mysql/my.cnf
      - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql
    command: bash -c "chmod 644 /etc/mysql/my.cnf && docker-entrypoint.sh mysqld"
    restart: on-failure

  seat-view-jar:
    container_name: seat-view-jar
    image: jeromeeugenemorrow/seat-view-reviews:latest
    ports:
      - "8080:8080"
    env_file:
      - ../env/dev-db.env
      - ../env/aws.env
    environment:
      TZ: Asia/Seoul
    depends_on:
      - db
    restart: on-failure

volumes:
  seat-view:

seat-view-db 컨테이너와 관련된 내용이나 기본적인 docker-compose 활용에 대한 내용은 이 글을 참고하자. 2번째 컨테이너인 seat-view-jar 에 대해서 설명하겠다.

  • seat-view-jar 컨테이너는 위에서 Dockerfile 을 바탕으로 만든 도커 이미지(jeromeeugenemorrow/seat-view-reviews:latest)로 실행되는 컨테이너이다. 컨테이너가 실행될 때, 명시해준 이미지가 로컬 머신에 없다면 pull 받아서 실행한다.
  • 앞서 Dockerfile 에서 도커 이미지를 만들 때 이미지에 중요한 환경변수가 들어가면 안되니 외부에서 주입해준다고 하였었다. docker-compose 를 통해 컨테이너들을 실행할 때, 환경변수를 넣어주도록 하였다.
  • saet-view-jar 컨테이너는 MySQL 컨테이너가 실행되고 난 후에 실행되어야한다. 그러므로 depends_on 을 통해 명시해주었다. depends_on 은 실행 시작 순서는 보장하지만 완료의 순서를 보장하지는 않는다. 완료의 순서를 꼭 보장해야한다면 healthcheck 옵션을 이용하면된다. 나는 restart 옵션을 always 로 해주어 순서로 인해 오류가 생기는 것을 방지하였다.
  • 이 docker-compose 에 정의된 컨테이너들을 실행하기 위해서 필요한 파일들이 있다. env 파일이라던가 각종 sql 파일들이 그렇다. 그러므로 docker-compose 가 실행되는 서버에 꼭 그 파일들이 미리 있어야한다.

 

ci.yml

name: CI

on:
  pull_request:
    branches:
      - develop

jobs:
  build:
    runs-on: ubuntu-22.04

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

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

      - name: Generate environment file
        run: |
          mkdir -p env
          echo "${{secrets.DOCKER_COMPOSE_ENV}}" >> env/docker-compose.env
          echo "${{secrets.AWS_ENV}}" >> env/aws.env
          echo "${{secrets.DB_ENV}}" >> env/dev-db.env

      - name: Run docker DB container
        run: docker-compose -f ./docker/docker-compose.yml up -d db

      - name: Get execution permission to gradlew
        run: chmod +x ./gradlew

      - name: Build with Gradle
        run: ./gradlew clean build

CI 스크립트의 경우 CD 와 겹치기에 CD 에서 한번에 설명하겠다.

 

cd.yml

name: CD

on:
  push:
    branches:
      - develop

jobs:
  build:
    runs-on: ubuntu-22.04

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

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

      - name: Generate environment file
        run: |
          mkdir -p env
          echo "${{secrets.DOCKER_COMPOSE_ENV}}" >> env/docker-compose.env
          echo "${{secrets.AWS_ENV}}" >> env/aws.env
          echo "${{secrets.DB_ENV}}" >> env/dev-db.env

      - name: Run docker DB container
        run: docker-compose -f ./docker/docker-compose.yml up -d db

      - name: Get execution permission to gradlew
        run: chmod +x ./gradlew

      - name: Build with Gradle
        run: ./gradlew clean build

      - name: Build and push docker image
        run: |
          docker login -u ${{secrets.DOCKERHUB_USERNAME}} -p ${{secrets.DOCKERHUB_PASSWORD}}
          docker build -t ${{secrets.DOCKERHUB_USERNAME}}/seat-view-reviews:latest .
          docker push ${{secrets.DOCKERHUB_USERNAME}}/seat-view-reviews:latest

      - name: Get github actions public IP
        id: ip
        uses: haythem/public-ip@v1.3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{secrets.AWS_ACCESS_KEY}}
          aws-secret-access-key: ${{secrets.AWS_SECRET_KEY}}
          aws-region: ap-northeast-2

      - name: Add github actions IP to security group
        run: |
          aws ec2 authorize-security-group-ingress --group-id ${{secrets.AWS_SECURITY_GROUP_ID}} --protocol tcp --port 22 --cidr ${{steps.ip.outputs.ipv4}}/32    

      - name: Send necessary files to EC2
        uses: appleboy/scp-action@master
        with:
          host: ${{secrets.EC2_HOST}}
          username: ${{secrets.EC2_USERNAME}}
          key: ${{secrets.EC2_KEY}}
          port: ${{secrets.EC2_PORT}}
          source: "env/*,docker/*"
          strip_components: 0
          target: "~"

      - name: Access AWS EC2 and run the app
        uses: appleboy/ssh-action@master
        with:
          host: ${{secrets.EC2_HOST}}
          username: ${{secrets.EC2_USERNAME}}
          key: ${{secrets.EC2_KEY}}
          port: ${{secrets.EC2_PORT}}
          script: |
            docker-compose -f ./docker/docker-compose.yml stop
            docker-compose -f ./docker/docker-compose.yml pull
            docker-compose -f ./docker/docker-compose.yml up -d

      - name: Remove github actions IP from security group
        run: |
          aws ec2 revoke-security-group-ingress --group-id ${{secrets.AWS_SECURITY_GROUP_ID}} --protocol tcp --port 22 --cidr ${{steps.ip.outputs.ipv4}}/32

Github actions 에서 CD 를 위한 스크립트이다. 각 단계 별로 한번 살펴보자. name 별로 구분하여 살펴볼 것이다.

Checkout

  • Github 에 올라간 코드를 Github actions 서버로 내려 받고 작업 브랜치로 이동하는 작업을 의미한다.

 

Set up JDK 17

  • 프로젝트가 Java 17 로 작성되었으니 빌드 전에 설치해줘야한다.

 

Generate environment file

  • 프로젝트에 필요한 환경변수들을 env 파일로 관리하고 있다.
  • 실행 시에 필요한데 Github 소스에는 보안을 위해 올라가있지 않다. env 파일을 Github actions Secrets 을 바탕으로 Github actions 서버 내에서 만들어주었다.

 

Run docker DB container

  • 빌드 전에 DB 컨테이너를 띄워서 빌드 과정에서의 테스트가 실패하지 않도록 하였다.

 

Get execution permission to gradlew / Build with Gradle

  • gradlew 명령어에 대한 권한을 얻고, 빌드를 하였다.

 

Build and push docker image

  • docker 이미지를 빌드하고, docker hub 에 빌드된 이미지를 푸쉬하였다.
  • 이 때, docker hub 의 username 과 password 는 github actions secrets 를 이용해 관리하였다.

 

Get github actions public IP

  • 현재 Github actions 실행 환경에서의 IP 를 얻는다.

 

Configure AWS credentials

  • AWS 인증 정보를 추가해준다.

 

Add github actions IP to security group

  • 현재 EC2 에 접속 가능한 IP 를 제한하고 있는 상태이므로 보안 그룹에 Github actions 의 IP 를 추가해준다.

 

Send necessary files to EC2

  • 아까 docker-compose.yml 을 설명할 때, 컨테이너들을 실행하기 위해서 필요한 파일들이 실행되는 서버에 미리 존재해야한다고 설명하였다. 그러므로, EC2 에서 docker-compose 를 실행하기위해 필요한 파일들을 Github actions 서버에서 복사하였다.
  • 직접 scp 명령어를 사용하여 파일 또는 폴더를 전송하려면 EC2 접속 password 를 입력해야한다. password 를 입력하지 않으려면 EC2 에 키 파일을 업로드해야한다. 이런 별도의 과정을 워크플로우에 추가하기보다는 appleboy/scp-action@master 를 사용하였다.

 

Access AWS EC2 and run the app

  • SSH 를 통하여 EC2 에 접속하고, docker-compose 파일을 실행한다.

 

Remove github actions IP from security group

  • 이전에 추가한 Github actions 의 IP 를 보안 그룹에서 제거한다.