본문 바로가기
Projects/OpenRoadmaps

[OpenRoadmaps] Github Actions를 통한 CI/CD 구축하기

by DevJaewoo 2023. 3. 8.
반응형

테스트 코드와 AWS를 통한 배포환경이 준비되었으니 이제 CI/CD를 구축할 수 있다.

 

CI/CD의 간단한 정의는 다음과 같다.

CI (Continuous Integration): 레포지트로의 코드가 변경될 때, 자동으로 빌드와 테스트를 해준다.

CD (Continuous Deployment): CI를 통과한 변경사항을 적용하여 자동으로 배포한다.

 

Github Actions에서는 push, pull request 등 다양한 변경사항과 cron 등의 스케줄에 대해 테스트, 빌드 배포 등의 workflow 자동화를 지원한다. 이를 사용해 CI/CD를 적용했고, 이에 대해 정리해보려 한다.

 

전체 workflow 파일은 Github에서 확인할 수 있다.

 

GitHub - DevJaewoo/OpenRoadmaps

Contribute to DevJaewoo/OpenRoadmaps development by creating an account on GitHub.

github.com


Backend CI 적용

백엔드의 경우 gradle build만 실행하도록 구성하면 된다. gradle build 실행 시 빌드 및 테스트가 함께 진행된다.

전체 코드는 다음과 같고, 작성한 workflow 코드를 하나씩 설명하도록 하겠다.

name: CI Test Backend

on:
  pull_request:
    branches: ["main"]

permissions:
  contents: read

jobs:
  build-and-test:
    # main에 push하거나, Backend branch에서 pull_request를 요청했을 경우
    if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip backend]') && (github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.head_ref, '/be/'))) }}

    runs-on: ubuntu-22.04

    services:
      redis:
        image: redis:latest
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v3

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

      - name: Add executable permission to gradlew
        run: chmod +x ./backend/gradlew

      - name: Build with Gradle
        uses: gradle/gradle-build-action@v2
        with:
          arguments: build
          build-root-directory: ./backend

 

name: CI Test Backend

on:
  pull_request:
    branches: ["main"]

 

 

name: Actions에 보여지게 될 workflow의 이름을 설정할 수 있다.

 

on: workflow가 언제 실행될지 정의한다. 위와 같이 구성하는 경우, main branch에 pull request가 들어왔을 때 workflow가 실행된다. pull request 이외에도 push, fork, issue 등 다양한 이벤트를 설정할 수 있다.

자세한 내용은 공식 문서를 참고하면 좋을것 같다.

https://docs.github.com/ko/actions/using-workflows/workflow-syntax-for-github-actions#on

 

Workflow syntax for GitHub Actions - GitHub Docs

 

docs.github.com


...

jobs:
  build-and-test:
    if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip backend]') && (github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.head_ref, '/be/'))) }}

    runs-on: ubuntu-22.04

    services:
      redis:
        image: redis:latest
        ports:
          - 6379:6379

jobs: workflow에서 수행할 job들을 정의한다. job의 이름은 자유롭게 지을 수 있고, 이후 workflow 실행 시 이름이 출력된다.

 

if: if문을 수행한 결과가 true일때만 job을 실행한다. backend 브랜치로부터 PR이 오거나, main에 push가 됐을 경우 job을 실행하고, 커밋 메세지에 [skip ci] 또는 [skip backend]가 포함되어있을 경우 실행하지 않도록 구성했다.

 

runs-on: job을 실행할 docker 이미지를 정의한다. ubuntu-22.04 컨테이너에서 수행하도록 했다. 처음엔 latest로 했지만, 이후 latest에 새로운 이미지가 할당될 때마다 CI 환경이 달라지기 때문에 버전을 명시해줬다.

 

services: 세션을 테스트하기 위해 Redis 서비스가 필요하여, redis 도커 이미지를 실행시키도록 설정했다.


...

jobs:
  build-and-test:
    ...
    
    steps:
      - uses: actions/checkout@v3

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

      - name: Add executable permission to gradlew
        run: chmod +x ./backend/gradlew

      - name: Build with Gradle
        uses: gradle/gradle-build-action@v2
        with:
          arguments: build
          build-root-directory: ./backend

steps: job 내에서 실행할 작업을 나열한다. 위에서 아래 방향으로 작업들을 실행하고, 모든 작업 완료 시 실행된 작업의 역순으로 Post 작업이 실행된다.

 

name: 작업의 이름을 지정한다. 이후 Job이 실행될 때 name에 지정한 이름으로 작업의 이름이 표시된다.

 

uses: 다른 사람이 만들어두거나 내가 직접 만든 action을 불러와 사용한다. checkout의 경우 현재 레포지토리를 도커 환경에 복사해오는 역할을 하고, setup-java의 경우 도커 환경에 Java JDK 환경을 구성해주는 역할을 한다.

 

run: 도커 환경에 명령어를 실행한다.

 

따라서 위 작업의 진행 순서는 다음과 같다.

  1. 코드를 도커 환경에 복사
  2. Java 17 (Corretto) 환경 구성
  3. .gradlew 파일에 execute 권한 추가 (원래는 execute 권한이 없어 실행 시 오류가 난다.)
  4. backend 디렉토리에서 gradle build 실행 (빌드 시 테스트도 함께 진행된다.)

 

작업 실행이 완료되고 로그를 확인하면 아래와 같이 테스트 / 빌드 모두 성공적으로 끝난것을 확인할 수 있다.

Starting a Gradle Daemon (subsequent builds will be faster)
> Task :initQuerydslSourcesDir
> Task :compileQuerydsl
> Task :compileJava
> Task :processResources
> Task :classes
> Task :bootJarMainClassName
> Task :bootJar
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :processTestResources
> Task :testClasses

> Task :test
2023-02-06 11:31:26.893  INFO 2187 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2023-02-06 11:31:26.902  INFO 2187 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2023-02-06 11:31:26.911  INFO 2187 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.
2023-02-06 11:31:26.913  INFO 2187 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2023-02-06 11:31:26.930  INFO 2187 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2023-02-06 11:31:27.033  INFO 2187 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-2 - Shutdown initiated...
2023-02-06 11:31:27.034  INFO 2187 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-2 - Shutdown completed.

> Task :check
> Task :build

Frontend CI 적용

프론트엔드의 경우 ESLint 체크만 하도록 구성하면 된다.

 

name: CI Test Frontend

on:
  pull_request:
    branches: ["main"]

jobs:
  build-and-test:
    # main에 push하거나, Frontend branch에서 pull_request를 요청했을 경우
    if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip frontend]') && (github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.head_ref, '/fe/'))) }}

    runs-on: ubuntu-22.04

    defaults:
      run:
        working-directory: ./frontend

    strategy:
      matrix:
        node-version: [18.x]

    steps:
      - uses: actions/checkout@v3

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      - name: Cache node modules
        id: node-cache
        uses: actions/cache@v3
        env:
          cache-name: cache-node-modules
        with:
          # npm cache files are stored in `~/.npm` on Linux/macOS
          path: "**/node_modules"
          key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-modules-
      - name: Install NPM Modules
        if: steps.node-cache.outputs.cache-hit != 'true'
        run: npm install

      - name: Run ESLint
        run: npx eslint src --ext .js,.jsx,.ts,.tsx

frontend 브랜치로부터 PR이 오거나, main에 push가 됐을 경우 job을 실행하고, 커밋 메세지에 [skip ci] 또는 [skip frontend]가 포함되어있을 경우 실행하지 않도록 구성했다.

 

또한 npm install을 매번 수행하기엔 시간이 너무 오래걸려, node_modules 폴더를 캐시에 저장하고 불러오도록 설정했다.

캐시를 적용하면 package-lock.json에 변화가 생기지 않는 이상 이전에 설치한 node_modules 폴더를 그대로 재사용하기 때문에 시간을 훨씬 단축할 수 있다.

 

아래 작업 로그를 보면 Install NPM modules 작업이 skip된것을 확인할 수 있다.


Backend CD 적용

CI를 통해 테스트를 통과한 코드만 통합되도록 설정했고, 이에 대한 배포 workflow를 작성해야 한다.

코드가 좀 긴데, 중복되는 내용은 빼고 정리하도록 하겠다.

name: Deploy development server backend

on:
  push:
    branches: ["main"]
  workflow_dispatch:
    inputs:
      logLevel:
        description: "Log level"
        required: true
        default: "warning"
      tags:
        description: "Tags"

jobs:
  build-and-push:
    if: ${{ !contains(github.event.head_commit.message, '[skip cd]') && !contains(github.event.head_commit.message, '[skip backend]') }}
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

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

      - name: Add executable permission to gradlew
        run: chmod +x ./backend/gradlew

      - name: Build with Gradle
        uses: gradle/gradle-build-action@v2
        with:
          arguments: build -x test
          build-root-directory: ./backend

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v3
        with:
          push: true
          context: ./backend
          build-args: SPRING_PROFILES_ACTIVE="prod"
          tags: devjaewoo/openroadmaps-develop:latest

  deploy:
    runs-on: ubuntu-latest
    needs: [build-and-push]
    steps:
      - name: Get Github action IP
        id: ip
        uses: haythem/public-ip@v1.2

      - name: Add Github Actions IP to Security group
        run: |
          aws ec2 authorize-security-group-ingress --group-name ${{ secrets.AWS_SG_NAME_DEVELOP }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_DEVELOP }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_SECRET_DEVELOP }}
          AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}

      - name: Deploy to AWS
        uses: appleboy/ssh-action@master
        with:
          username: ${{ secrets.AWS_SSH_USERNAME_DEVELOP }}
          host: ${{ secrets.AWS_SSH_HOST_DEVELOP }}
          key: ${{ secrets.AWS_SSH_KEY_DEVELOP }}
          script: |
            sudo docker compose pull
            sudo docker compose up --force-recreate --build -d
            sudo docker image prune -f
      - name: Remove Github Actions IP from security group
        run: |
          aws ec2 revoke-security-group-ingress --group-name ${{ secrets.AWS_SG_NAME_DEVELOP }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_DEVELOP }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_SECRET_DEVELOP }}
          AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
        if: always()

on:
  push:
    branches: ["main"]
  workflow_dispatch:
    inputs:
      logLevel:
        description: "Log level"
        required: true
        default: "warning"
      tags:
        description: "Tags"

on push: pull_request와 달리 push로 바뀌었다. main 브랜치에 push 하거나, main으로 온 PR을 merge할 때도 작동한다.

workflow_dispatch: 이벤트 없이 원할 때 workflow를 바로 실행할 수 있도록 해준다. 이 트리거가 적용된 workflow는 아래와 같이 Run workflow 버튼이 생기고, 이를 통해 수동으로 실행할 수 있다.


jobs:
  build-and-push:
    if: ${{ !contains(github.event.head_commit.message, '[skip cd]') && !contains(github.event.head_commit.message, '[skip backend]') }}
    runs-on: ubuntu-latest

    steps:
      ...

      - name: Build with Gradle
        uses: gradle/gradle-build-action@v2
        with:
          arguments: build -x test
          build-root-directory: ./backend

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v3
        with:
          push: true
          context: ./backend
          build-args: SPRING_PROFILES_ACTIVE="prod"
          tags: devjaewoo/openroadmaps-develop:latest

 

Build with gradle: argument에 "-x test"가 추가됐다. CI 단계에서 이미 테스트를 진행했기 때문에, 배포 빌드 시 테스트는 제외하도록 했다.

 

Set up QEMU ~ Build and push: Docker Hub에 이미지를 빌드하고 push 한다. 아래 공식 문서를 참고하여 작성했다.

https://github.com/marketplace/actions/build-and-push-docker-images#git-context

 

Build and push Docker images - GitHub Marketplace

Build and push Docker images with Buildx

github.com

 

코드 중 "${{ secrets.DOCKERHUB_USERNAME }}"와 같은 변수가 있다.

[Settings] - [Secrets and variables] - [Actions]에 Github Actions에서 사용할 변수를 등록할 수 있는데, 이렇게 등록된 변수는 타인에게 노출되지 않아 더 안전하게 workflow를 돌릴 수 있다.

 

workflow를 돌리면 Docker Hub에 이미지가 배포된다.

https://hub.docker.com/repository/docker/devjaewoo/openroadmaps-develop/general

 

Docker

 

hub.docker.com


이후 deploy job에서 AWS CLI에 연결하고, Docker Hub에 업데이트된 이미지를 받아와 배포하도록 구성했다.

jobs:
  deploy:
    runs-on: ubuntu-latest
    needs: [build-and-push]
    steps:
      - name: Get Github action IP
        id: ip
        uses: haythem/public-ip@v1.2

      - name: Add Github Actions IP to Security group
        run: |
          aws ec2 authorize-security-group-ingress --group-name ${{ secrets.AWS_SG_NAME_DEVELOP }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_DEVELOP }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_SECRET_DEVELOP }}
          AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}

      - name: Deploy to AWS
        uses: appleboy/ssh-action@master
        with:
          username: ${{ secrets.AWS_SSH_USERNAME_DEVELOP }}
          host: ${{ secrets.AWS_SSH_HOST_DEVELOP }}
          key: ${{ secrets.AWS_SSH_KEY_DEVELOP }}
          script: |
            sudo docker compose pull
            sudo docker compose up --force-recreate --build -d
            sudo docker image prune -f
      - name: Remove Github Actions IP from security group
        run: |
          aws ec2 revoke-security-group-ingress --group-name ${{ secrets.AWS_SG_NAME_DEVELOP }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_DEVELOP }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_SECRET_DEVELOP }}
          AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
        if: always()

 

AWS에는 허용된 IP만 접근할 수 있게 해주는 보안 그룹 설정이 있기 때문에, Github Actions를 통해 접근하려 할 경우 보안그룹에 막혀 접근이 되지 않는다. 때문에 현재 IP를 받아와 AWS의 보안그룹에 추가해줘야 정상적으로 명령을 실행할 수 있다.

 

Get Github action IP: 보안그룹에 현재 IP를 추가하기 위해 IP를 받아온다.

Add / Remove Github Actions IP to Security group: 보안 그룹에 현재 IP에 대해 SSH 연결을 허용 / 불허한다.

Deploy to AWS: Docker Hub의 업데이트된 이미지를 받아와 다시 배포한다.

 

위 작업들을 수행해 AWS 환경에 자동으로 배포시킬 수 있었다.


Frontend CD 적용

Frontend CD는 CI와 크게 다르지 않다. ESLint 체크가 제거되고, Build와 배포가 추가되었다.

name: Deploy development server frontend

on:
  push:
    branches: ["main"]
  workflow_dispatch:
    inputs:
      logLevel:
        description: "Log level"
        required: true
        default: "warning"
      tags:
        description: "Tags"

jobs:
  build-and-deploy:
    if: ${{ !contains(github.event.head_commit.message, '[skip cd]') && !contains(github.event.head_commit.message, '[skip frontend]') }}
    runs-on: ubuntu-latest

    defaults:
      run:
        working-directory: ./frontend

    strategy:
      matrix:
        node-version: [18.x]

    steps:
      - uses: actions/checkout@v3

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}

      - name: Cache node modules
        id: node-cache
        uses: actions/cache@v3
        env:
          cache-name: cache-node-modules
        with:
          # npm cache files are stored in `~/.npm` on Linux/macOS
          path: "**/node_modules"
          key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-modules-
      - name: Install NPM Modules
        if: steps.node-cache.outputs.cache-hit != 'true'
        run: npm install

      - name: Build
        run: npm run build

      - name: Deploy
        env:
          AWS_EC2_METADATA_DISABLED: true
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_DEVELOP }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_SECRET_DEVELOP }}
        run: |
          aws s3 sync --region ${{ secrets.AWS_DEFAULT_REGION }} ./build s3://openroadmaps-develop-s3

jobs:
  build-and-deploy:
    defaults:
      run:
        working-directory: ./frontend

    steps:
      ...

      - name: Build
        run: npm run build

      - name: Deploy
        env:
          AWS_EC2_METADATA_DISABLED: true
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_DEVELOP }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_SECRET_DEVELOP }}
        run: |
          aws s3 sync --region ${{ secrets.AWS_DEFAULT_REGION }} ./build s3://openroadmaps-develop-s3

defaults - run - working-directory: 모든 step들의 run에 대해 ./frontend 디렉토리에서 명령어를 수행하도록 한다.

Deploy: aws s3 sync 명령어를 통해 빌드 결과물을 s3 버킷에 업로드한다.


이렇게 Frontend, Backend 모두 CI/CD를 적용시켰다.

아마 이번 글이 이 프로젝트의 마지막 글이 될 것 같다.

결과적으로 졸업작품은 A+를 받았다.

반응형