WebアプリケーションのCI/CD

アプリケーションエンジニア 難波 アプリケーションエンジニア 難波

こんにちは、アプリケーションエンジニアの難波です。
最近は主にサーバサイドを担当しています。

Webアプリケーションの改修を続けるにはCI/CDが重要になってきます。
Braveridgeでは以下の構成でWebアプリケーション開発とCI/CDを行っています。

・Webアプリ
 ・Kotlin
 ・SpringBoot2
・テスト
 ・JUnit5
・CI/CD
 ・GitHub Actions (self-hosted runners, macOS)
・デプロイ
 ・AWS CodeDeploy
・リリース管理
 ・Github Releases

今回はGitHub ActionsをベースにしたCI/CDを紹介します。

基本的なフローは以下の通りです。

  1. pushされたらテストを実行
  2. mainブランチにマージされたら開発サーバにデプロイ
  3. releaseブランチにマージされたら本番サーバにデプロイ & リポジトリにReleaseを作成

準備

GitHub Actions Runnerのインストール

repositoryにpushするたびにテストを実行したいので、無制限に使用できる self-hosted runner を用意します。
社内に使用していない古いMacbookPro2013があったので、これにGitHub Actions Runnerをインストールします。
当初、DockerやVirtualBoxでUbuntuをホスティングしてrunnerをインストールしようと思いましたが、Dockerではrunnerの実行時にエラーが発生し、VirtualBoxでは動作が遅かったので、macOSに直接runnerをインストールすることにしました。

1. Docker Desktop for Mac をインストール
2. brewでAWS CLIをインストール
 ※brewはインストール済み
3. runnerのインストール
 GitHub repository (または organization) の Settings タブをクリックして Actions -> Self-hosted runners -> Add new -> New runner でインストールスクリプトを表示します。


mkdir actions-runner && cd actions-runner
curl -o actions-runner-osx-x64-x.x.x.tar.gz -L https://github.com/actions/runner/releases/download/vx.x.x/actions-runner-osx-x64-x.x.x.tar.gz
tar xzf ./actions-runner-osx-x64-x.x.x.tar.gz

./config.sh --url https://github.com/repo --token yourToken
./run.sh



※タグに MacBookPro2013 を付けています。

ファイルの準備

SpringBootのWebアプリのソースコードの他に以下のファイルを用意します。


.github/workflows/mac.yml
.codedeploy/
  appspec.yml
  scripts/
    1_application_stop.sh
    2_before_install.sh
    3_application_start.sh
    4_validate_service.sh
version.txt



※上記ファイルの内容は後ほど説明します。

ワークフロー

テスト

pushのたびにテスト (./gradlew test) を実行します。


name: Test

on:
  push:
    paths-ignore:
      - '**.md'
      - '**.txt'

jobs:
  test:
    name: Test
    runs-on: [self-hosted, MacBookPro2013]

    steps:
      - name: Checkout sources
        uses: actions/checkout@v2

      - name: Setup Java 11
        uses: joschi/setup-jdk@v2
        with:
          java-version: '11'

      - name: Test
        uses: eskatos/gradle-command-action@v1
        with:
          wrapper-cache-enabled: true
          dependencies-cache-enabled: true
          configuration-cache-enabled: true
          arguments: test


開発サーバへのデプロイ

AWSのCodeDeployを使用します。
予め、CodeDeployにアプリケーションとデプロイグループ(develop)を作成します。

デプロイの流れは以下の通りです。

  1. mainブランチにpush (or PR merge)
  2. ビルド(./gradlew bootJar)
  3. S3にアップロード
  4. CodeDeployを実行
  5. 開発サーバにデプロイ

※AWS CLIで使用するKEYはGitHub repositoryのAction secrets に登録しています。 (AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY)


# 前半省略

env:
  REGION: ap-northeast-1
  CODEDEPLOY_APP: codedeploy_app_name
  S3_BACKET: io.braveridge.app.code-deploy
  APP_NAME_PREFIX: app-
  TEAMS_WEBHOOK: teams_webhook_url 

jobs:
  deploy:
    if: github.ref == 'refs/heads/main'
    name: Deploy
    needs: test
    runs-on: [self-hosted, MacBookPro2013]

    steps:
      - name: Checkout sources
        uses: actions/checkout@v2

      - name: Set Version
        run: |
          VER=$(head -n 1 version.txt)
          echo "APP_NAME=$APP_NAME_PREFIX$VER" >> $GITHUB_ENV

      - name: Setup Java 11
        uses: joschi/setup-jdk@v2
        with:
          java-version: '11'

      - name: Build
        uses: eskatos/gradle-command-action@v1
        with:
          wrapper-cache-enabled: true
          dependencies-cache-enabled: true
          configuration-cache-enabled: true
          arguments: bootJar

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.REGION }}

      - name: Prepare CodeDeploy
        run: |
          mkdir deploy
          cp projects/web/build/libs/*.jar ./deploy
          cp -R .codedeploy/* ./deploy
          sed -i '' 's/APP_NAME/${{ env.APP_NAME }}/' ./deploy/scripts/3_application_start.sh
          aws deploy push --application-name ${{ env.CODEDEPLOY_APP }} --s3-location s3://${{ env.S3_BACKET }}/${{ env.APP_NAME }}.zip --source deploy

      - name: CodeDeploy for dev
        run: |
          aws deploy create-deployment --ignore-application-stop-failures --application-name ${{ env.CODEDEPLOY_APP }} --deployment-config-name CodeDeployDefault.OneAtATime --deployment-group-name develop --s3-location bucket=${{ env.S3_BACKET }},bundleType=zip,key=${{ env.APP_NAME }}.zip>



version.txt には x.y.z-SNAPSHOT が記載されていて、
env.APP_NAME = app-x.y.z-SNAPSHOT
と展開されます。

弊社ではALB -> EC2 という構成にしているので appspec.yml でロードバランシングしています。
以下にCodeDeployで実行しているスクリプトを記載します。


version: 0.0
os: linux
files:
  - source: /
    destination: /var/www/app.braveridge.io
hooks:
  ApplicationStop:
    - location: scripts/1_application_stop.sh
      timeout: 30
      runas: root
  BeforeInstall:
    - location: scripts/2_before_install.sh
      timeout: 30
      runas: root
  ApplicationStart:
    - location: scripts/3_application_start.sh
      timeout: 30
      runas: root
  ValidateService:
    - location: scripts/4_validate_service.sh
      timeout: 300
      runas: root




#!/bin/bash

curl -s -X POST http://localhost:8081/actuator/shutdown

MAX=10
for ((i=0; i < $MAX; i++)); do
  stat=$(curl -s http://localhost:8081/actuator/health)
  if [[ ${stat} == '' ]] ; then
    echo "successfully finished the app"
    exit 0
  fi

  echo "waiting for shutdown app gracefully"
  sleep 1
done

APPLICATION_ROOT_DIR="/var/www/app.braveridge.io"
PID_FILE="${APPLICATION_ROOT_DIR}/pid.file"

if [ -f ${PID_FILE} ]; then
    kill $(cat ${PID_FILE})
fi




#!/bin/bash

APPLICATION_ROOT_DIR="/var/www/app.braveridge.io"
rm -f ${APPLICATION_ROOT_DIR}/*.jar




#!/bin/bash

APPLICATION_ROOT_DIR="/var/www/app.braveridge.io"
APP_JAR="${APPLICATION_ROOT_DIR}/APP_NAME.jar"
PID_FILE="${APPLICATION_ROOT_DIR}/pid.file"

INSTANCE_ID=`curl -s http://169.254.169.254/latest/meta-data/instance-id`
SERVER_ROLE=`aws ec2 describe-instances --instance-ids ${INSTANCE_ID} --output text --query 'Reservations[].Instances[].{Name:Tags[?Key==\`ServerRole\`].Value}'`
PROFILE="release"
if [[ $SERVER_ROLE == *develop* ]]; then PROFILE="develop"; fi

java -Xms256m -Xmx384m -jar -Dspring.profiles.active=${PROFILE} ${APP_JAR} > /dev/null 2>&1 & echo $! > ${PID_FILE} &





#!/bin/bash

while :; do
  stat=$(curl -s http://localhost:8081/actuator/health)
  if [[ ${stat} == '{"status":"UP"}' ]] ; then
     exit 0
  fi

  echo "waiting for starting app"
  sleep 5
done


ポイントは 4_validate_service.sh で、SpringBootFatJarは起動に時間がかかるので、actuatorを監視することで起動するまで待機する点です。

本番サーバへのデプロイとリリース

予め、CodeDeployにデプロイグループ(release)を作成します。
デプロイの流れは開発サーバへのデプロイとほぼ同じで、リリース作成を追加します。

  1. releaseブランチにpush
  2. リリースバージョン生成(x.y.z-SNAPSNNOT -> x.y.z)
  3. TagとReleaseを作成(vx.y.z)
  4. ビルド(./gradlew bootJar)
  5. Teamsに通知
  6. S3にアップロード
  7. CodeDeployを実行
  8. 本番サーバにデプロイ
  9. バージョンアップして mainブランチにpush (x.y.z-SNAPSHOT -> x.[y+1].z-SNAPSHOT)

#前半省略

  deploy:
    if: github.ref == 'refs/heads/release'
    name: Deploy
    needs: test
    runs-on: [self-hosted, server, MacBookPro2013, direct]

    steps:
      - name: Checkout sources
        uses: actions/checkout@v2

      - name: Generate Version
        uses: HardNorth/github-version-generate@v1.1.0
        with:
          version-source: file
          version-file: version.txt
          next-version-increment-minor: true

      - name: Create Tag
        run: |
          echo ${{ env.RELEASE_VERSION }} > version.txt
          git commit -m "version to ${{ env.RELEASE_VERSION }}" -a
          TAG="v${{ env.RELEASE_VERSION }}"
          echo "TAG_NAME=$TAG" >> $GITHUB_ENV
          git tag $TAG
          git push origin $TAG

      - name: Create Release
        uses: softprops/action-gh-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ env.TAG_NAME }}

      - name: Set Version
        run: |
          VER=$(head -n 1 version.txt)
          echo "APP_NAME=$APP_NAME_PREFIX$VER" >> $GITHUB_ENV

      - name: Setup Java 11
        uses: joschi/setup-jdk@v2
        with:
          java-version: '11'

      - name: Build
        uses: eskatos/gradle-command-action@v1
        with:
          wrapper-cache-enabled: true
          dependencies-cache-enabled: true
          configuration-cache-enabled: true
          arguments: bootJar

      - name: Notify to teams
        uses: luisghz/simple-ms-teams-webhook-notifier@v1-latest
        with:
          webhook_url: ${{ env.TEAMS_WEBHOOK }}
          summary: Start to deploy ${{ env.APP_NAME }}
          title: Start to deploy ${{ env.APP_NAME }}
          theme-color: 39A3E3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.REGION }}

      - name: Prepare CodeDeploy
        run: |
          mkdir deploy
          cp projects/web/build/libs/*.jar ./deploy
          cp -R .codedeploy/* ./deploy
          sed -i '' 's/APP_NAME/${{ env.APP_NAME }}/' ./deploy/scripts/3_application_start.sh
          aws deploy push --application-name ${{ env.CODEDEPLOY_APP }} --s3-location s3://${{ env.S3_BACKET }}/${{ env.APP_NAME }}.zip --source deploy

      - name: CodeDeploy for release
        run: |
          aws deploy create-deployment --ignore-application-stop-failures --application-name ${{ env.CODEDEPLOY_APP }} --deployment-config-name CodeDeployDefault.OneAtATime --deployment-group-name release --s3-location bucket=${{ env.S3_BACKET }},bundleType=zip,key=${{ env.APP_NAME }}.zip

      - name: Version up
        run: |
          git checkout main
          git pull origin main
          echo ${{ env.NEXT_VERSION }} > version.txt
          git commit -m "version to ${{ env.NEXT_VERSION }}" -a
          git push origin main


ワークフローの最後のステップの version.txtをmainブランチにpushする のはやりすぎかと思いましたが、ルーチンワークをできるだけ自動化することが重要だと考え、リリースフローに追加しています。

最後に mac.yml の全体を記載します。

ワークフローファイル


name: Test and Deploy

on:
  push:
    paths-ignore:
      - '**.md'
      - '**.txt'

env:
  REGION: ap-northeast-1
  CODEDEPLOY_APP: codedeploy_app_name
  S3_BACKET: io.braveridge.app.code-deploy
  APP_NAME_PREFIX: app-
  TEAMS_WEBHOOK: teams_webhook_url

jobs:
  test:
    name: Test
    runs-on: [self-hosted, MacBookPro2013]

    steps:
      - name: Checkout sources
        uses: actions/checkout@v2

      - name: Setup Java 11
        uses: joschi/setup-jdk@v2
        with:
          java-version: '11'

      - name: Test with Gradle
        uses: eskatos/gradle-command-action@v1
        with:
          wrapper-cache-enabled: true
          dependencies-cache-enabled: true
          configuration-cache-enabled: true
          arguments: test jacocoTestReport

  deploy:
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release'
    name: Deploy
    needs: test
    runs-on: [self-hosted, MacBookPro2013]

    steps:
      - name: Checkout sources
        uses: actions/checkout@v2

      - name: Generate Version
        if: github.ref == 'refs/heads/release'
        uses: HardNorth/github-version-generate@v1.1.0
        with:
          version-source: file
          version-file: version.txt
          next-version-increment-minor: true

      - name: Create Tag
        if: github.ref == 'refs/heads/release'
        run: |
          echo ${{ env.RELEASE_VERSION }} > version.txt
          git commit -m "version to ${{ env.RELEASE_VERSION }}" -a
          TAG="v${{ env.RELEASE_VERSION }}"
          echo "TAG_NAME=$TAG" >> $GITHUB_ENV
          git tag $TAG
          git push origin $TAG

      - name: Create Release
        if: github.ref == 'refs/heads/release'
        uses: softprops/action-gh-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ env.TAG_NAME }}

      - name: Set Version
        run: |
          VER=$(head -n 1 version.txt)
          echo "APP_NAME=$APP_NAME_PREFIX$VER" >> $GITHUB_ENV

      - name: Setup Java 11
        uses: joschi/setup-jdk@v2
        with:
          java-version: '11'

      - name: Build
        uses: eskatos/gradle-command-action@v1
        with:
          wrapper-cache-enabled: true
          dependencies-cache-enabled: true
          configuration-cache-enabled: true
          arguments: bootJar

      - name: Notify to teams
        if: github.ref == 'refs/heads/release'
        uses: luisghz/simple-ms-teams-webhook-notifier@v1-latest
        with:
          webhook_url: ${{ env.TEAMS_WEBHOOK }}
          summary: Start to deploy ${{ env.APP_NAME }}
          title: Start to deploy ${{ env.APP_NAME }}
          theme-color: 39A3E3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.REGION }}

      - name: Prepare CodeDeploy
        run: |
          mkdir deploy
          cp projects/web/build/libs/*.jar ./deploy
          cp -R .codedeploy/* ./deploy
          sed -i '' 's/APP_NAME/${{ env.APP_NAME }}/' ./deploy/scripts/3_application_start.sh
          aws deploy push --application-name ${{ env.CODEDEPLOY_APP }} --s3-location s3://${{ env.S3_BACKET }}/${{ env.APP_NAME }}.zip --source deploy

      - name: CodeDeploy for dev
        if: github.ref == 'refs/heads/main'
        run: |
          aws deploy create-deployment --ignore-application-stop-failures --application-name ${{ env.CODEDEPLOY_APP }} --deployment-config-name CodeDeployDefault.OneAtATime --deployment-group-name develop --s3-location bucket=${{ env.S3_BACKET }},bundleType=zip,key=${{ env.APP_NAME }}.zip

      - name: CodeDeploy for release
        if: github.ref == 'refs/heads/release'
        run: |
          aws deploy create-deployment --ignore-application-stop-failures --application-name ${{ env.CODEDEPLOY_APP }} --deployment-config-name CodeDeployDefault.OneAtATime --deployment-group-name release --s3-location bucket=${{ env.S3_BACKET }},bundleType=zip,key=${{ env.APP_NAME }}.zip

      - name: Version up
        if: github.ref == 'refs/heads/release'
        run: |
          git checkout main
          git pull origin main
          echo ${{ env.NEXT_VERSION }} > version.txt
          git commit -m "version to ${{ env.NEXT_VERSION }}" -a
          git push origin main


最後に

GitHub Actions でCI/CDを実行することでルーチンワークを自動化しました。
人はいつかミスをすると思っているので、自動化は健全な開発を継続する為に重要だと考えています。
今後もルーチンワークはどんどん自動化していきます。

SNS SHARE