0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【ECS + GitHub Actions】コンテナ化Spring Bootアプリの本番環境を構築 [後編] - CI/CDパイプライン構築

Last updated at Posted at 2025-08-10

はじめに

概要

AWSとGitHub Actionsを使用した本番環境の構築・コンテナ化アプリのデプロイ方法について取り上げます。

image.png

前提条件

  • 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ブランチは常にデプロイ可能な状態を維持する」という考え方に基づき、ワークフローは以下の通り作成します。
image.png

後述するブランチ保護ルールの設定と組み合わせることで、ビルド・テストが正常に動くことが担保されたコードのみがmainブランチにマージされる構成です。

上記構成に基づき、アプリケーションルート/.github/workflowsフォルダに「main.yml」という名前で、以下の通りワークフローファイルを作成します。

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時、指定したワークフローのジョブが成功することをチェック

実際に設定していきます。

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

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

image.png
ターゲットは「default branch(=main)」を選択します。

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

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

image.png
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のアーティファクトに保存します。

以上で完成したワークフローファイルは以下の通りです。

main.yml
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のアーティファクトに保存します。

以上で完成したワークフローファイルは以下の通りです。

main.yml
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プロバイダを追加とし、以下の通り設定します。

続いて、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に登録し、ワークフローからこちらを参照するようにします。

image.png
対象のリポジトリを開き、設定から、Secrets and variablesのActionsを選択します。

Secretsタブから、「New repository secret」を選択します。

image.png
AWS_ROLE_ARN」というキー名で登録します。

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に設定することで、デプロイを強制します。
今回はタスク定義が更新されるので強制する必要はなさそうですが、念のため設定します。

以上で完成したワークフローファイルは以下の通りです。

main.yml
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を投げてみます。

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

image.png
PRを確認すると、ブランチ保護ルールとしてRequire status checks to passに設定した、build・testジョブには「Required」と表示されています。

このPRをマージすると、今度はmainブランチへのプッシュがトリガーとなり再度ワークフローが動きます。

image.png
この通り、deployジョブまで成功しています。

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

image.png
タスク定義は新しいリビジョン(8)が作成され、先ほどのタグが付いているイメージを参照しています。

また、サービスから起動したタスクを確認すると、意図した通りリビジョン8のタスク定義を使用していることも分かります。

アプリケーションに変更を加える

image.png
「CI/CD実装済み」という文言を追加します。

失敗例

本アプリケーションではテストコードは実装していなかったのですが、簡易的な失敗するテストコードを書き、上記変更がmainブランチに取り込まれないことを確認します。

HelloControllerTest.java
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を投げます。

image.png
ワークフローを確認すると、意図した通りtestジョブが失敗しています。

com.example.demo.HelloControllerTest.txt
-------------------------------------------------------------------------------
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」だった旨のメッセージが格納されています。

image.png
また、PRを確認すると、上記失敗に伴いマージができないようになっていることが分かります。

成功例

HelloControllerTest.java
@Test
public void indexShouldReturnCorrectViewName() throws Exception {
    mockMvc.perform(MockMvcRequestBuilders.get(""))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("hello"));
}

該当のテストメソッドを修正しコミットします。

image.png
今度はtestジョブも成功しています。

image.png
また、PRもマージできる状態になっていることが分かります。

このままマージします。

image.png
ワークフローを確認すると、deployジョブまで成功しています。

また、ブラウザからアプリケーションにアクセスすると、
image.png
image.png
無事に変更後のコードが適用されていることが確認できました!

おわりに

以上で、GitHub Actionsを使用したCI/CDパイプラインの構築が完了しました。

アプリケーションのビルドやテスト、DockerイメージのビルドやECRへのプッシュ、ECSタスク定義・サービスの更新を自動化しています。

テストコードをちゃんと実装していけば、今回作成したtestジョブにより自動でテストが実行される環境が整っています。
また、静的解析ツールなどを組み込む際には、Mavenプラグインを追加してライフサイクルへ組み込み、またはワークフローから直接実行するだけで、簡単に自動化することができるようになりました。

最後に、他にも基礎的なAWSインフラ構築関連の記事を書いているので、よければご参照ください。

追記:開発環境におけるアプリケーションの実行環境について

背景

開発環境でも、本番環境同様コンテナ内でアプリケーションを実行できるようにしておくのが自然かと思いました。

現状とやりたいこと

現状
image.png
ここまで前後編通じて作成した環境は、ざっくり上図の通りです。

ローカルマシンにJDKやソースコードがあり、開発が完了するとDockerイメージを作成します。(前編では手動で、後編ではGitHub Actionsを使用して自動で行えるようにしました)
その後、作成されたイメージをAWS上にプッシュしていくという流れです。

この時、開発中にデバッグしようとした場合、ローカルマシンで直接アプリケーションを動かすことになります。
これだと、本番環境とローカルマシンで環境に差異があるので、もしかしたら開発環境では起きなかったエラーが本番環境で起きてしまう可能性があります。

やりたいこと
image.png
そこで、せっかく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番ポートを使用してデバッグを行うよう引数を追加しています。

docker-compose.yml
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の場合は以下の通りです。

launch.json
{
    "type": "java",
    "name": "Container",
    "request": "attach",
    "hostName": "localhost",
    "port": 5005
}

requestをattachとし、localhost:5005にアタッチします。

こちらでアタッチしてあげれば、無事に開発コンテナ内でデバッグができます。

最後に、GitHub Actionsではprodステージを使う必要があるので、ワークフローファイルを修正します。

main.yml
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イメージの再ビルドを経るので、起動に時間がかかります。

ローカルマシンで直接動かす方法の方が起動に時間がかからないので、これらをうまく使い分けられたらよいかと思いました!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?