はじめに

これまで、このブログはサーバ上で手動ビルド・手動デプロイをしていた。

流れとしては、

ソースコード更新
↓
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ができていることが確認できる。

Artifacts

これで、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ブランチだけに絞った方がいいだろう。あと、同時実行対策もした方がいいだろう。もう疲れたので、今回はこれまでにする。