こんにちは、アプリケーションエンジニアの難波です。
最近は主にサーバサイドを担当しています。
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を紹介します。
基本的なフローは以下の通りです。
- pushされたらテストを実行
- mainブランチにマージされたら開発サーバにデプロイ
- 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)を作成します。
デプロイの流れは以下の通りです。
- mainブランチにpush (or PR merge)
- ビルド(./gradlew bootJar)
- S3にアップロード
- CodeDeployを実行
- 開発サーバにデプロイ
※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)を作成します。
デプロイの流れは開発サーバへのデプロイとほぼ同じで、リリース作成を追加します。
- releaseブランチにpush
- リリースバージョン生成(x.y.z-SNAPSNNOT -> x.y.z)
- TagとReleaseを作成(vx.y.z)
- ビルド(./gradlew bootJar)
- Teamsに通知
- S3にアップロード
- CodeDeployを実行
- 本番サーバにデプロイ
- バージョンアップして 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を実行することでルーチンワークを自動化しました。
人はいつかミスをすると思っているので、自動化は健全な開発を継続する為に重要だと考えています。
今後もルーチンワークはどんどん自動化していきます。