はじめに
概要
AWSとGitHub Actionsを使用した本番環境の構築・アプリケーションのデプロイ方法について取り上げます。
前提条件
- Java/Spring Boot環境構築済み
- AWSアカウント作成済み
- GitHubアカウント作成済み
リポジトリ
動作環境
- Windows 11 Home(24H2)
- Java 21
- Maven 3.9.9
- Spring Boot 3.4.5
本手順
前編では、Webサーバやロードバランサーの構築まで完了しました。
後編では、アプリケーションのビルドや、Webサーバへの成果物の転送、systemdサービスの更新を、GitHub Actionsを使用して自動化する方法をまとめていきます。
1. 基本
ブランチ戦略はGitHub Flowを採用する想定で進めます。
GitHub Flowの「mainブランチは常にデプロイ可能な状態を維持する」という考え方に基づき、ワークフローは以下の通り作成します。

後述するブランチ保護ルールの設定と組み合わせることで、ビルド・テストが正常に動くことが担保されたコードのみがmainブランチにマージされる構成です。
上記構成に基づき、アプリケーションルート/.github/workflowsフォルダに「main.yml」という名前で、以下の通りワークフローファイルを作成します。
name: Qiita Spring EC2 Application CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
test:
needs: build
runs-on: ubuntu-latest
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
runs-on: ubuntu-latest
2. ブランチ保護ルールの設定
今回設定するルールは以下の2つです。
| ルール | 内容 |
|---|---|
| Require a pull request before merging | PRを介さない直接のプッシュを禁止 |
| Require status checks to pass | PR時、指定したワークフローのジョブが成功することをチェック |
実際に設定していきます。

GitHubで対象のリポジトリを開き、設定から「New branch ruleset」を選択します。

名前は「main rule」とし、ステータスを「Active」にします。

ターゲットは「default branch(=main)」を選択します。
ルールでは、デフォルトで選択されている「Restrict deletions(ブランチ削除禁止)」と「Block force pushes(強制プッシュ禁止)」はそのままとします。

「Require a pull request before merging」を選択します。
「Required approvals(チームメンバーから必要な承認の数)」は0のままとし、その他の追加設定もデフォルトのままとします。

「Require status checks to pass」を選択し、checksに「build」「test」を登録します。
これにより、PR時にbuild・testの両ジョブが成功しない限りマージできないようにします。
加えて、「Require branches to be up to date before merging」を選択することで、最新のコードベース、すなわちPRブランチがターゲットブランチの最新の変更まで全て取り込んだ上で上記確認を行うようにします。
以上で、「Create」とします。
3. buildジョブの作成
ジョブを作成していきます。
始めに、以下の構成でbuildジョブを作成します。
①ソースコードのチェックアウト
②Java環境構築
③アプリケーションのビルド
④ビルド成果物のアップロード
①ソースコードのチェックアウト
uses: actions/checkout@v4
GitHub公式のアクションを使用します。
②Java環境構築
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'corretto'
cache: maven
GitHub公式のアクションを使用します。
パラメータに、Javaバージョンは21、ディストリビューションは本番環境のWebサーバと同様「Amazon Corretto」を指定します。
また、Mavenのローカルリポジトリをキャッシュすることで、2回目以降の実行時間を短縮するようにします。
③アプリケーションのビルド
run: mvn -B clean package -DskipTests
Mavenコマンドをバッチモードで使用し、パッケージングを行います。
テストは後述するtestジョブが担う想定なので、ここではスキップします。
④ビルド成果物のアップロード
uses: actions/upload-artifact@v4
with:
name: app-jar
path: target/demo-0.0.1-SNAPSHOT.jar
GitHub公式のアクションを使用します。
ビルド成果物を、「app-jar」という名前でGitHub Actionsのアーティファクトに保存します。
以上で完成したワークフローファイルは以下の通りです。
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'corretto'
cache: maven
- name: Build with Maven
run: mvn -B clean package -DskipTests
- name: Upload JAR Artifacts
uses: actions/upload-artifact@v4
with:
name: app-jar
path: target/demo-0.0.1-SNAPSHOT.jar
4. testジョブの作成
続いて、以下の構成でtestジョブを作成します。
①ソースコードのチェックアウト
②Java環境構築
③アプリケーションのテスト
④テスト結果のアップロード
①ソースコードのチェックアウト
②Java環境構築
buildジョブ同様、GitHub公式のアクションを使用します。
③アプリケーションのテスト
run: mvn -B test
Mavenコマンドをバッチモードで使用し、テストを行います。
④テスト結果のアップロード
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: target/surefire-reports/
他のステップ(主にアプリケーションのテスト)が失敗したとしても、テスト結果を「test-results」という名前でGitHub Actionsのアーティファクトに保存します。
以上で完成したワークフローファイルは以下の通りです。
jobs:
# build:…
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'corretto'
cache: maven
- name: Test with Maven
run: mvn -B test
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: target/surefire-reports/
5. deployジョブの作成
1) GitHub Secretsの登録
deployジョブではビルド成果物をWebサーバに転送するにあたり、接続情報が必要になります。
この時、接続情報をワークフローに直接持たせるのはセキュリティ上危険なので、GitHub Secretsに登録してこちらを参照するようにします。

対象のリポジトリを開き、設定から、Secrets and variablesのActionsを選択します。
Secretsタブから、「New repository secret」を選択します。
| キー | 値 |
|---|---|
| SPRING_APP_SSH_KEY | qiita-spring-ec2-key.pemファイルの中身 |
| SPRING_APP_BASTION_HOST | 踏み台サーバのパブリックIPアドレス |
| SPRING_APP_BASTION_USER | ubuntu |
| SPRING_APP_WEB1_HOST | Webサーバ1のプライベートIPアドレス |
| SPRING_APP_WEB1_USER | ubuntu |
| SPRING_APP_WEB2_HOST | Webサーバ2のプライベートIPアドレス |
| SPRING_APP_WEB2_USER | ubuntu |
2) ワークフローの作成
最後に、以下の構成でdeployジョブを作成します。
①ソースコードのチェックアウト
②ビルド成果物のダウンロード
③SSH接続の設定
④ビルド成果物の転送
⑤アプリケーションの再起動
①ソースコードのチェックアウト
build・testジョブ同様、GitHub公式のアクションを使用します。
②ビルド成果物のダウンロード
uses: actions/download-artifact@v4
with:
name: app-jar
path: target/
GitHub公式のアクションを使用し、buildジョブでアップロードした成果物をダウンロードします。
③SSH接続の設定
・秘密鍵の登録
uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ secrets.SPRING_APP_SSH_KEY }}
先ほどGitHub Secretsに登録した情報は、上記の通り「secrets.キー名」で取得できます。
ここでは既存のアクションを使用し、秘密鍵の内容をファイルに書き込むことなくSSHエージェントへ登録しています。
これにより、以降のステップで、例えばsshコマンドにおける-iオプションの指定などが不要になります。
・SSH設定ファイルの作成
run: |
mkdir -p ~/.ssh
cat > ~/.ssh/config << 'EOL'
Host bastion
HostName ${{ secrets.SPRING_APP_BASTION_HOST }}
User ${{ secrets.SPRING_APP_BASTION_USER }}
Host web1
HostName ${{ secrets.SPRING_APP_WEB1_HOST }}
User ${{ secrets.SPRING_APP_WEB1_USER }}
ProxyCommand ssh -W %h:%p bastion
Host web2
HostName ${{ secrets.SPRING_APP_WEB2_HOST }}
User ${{ secrets.SPRING_APP_WEB2_USER }}
ProxyCommand ssh -W %h:%p bastion
EOL
chmod 600 ~/.ssh/config
続いてSSH接続の設定ファイルを作成します。
各Webサーバのホスト名やユーザ名はGitHub Secretsから取得しています。
先ほど秘密鍵の登録は完了しているので、「IdentityFile xxx.pem」のような指定は省略しています。
・SSH接続先の情報取得
run: |
ssh-keyscan ${{ secrets.SPRING_APP_BASTION_HOST }} >> ~/.ssh/known_hosts
ssh bastion "ssh-keyscan -H ${{ secrets.SPRING_APP_WEB1_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
ssh bastion "ssh-keyscan -H ${{ secrets.SPRING_APP_WEB2_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
各Webサーバの公開鍵を事前にknown_hostsファイルに登録しておくことで、初回接続時の確認が発生しないようにします。
また、各Webサーバの公開鍵の情報は-Hオプションでハッシュ化、およびエラー発生時のメッセージをログに残らないようにすることで安全性を高くしています。
④ビルド成果物の転送
run: |
scp target/demo-0.0.1-SNAPSHOT.jar web1:~/app/app.jar
scp target/demo-0.0.1-SNAPSHOT.jar web2:~/app/app.jar
scpコマンドを使用してダウンロードした成果物を各Webサーバに転送します。
⑤アプリケーションの再起動
run: |
ssh web1 << 'ENDSSH1'
sudo systemctl restart qiita-spring-ec2-app
sudo systemctl status qiita-spring-ec2-app --no-pager
ENDSSH1
ssh web2 << 'ENDSSH2'
sudo systemctl restart qiita-spring-ec2-app
sudo systemctl status qiita-spring-ec2-app --no-pager
ENDSSH2
各Webサーバに接続してアプリケーションの再起動を行います。
以上で完成したワークフローファイルは以下の通りです。
jobs:
# build:…
# test:…
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download JAR Artifacts
uses: actions/download-artifact@v4
with:
name: app-jar
path: target/
- name: Setup SSH
uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ secrets.SPRING_APP_SSH_KEY }}
- name: Create SSH config file
run: |
mkdir -p ~/.ssh
cat > ~/.ssh/config << 'EOL'
Host bastion
HostName ${{ secrets.SPRING_APP_BASTION_HOST }}
User ${{ secrets.SPRING_APP_BASTION_USER }}
Host web1
HostName ${{ secrets.SPRING_APP_WEB1_HOST }}
User ${{ secrets.SPRING_APP_WEB1_USER }}
ProxyCommand ssh -W %h:%p bastion
Host web2
HostName ${{ secrets.SPRING_APP_WEB2_HOST }}
User ${{ secrets.SPRING_APP_WEB2_USER }}
ProxyCommand ssh -W %h:%p bastion
EOL
chmod 600 ~/.ssh/config
- name: Setup known_hosts
run: |
ssh-keyscan ${{ secrets.SPRING_APP_BASTION_HOST }} >> ~/.ssh/known_hosts
ssh bastion "ssh-keyscan -H ${{ secrets.SPRING_APP_WEB1_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
ssh bastion "ssh-keyscan -H ${{ secrets.SPRING_APP_WEB2_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy to EC2
run: |
scp target/demo-0.0.1-SNAPSHOT.jar web1:~/app/app.jar
scp target/demo-0.0.1-SNAPSHOT.jar web2:~/app/app.jar
- name: Start Application
run: |
ssh web1 << 'ENDSSH1'
sudo systemctl restart qiita-spring-ec2-app
sudo systemctl status qiita-spring-ec2-app --no-pager
ENDSSH1
ssh web2 << 'ENDSSH2'
sudo systemctl restart qiita-spring-ec2-app
sudo systemctl status qiita-spring-ec2-app --no-pager
ENDSSH2
6. 動作確認
PRを投げる
以上の内容でPRを投げてみます。

Actionsタブから該当のワークフローを確認すると、build・testジョブが成功していることが分かります。
deployジョブは、まだPRを投げただけなので実行されていません。
また、アーティファクトにはビルド成果物とテスト結果が格納されており、ダウンロードして確認することができます。

PRを確認すると、ブランチ保護ルールとしてRequire status checks to passに設定した、build・testジョブには「Required」と表示されています。
このPRをマージすると、今度はmainブランチへのプッシュがトリガーとなり再度ワークフローが動きます。
アプリケーションに変更を加える
失敗例
本アプリケーションではテストコードは実装していなかったのですが、簡易的な失敗するテストコードを書き、上記変更がmainブランチに取り込まれないことを確認します。
package com.example.demo;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
@WebMvcTest(HelloController.class)
public class HelloControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
public void indexShouldReturnIncorrectViewName() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get(""))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.view().name("wrong"));
}
}
ルートパスにアクセスして取得するビュー名が、「wrong」であることを期待するテストを実装しました。(正しくは「hello」)
以上でPRを投げます。

ワークフローを確認すると、意図した通りtestジョブが失敗しています。
-------------------------------------------------------------------------------
Test set: com.example.demo.HelloControllerTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.362 s <<< FAILURE! -- in com.example.demo.HelloControllerTest
com.example.demo.HelloControllerTest.indexShouldReturnIncorrectViewName -- Time elapsed: 0.962 s <<< FAILURE!
java.lang.AssertionError: View name expected:<wrong> but was:<hello>
…
アーティファクトに保存されているテスト結果を確認すると、「wrong」で期待したが、実際は「hello」だった旨のメッセージが格納されています。

また、PRを確認すると、上記失敗に伴いマージができないようになっていることが分かります。
成功例
@Test
public void indexShouldReturnCorrectViewName() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get(""))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.view().name("hello"));
}
該当のテストメソッドを修正しコミットします。
このままマージします。

ワークフローを確認すると、deployジョブまで成功しています。
また、ブラウザからアプリケーションにアクセスすると、


無事に変更後のコードが適用されていることが確認できました!
前編記事との間でEC2インスタンスを再起動しているので、ホスト名が変わっています
おわりに
以上で、GitHub Actionsを使用したCI/CDパイプラインの構築が完了しました。
アプリケーションのビルドやテスト、前編で構築したWebサーバへの接続や成果物の転送を自動化しています。
テストコードをちゃんと実装していけば、今回作成したtestジョブにより自動でテストが実行される環境が整っています。
また、静的解析ツールなどを組み込む際には、Mavenプラグインを追加してライフサイクルへ組み込み、またはワークフローから直接実行するだけで、簡単に自動化することができるようになりました。
最後に、他にも基礎的なAWSインフラ構築関連の記事を書いているので、よければご参照ください。





