はじめに
これまで、このブログはサーバ上で手動ビルド・手動デプロイをしていた。
流れとしては、
ソースコード更新
↓
Githubにプッシュ
↓
サーバ上で、git pullする
↓
GradleのbootJarタスクでビルド
↓
systemdサービスから、生成したjarをjavaコマンドで実行する
自動化するスクリプトを用意しているので、正直そこまで不便では無かった。ただ、以前、書いたように、サーバのメモリが小さく、サーバ上のビルドは少し工夫が必要だ。
勉強がてら、Github Actionsで、ビルド・テスト・デプロイをするようにした。備忘録として、実施したことを残す。
前提
- アプリ: Spring Boot
- ビルドツール: Gradle
- OS: Ubuntu 24.04
第1段階:CIを作る
まずはデプロイを考えず、GitHub Actions上でビルドとテストが通ることを確認した。
以下のような、.github/workflows/ci.ymlを作った。
name: CI
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Make gradlew executable
run: chmod +x ./gradlew
- name: Build and test
run: ./gradlew --no-daemon clean check bootJar
最初のon:のところの記述で、mainにプッシュされるか、プルリクエストが作成されたときに、jobs:が実行されるようになる。
jobs:でやっていることは以下。
Checkout
リポジトリのソースコードを取得する
Set up Java
Java 21をセットアップする
Set up Gradle
Gradle実行用の設定をする
Make gradlew executable
gradlewに実行権限を付ける
Build and test
clean check bootJarを実行する
これで、ビルドとテストまでをGithub Actionsで実行できる。
実行結果は、Githubのリポジトリの画面までいって、Actionsタブを押せば確認できる。
第2段階:jarをartifactとして保存する
次に、ビルドしたjarをGitHub Actionsのartifactとして保存する。
さっきの.github/workflows/ci.ymlの最後に次の処理を追加する。
- name: Prepare artifact
run: |
set -euo pipefail
mkdir -p dist
shopt -s nullglob
jars=(build/libs/app-*.jar)
shopt -u nullglob
if [ "${#jars[@]}" -ne 1 ]; then
echo "jar の数が想定外です"
printf ' - %s\n' "${jars[@]:-}"
exit 1
fi
cp "${jars[0]}" dist/app.jar
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: app
path: |
dist/app.jar
retention-days: 7
ここでは、build/libs配下に作られたjarをdist/app.jarという固定名にコピーしている。 jarの数が0個でも2個以上でも、念のため、失敗させるようにしている。
成功したworkflowの結果を見ると、一番下のArtifactsの欄に、jarができていることが確認できる。
これで、GitHub Actions上でサーバに送るjarを作成できた。
第3段階:本番デプロイ用ユーザーを作る
次に、Github Actionsが本番サーバへSSHするための専用ユーザーを作成した。
普段使っているユーザーのSSH鍵をGitHub Secretsに入れることもできるが、権限が広すぎる。
そこで、GitHub Actions用に、以下github-deployのような専用ユーザーを作る。
sudo adduser --disabled-password --gecos "" github-deploy
--disabled-password はパスワードログインを無効にする指定。 SSH鍵認証だけでログインさせる。
--gecos "" は、氏名や電話番号などの追加情報を空にする指定。 機械用ユーザーなのでなしにする。
SSHをAllowUsersで制限している場合は、github-deployも追加する。
AllowUsers your-user github-deploy
変更後はsshdの設定を確認してreloadする。
sudo sshd -t
sudo systemctl reload ssh
第4段階:SSH鍵を作る
GitHub Actions専用のSSH鍵を作る。
ここで重要なのは、パスフレーズなしの鍵にすること。詰まった点として、対話入力で空のパスフレーズを指定したつもりだったが、GitHub Actionsでは正常に利用できなかった。そこで、以下のように-N ""を指定し、空のパスフレーズを明示したところ正常に動作した。
ssh-keygen -t ed25519 -N "" -f github-actions -C "github-actions"
作られるファイルは次の2つ。
github-actions 秘密鍵
github-actions.pub 公開鍵
サーバ側のauthorized_keysに公開鍵を登録する。
sudo mkdir -p /home/github-deploy/.ssh
sudo chmod 700 /home/github-deploy/.ssh
sudo tee /home/github-deploy/.ssh/authorized_keys < github-actions.pub
sudo chown -R github-deploy:github-deploy /home/github-deploy/.ssh
sudo chmod 600 /home/github-deploy/.ssh/authorized_keys
手元からログインできるか確認する。
ssh -i github-actions -p <SSHのポート番号> github-deploy@<サーバのIPアドレス>
ここでパスフレーズを聞かれずにログインできればOK。
第5段階:sudo権限を限定する
github-deployには、全sudo権限を与えないようにした。
必要なのはアプリの再起動と起動確認だけ。
sudoersを作成する。
sudo visudo -f /etc/sudoers.d/app-deploy
中身は次のようにして、ブログの再起動とis-activeをできるようにした。スクリプト内で、--quietオプションを付けてsystemctlを実行するようにしているので、sudoersの中もそうした。
github-deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart --quiet app, /usr/bin/systemctl is-active --quiet app
設定後は構文チェックする。
sudo visudo -c
sudoできるか確認する。
sudo -n /usr/bin/systemctl restart --quiet app
sudo -n /usr/bin/systemctl is-active --quiet app
sudo -n は、パスワード入力が必要な場合に即失敗させる指定。
第6段階:サーバ側デプロイスクリプトを作る
GitHub Actions からアップロードされたjarを配置するため、サーバ側にスクリプトを作成した。
deploy_uploaded_jar.sh
中身は次のようにした。
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="<アプリのディレクトリ>"
RELEASES_DIR="$APP_DIR/releases"
SERVICE_NAME="app"
SOURCE_JAR="${1:?usage: deploy_uploaded_jar.sh /path/to/app.jar}"
KEEP_RELEASES=10
if [ ! -f "$SOURCE_JAR" ]; then
echo "jar が見つかりません: $SOURCE_JAR"
exit 1
fi
mkdir -p "$RELEASES_DIR"
SHA256=$(sha256sum "$SOURCE_JAR" | awk '{print $1}')
TIMESTAMP=$(date +%Y%m%d-%H%M%S-%N)
NEW_JAR="app-${TIMESTAMP}-${SHA256:0:12}.jar"
PREVIOUS_JAR=""
if [ -L "$APP_DIR/current.jar" ]; then
PREVIOUS_JAR=$(readlink -f "$APP_DIR/current.jar" || true)
fi
rollback() {
if [ -n "$PREVIOUS_JAR" ] && [ -f "$PREVIOUS_JAR" ]; then
echo "前回の jar に戻します: $PREVIOUS_JAR"
ln -sfn "$PREVIOUS_JAR" "$APP_DIR/current.jar"
sudo systemctl restart --quiet "$SERVICE_NAME" || true
fi
}
install -m 644 "$SOURCE_JAR" "$RELEASES_DIR/$NEW_JAR"
rm -f "$SOURCE_JAR"
ln -sfn "$RELEASES_DIR/$NEW_JAR" "$APP_DIR/current.jar"
if ! sudo systemctl restart --quiet "$SERVICE_NAME"; then
echo "$SERVICE_NAME の restart に失敗しました"
rollback
exit 1
fi
sleep 3
if ! sudo systemctl is-active --quiet "$SERVICE_NAME"; then
echo "$SERVICE_NAME の起動確認に失敗しました"
rollback
exit 1
fi
find "$RELEASES_DIR" -maxdepth 1 -type f -name 'app-*.jar' -printf '%T@ %p\n' \
| sort -nr \
| tail -n +$((KEEP_RELEASES + 1)) \
| cut -d ' ' -f 2- \
| xargs -r rm -f
echo "deployed: $NEW_JAR"
このスクリプトでは、次のことをしている。
・アップロードされた jar の存在確認
・SHA-256と時刻を使ってリリース用ファイル名を作成
・releasesにjarを配置
・current.jarを新しいjarに向ける
・systemctl restart
・起動確認
・失敗時は前回のjarに戻す
・古いrelease jarを整理する
実行権限を付ける。
chmod +x deploy_uploaded_jar.sh
また、github-deployユーザが書き込めるように、グループや権限も調整すること。
第7段階:GitHub Secretsを登録する
GitHubのリポジトリで、次のSecretsを登録する。
PROD_SSH_HOST
PROD_SSH_PORT
PROD_SSH_USER
PROD_SSH_PRIVATE_KEY
PROD_SSH_KNOWN_HOSTS
例は以下。
PROD_SSH_HOST=<サーバのIP or FQDN>
PROD_SSH_PORT=22
PROD_SSH_USER=github-deploy
PROD_SSH_PRIVATE_KEY には、秘密鍵の中身を入れる。
貼り付けるのは、次のような形式。
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
PROD_SSH_KNOWN_HOSTS は、接続先サーバのホスト鍵。 SSH ポートが 2222 の場合は次のように取得する。
ssh-keyscan -p 2222 <サーバのIP or FQDN>
出力例は以下。
[example.com]:2222 ssh-ed25519 hogehoge...
これを PROD_SSH_KNOWN_HOSTS に登録する。
複数行出力された場合は、#始まりでない業をすべて、登録する。
第8段階:手動デプロイ workflowにする
最終的な.github/workflows/ci.ymlは以下のようになった。
name: CI
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Make gradlew executable
run: chmod +x ./gradlew
- name: Build and test
run: ./gradlew --no-daemon clean check bootJar
- name: Prepare artifact
run: |
set -euo pipefail
mkdir -p dist
shopt -s nullglob
jars=(build/libs/app-*.jar)
shopt -u nullglob
if [ "${#jars[@]}" -ne 1 ]; then
echo "jar の数が想定外です"
printf ' - %s\n' "${jars[@]:-}"
exit 1
fi
cp "${jars[0]}" dist/app.jar
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: app
path: |
dist/app.jar
retention-days: 7
deploy:
name: Deploy to production
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: app
path: dist
- name: Configure SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Upload and deploy
env:
SSH_HOST: ${{ secrets.PROD_SSH_HOST }}
SSH_PORT: ${{ secrets.PROD_SSH_PORT }}
SSH_USER: ${{ secrets.PROD_SSH_USER }}
run: |
set -euo pipefail
remote_jar="/tmp/app-${GITHUB_SHA}.jar"
echo "SSH login test:"
ssh -i ~/.ssh/id_ed25519 \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o BatchMode=yes \
-p "$SSH_PORT" \
"$SSH_USER@$SSH_HOST" \
"whoami"
echo "Upload jar:"
scp -i ~/.ssh/id_ed25519 \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o BatchMode=yes \
-P "$SSH_PORT" \
dist/app.jar "$SSH_USER@$SSH_HOST:$remote_jar"
echo "Deploy jar:"
ssh -i ~/.ssh/id_ed25519 \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o BatchMode=yes \
-p "$SSH_PORT" \
"$SSH_USER@$SSH_HOST" \
"bash <deploy_uploaded_jar.shのパス> '$remote_jar'"
deployの条件が以下になっているため、mainにプッシュしただけではデプロイされない。
if: github.event_name == 'workflow_dispatch'
つまり、デプロイしたいときだけGitHub Actionsの画面から手動実行するようにした。
完成した流れ
最終的な流れは次の通り。
GitHubにプッシュ
↓
GitHub Actionsでbuild/test
↓
bootJar作成
↓
artifact保存
デプロイするときは、GitHub Actionsの画面から手動実行する。
Run workflow
↓
build/test
↓
artifact作成
↓
jarを本番サーバへscp
↓
deploy_uploaded_jar.shをSSH経由で実行
↓
releasesにjar配置
↓
current.jar切り替え
↓
systemctl restart
↓
起動確認
おわりに
これで、サーバ上で手動ビルドする必要がなくなった。現在の.github/workflows/ci.ymlだと、任意のブランチでデプロイできてしまうため、mainブランチだけに絞った方がいいだろう。あと、同時実行対策もした方がいいだろう。もう疲れたので、今回はこれまでにする。

コメント