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

後述するブランチ保護ルールの設定と組み合わせることで、ビルド・テストが正常に動くことが担保されたコードのみがmainブランチにマージされる構成です。
上記構成に基づき、アプリケーションルート/.github/workflowsフォルダに「main.yml」という名前で、以下の通りワークフローファイルを作成します。
name: Qiita Spring ECS 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、ディストリビューションは本番環境のコンテナと同様「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) OIDC認証の設定
deployジョブではDockerイメージをECRにプッシュしたり、ECSサービスの更新を行うなど、AWSリソースにアクセスすることになります。
GitHubのような外部のサービスがAWSリソースにアクセスするには、AWSから認証情報を取得する必要があり、その方式の一つとして今回はOIDC認証を行います。
認証にアクセスキーとシークレットキーを使用する方法もありますが、こちらは長期間有効な認証情報を保管して使用するのに対し、OIDC認証では一時的な認証情報を使用することから、認証情報の漏洩リスクが低いため推奨されているようです。
GitHub Actionsのドキュメントを参考に、実際に設定していきます。
はじめに、IAMのIDプロバイダを追加とし、以下の通り設定します。
- プロバイダのタイプ:OpenID Connect
- URL:https://token.actions.githubusercontent.com
- 対象者:sts.amazonaws.com
続いて、IAMロールを作成します。
エンティティタイプはカスタム信頼ポリシーを選択し、以下の通り設定します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "<作成したIDプロバイダのARN>"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:<GitHubの組織またはアカウント名>/<GitHubのリポジトリ名>:ref:refs/heads/main"
}
}
}
]
}
ポリシーは以下の2つを選択します。
| ポリシー | 内容 |
|---|---|
| AmazonEC2ContainerRegistryPowerUser | ECRへイメージのプッシュに必要 |
| AmazonECS_FullAccess | ECSタスク定義・サービスの更新に必要 |
最後に、ロール名を「qiita-spring-ecs-role-github」とし、「作成」とします。
2) GitHub Secretsの登録
先ほど作成したIAMロールを、GitHub Actionsが使用してAWSリソースにアクセスすることになります。
そのために、IAMロールのARNをGitHub Secretsに登録し、ワークフローからこちらを参照するようにします。

対象のリポジトリを開き、設定から、Secrets and variablesのActionsを選択します。
Secretsタブから、「New repository secret」を選択します。
3) ワークフローの作成
最後に、以下の構成でdeployジョブを作成します。
①ソースコードのチェックアウト
②ビルド成果物のダウンロード
③AWSへ認証
④Dockerイメージのビルド・プッシュ
⑤タスク定義の更新
⑥サービスの更新
①ソースコードのチェックアウト
build・testジョブ同様、GitHub公式のアクションを使用します。
②ビルド成果物のダウンロード
uses: actions/download-artifact@v4
with:
name: app-jar
path: target/
GitHub公式のアクションを使用し、buildジョブでアップロードした成果物をダウンロードします。
③AWSへ認証
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
AWS公式のアクションを使用し、認証を行います。
先ほどGitHub Secretsに登録したIAMロールの情報を使用しています。
(ここで使用しているAWS_REGION含め、諸々の環境変数は後で別途定義します。)
④Dockerイメージのビルド・プッシュ
・ECRへ認証
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
AWS公式のアクションを使用し、ECRへログインします。
当アクションの出力結果を後続のステップで使用するため、IDを振っています。
・イメージのビルド/プッシュ
run: |
docker build -t ${{ env.ECR_REPOSITORY }} .
docker tag ${{ env.ECR_REPOSITORY }}:latest ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
docker push ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
dockerコマンドを使用してイメージのビルド・タグ付け・ECRへプッシュを行います。
レジストリ名は、先ほどのアクションの出力結果から取得しています。
タグ付けにはハッシュ値を使用しています。
⑤タスク定義の更新
・現在のタスク定義のダウンロード
run: |
aws ecs describe-task-definition \
--task-definition ${{ env.ECS_TASK_DEFINITION }} \
--query taskDefinition > task-definition.json
awsコマンドを使用して現在設定されているタスク定義を取得し、JSONファイルを作成しています。
・タスク定義の更新
id: update-image
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: ${{ env.APP_CONTAINER }}
image: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
AWS公式のアクションを使用し、タスク定義を更新します。
具体的には、先ほど作成したJSONファイルの内容をもとに、指定したコンテナのイメージURIを新しいものに変更し、結果を出力しています。
(JSONファイル自体は書き換えていません)
⑥サービスの更新
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.update-image.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
force-new-deployment: true
AWS公式のアクションを使用し、先ほどのアクションで出力されたタスク定義を使用してサービスを更新しています。
wait-for-service-stabilityをtrueに設定することで、ECSサービスが安定状態になるまでワークフローを待機させます。
これにより、サービスの起動に失敗した場合、ワークフローも失敗させるようにします。
また、force-new-deploymentをtrueに設定することで、デプロイを強制します。
今回はタスク定義が更新されるので強制する必要はなさそうですが、念のため設定します。
以上で完成したワークフローファイルは以下の通りです。
jobs:
# build:…
# test:…
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
runs-on: ubuntu-latest
env:
AWS_REGION: ap-northeast-1
ECR_REPOSITORY: qiita-spring-ecs-app
ECS_CLUSTER: qiita-spring-ecs-cluster
ECS_SERVICE: qiita-spring-ecs-service
ECS_TASK_DEFINITION: qiita-spring-ecs-task
APP_CONTAINER: qiita-spring-ecs-app
steps:
- uses: actions/checkout@v4
- name: Download JAR Artifacts
uses: actions/download-artifact@v4
with:
name: app-jar
path: target/
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and Push Docker Image to ECR
run: |
docker build -t ${{ env.ECR_REPOSITORY }} .
docker tag ${{ env.ECR_REPOSITORY }}:latest ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
docker push ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
- name: Download Current Task Definition
run: |
aws ecs describe-task-definition \
--task-definition ${{ env.ECS_TASK_DEFINITION }} \
--query taskDefinition > task-definition.json
- name: Update Image URI in the Task Definition
id: update-image
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: ${{ env.APP_CONTAINER }}
image: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.update-image.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
force-new-deployment: true
6. 動作確認
PRを投げる
以上の内容でPRを投げてみます。

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

PRを確認すると、ブランチ保護ルールとしてRequire status checks to passに設定した、build・testジョブには「Required」と表示されています。
このPRをマージすると、今度はmainブランチへのプッシュがトリガーとなり再度ワークフローが動きます。

ECRのリポジトリを確認すると、新しくイメージがプッシュされています。
イメージタグはワークフロー内で出力したハッシュ値になっています。

タスク定義は新しいリビジョン(8)が作成され、先ほどのタグが付いているイメージを参照しています。
また、サービスから起動したタスクを確認すると、意図した通りリビジョン8のタスク定義を使用していることも分かります。
アプリケーションに変更を加える
失敗例
本アプリケーションではテストコードは実装していなかったのですが、簡易的な失敗するテストコードを書き、上記変更が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.357 s <<< FAILURE! -- in com.example.demo.HelloControllerTest
com.example.demo.HelloControllerTest.indexShouldReturnIncorrectViewName -- Time elapsed: 0.934 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ジョブまで成功しています。
また、ブラウザからアプリケーションにアクセスすると、


無事に変更後のコードが適用されていることが確認できました!
おわりに
以上で、GitHub Actionsを使用したCI/CDパイプラインの構築が完了しました。
アプリケーションのビルドやテスト、DockerイメージのビルドやECRへのプッシュ、ECSタスク定義・サービスの更新を自動化しています。
テストコードをちゃんと実装していけば、今回作成したtestジョブにより自動でテストが実行される環境が整っています。
また、静的解析ツールなどを組み込む際には、Mavenプラグインを追加してライフサイクルへ組み込み、またはワークフローから直接実行するだけで、簡単に自動化することができるようになりました。
最後に、他にも基礎的なAWSインフラ構築関連の記事を書いているので、よければご参照ください。
追記:開発環境におけるアプリケーションの実行環境について
背景
開発環境でも、本番環境同様コンテナ内でアプリケーションを実行できるようにしておくのが自然かと思いました。
現状とやりたいこと
現状

ここまで前後編通じて作成した環境は、ざっくり上図の通りです。
ローカルマシンにJDKやソースコードがあり、開発が完了するとDockerイメージを作成します。(前編では手動で、後編ではGitHub Actionsを使用して自動で行えるようにしました)
その後、作成されたイメージをAWS上にプッシュしていくという流れです。
この時、開発中にデバッグしようとした場合、ローカルマシンで直接アプリケーションを動かすことになります。
これだと、本番環境とローカルマシンで環境に差異があるので、もしかしたら開発環境では起きなかったエラーが本番環境で起きてしまう可能性があります。
やりたいこと

そこで、せっかくDockerでアプリケーションをコンテナ化しているので、開発環境でもコンテナ内でアプリケーションを動かし、デバッグできるようにします。
そのために行うことは大きく2つです。
- Dockerfileのマルチステージビルド化
- Docker Composeの利用(任意)
手順
ステージ(Springのプロファイルのようなもの)に応じて作成するイメージを切り替えるようにします。
切り替えるといっても、本番用イメージはこれまで通り、開発用イメージは本番用+デバッグ機能といった構成です。
FROM amazoncorretto:21 AS base
WORKDIR /app
COPY target/demo-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
FROM base AS dev
EXPOSE 5005
ENTRYPOINT ["java", "-Djava.net.preferIPv4Stack=true", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5005", "-jar", "/app/app.jar"]
FROM base AS prod
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
開発用(dev)では、5005番ポートを使用してデバッグを行うよう引数を追加しています。
version: '3.8'
services:
app:
build:
context: .
target: dev
ports:
- '8080:8080'
- '5005:5005
開発コンテナ用に、docker-compose.ymlファイルを作成します。
ステージはdevを指定しています。
これにより、イメージの作成からコンテナの構築までをdocker compose upで一度に実行できるようにします。
この状態で、実際にコンテナの構築を行うと、javaコマンドが5005番ポートで接続を待ち受けるようになります。
そこで、使っているIDEからアタッチしてあげます。
VSCodeの場合は以下の通りです。
{
"type": "java",
"name": "Container",
"request": "attach",
"hostName": "localhost",
"port": 5005
}
requestをattachとし、localhost:5005にアタッチします。
こちらでアタッチしてあげれば、無事に開発コンテナ内でデバッグができます。
最後に、GitHub Actionsではprodステージを使う必要があるので、ワークフローファイルを修正します。
deploy:
…
steps:
…
- name: Build and Push Docker Image to ECR
run: |
- docker build -t ${{ env.ECR_REPOSITORY }} .
+ docker build --target prod -t ${{ env.ECR_REPOSITORY }} .
イメージビルド時、--targetオプションにprodステージを指定します。
以上で、開発環境でもコンテナ内でアプリケーションを動かしてデバッグできる環境が整いました。
とはいえ、こちらの方法でアプリケーションコードの変更を確認するには、MavenによるパッケージングからDockerイメージの再ビルドを経るので、起動に時間がかかります。
ローカルマシンで直接動かす方法の方が起動に時間がかからないので、これらをうまく使い分けられたらよいかと思いました!





