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?

AIエージェントで開発した自作アプリをAWSでGitOps

0
Last updated at Posted at 2026-07-15

はじめに

今回はAIエージェントで自作したローカルアプリをAWS上で稼働させてみたので、備忘録として残します。AWS上のインフラへ移行し、開発からデプロイまでの工程が自動化された、簡易的なGitOpsパイプラインを構築する手順を説明します。なお、記載しているコード類はサンプルであり、実際に作成するアプリの内容によって多少の修正が必要です。また、動かすアプリ自体は本記事に含んでいません。

開発フロー

本構成は、2つのGitリポジトリで運用します。

  1. インフラリポジトリ・・・インフラ担当者が管理する想定。CDKの導入により構成が大規模化した場合にコードの保守性を維持し、IaCスキャンによりクラウド設定不備をデプロイする前に検知します。
  2. 開発用リポジトリ・・・開発チームが管理する想定。AIエージェントが開発し、人間の管理者が承認する開発フローで、Human-in-the-Loopです。PR時に自動的にソースコードのSAST検査を行います。承認されたコードは自動的にAWSへデプロイされます。

ブランチ保護

開発用リポジトリは2つのブランチで運用します。AIエージェントはコーディングやローカルテストが完了したら、gitを利用してdevelopブランチへ最新コードをPushします。AIエージェントにはPRをmainブランチにマージする権限がないため、ここで人間の承認なしに勝手にコードが更新されることからアプリを保護します。

CI/CDパイプラインフロー

一度パイプラインが構成されると、アプリの修正からデプロイを繰り返すCI/CDの状態になります。パイプラインを図示すると下記のようなフローになります。

アプリの構成

私が実際にAIエージェントに作成させたアプリの構成です。

作成したアプリのイメージ(一部)

全体アーキテクチャ

まずはシステム全体の構成です。CodeCommitで、アプリとインフラをコードで管理します。CodeCommitへのPushをトリガーとして、インフラリポジトリであればCDKを利用してインフラを自動デプロイします。アプリリポジトリであれば、コンテナイメージをビルドしてECRを更新し、ECSで実行されるタスクを自動的に更新します。アプリのデータは、EFSに保管します。
本構成では個人利用を想定し、極力コストを抑えています。およそ毎月のランニングコストは1,000円〜2,000円になります。

  1. ECS Fargateでスポットインスタンス、スケジューリングで利用するときだけ起動
  2. NATは利用せず、ECSはパブリックセグメントに配置
  3. PCやスマートフォンからのアクセスにngrok(無料版)を利用

以下構築手順です。

フェーズ1 CI/CD基盤とガードレールの構築

①リポジトリの準備

CodeCommitに2つのリポジトリを作成します。(例:autotrade / autotrade-infra
CloudShellで操作するのが簡単です。

# 1. アプリケーション用リポジトリの作成
aws codecommit create-repository --repository-name autotrade --repository-description "AutoTrade Application Repository"

# 2. インフラ(CDK)用リポジトリの作成
aws codecommit create-repository --repository-name autotrade-infra --repository-description "AutoTrade Infrastructure CDK Repository"

②認証とブランチ保護

CodeCommit用のIAMユーザーを作成し、SSHキーをAIエージェントを利用しているローカルに設定します。このIAMユーザーに対し、mainブランチへの直接Pushを禁止するインラインポリシーをアタッチします。

SSHキーはローカル側で作成して、上記画面でキーをアップロードして登録してください。

Iam policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowRepositoryCommonActionsAndPullPush",
            "Effect": "Allow",
            "Action": [
                "codecommit:GitPull",
                "codecommit:GitPush",
                "codecommit:Get*",
                "codecommit:BatchGet*",
                "codecommit:List*",
                "codecommit:Describe*"
            ],
            "Resource": "arn:aws:codecommit:ap-northeast-1:your-AWSAccount:autotrade"
        },
        {
            "Sid": "DenyDirectPushToMainBranch",
            "Effect": "Deny",
            "Action": [
                "codecommit:GitPush"
            ],
            "Resource": "arn:aws:codecommit:ap-northeast-1:your-AWSAccount:autotrade",
            "Condition": {
                "StringEqualsIfExists": {
                    "codecommit:References": [
                        "refs/heads/main"
                    ]
                },
                "Null": {
                    "codecommit:References": "false"
                }
            }
        },
        {
            "Sid": "AllowPullRequestManagement",
            "Effect": "Allow",
            "Action": [
                "codecommit:CreatePullRequest",
                "codecommit:UpdatePullRequestDescription",
                "codecommit:UpdatePullRequestStatus",
                "codecommit:UpdatePullRequestTitle",
                "codecommit:PostCommentForPullRequest",
                "codecommit:UpdateComment"
            ],
            "Resource": "arn:aws:codecommit:ap-northeast-1:your-AWSAccount:autotrade"
        }
    ]
}

③パイプライン基盤のデプロイ

パイプライン構築用のCloudFormationテンプレートを実行し、CodePipelineとEventBrdige(PR検知用)のフレームワークを作成します。

pipeline-template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: CI/CD Pipeline for AutoTrade System (Infra & App)

Parameters:
  InfraRepositoryName:
    Type: String
    Default: autotrade-infra # 自身の環境のリポジトリ名称に変更してください。
    Description: Name of the CodeCommit repository for Infrastructure (CDK)
  AppRepositoryName:
    Type: String
    Default: autotrade # 自身の環境のリポジトリ名称に変更してください。
    Description: Name of the CodeCommit repository for Application
  BranchName:
    Type: String
    Default: main
    Description: Branch name to trigger the pipeline
  ClusterName:
    Type: String
    Default: autotrade-cluster # 自身のアプリ名称などに変更してください。
    Description: ECS Cluster name
  ServiceName:
    Type: String
    Default: autotrade-prod # 自身のアプリ名称などに変更してください。
    Description: ECS Service name

Resources:
  # --- Artifact Storage ---
  PipelineArtifactBucket:
    Type: AWS::S3::Bucket
    Properties:
      VersioningConfiguration:
        Status: Enabled

  # --- CodeBuild Projects ---
  BuildInfraProject:
    Type: AWS::CodeBuild::Project
    Properties:
      Name: Build-Infra-Project
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Source:
        Type: CODEPIPELINE
        BuildSpec: "buildspec-infra.yml"
      Artifacts:
        Type: CODEPIPELINE
      Environment:
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:8.0
        Type: LINUX_CONTAINER
        PrivilegedMode: false

  BuildAppProject:
    Type: AWS::CodeBuild::Project
    Properties:
      Name: Build-App-Project
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Source:
        Type: CODEPIPELINE
        BuildSpec: "buildspec-app.yml"
      Artifacts:
        Type: CODEPIPELINE
      Environment:
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:8.0
        Type: LINUX_CONTAINER
        PrivilegedMode: true # Dockerビルドに必須

  # --- 1. インフラ用パイプライン ---
  AutoTradeInfraPipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      Name: AutoTrade-Infra-Pipeline
      RoleArn: !GetAtt PipelineRole.Arn
      ArtifactStore: {Type: S3, Location: !Ref PipelineArtifactBucket}
      Stages:
        - Name: Source
          Actions:
            - Name: SourceInfra
              ActionTypeId: {Category: Source, Owner: AWS, Provider: CodeCommit, Version: '1'}
              Configuration: {RepositoryName: !Ref InfraRepositoryName, BranchName: !Ref BranchName}
              OutputArtifacts: [{Name: InfraSource}]
        - Name: InfraDeployment
          Actions:
            - Name: DeployInfra
              ActionTypeId: {Category: Build, Owner: AWS, Provider: CodeBuild, Version: '1'}
              InputArtifacts: [{Name: InfraSource}]
              Configuration: {ProjectName: !Ref BuildInfraProject}

  # --- 2. アプリ用パイプライン ---
  AutoTradeAppPipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      Name: AutoTrade-App-Pipeline
      RoleArn: !GetAtt PipelineRole.Arn
      ArtifactStore: {Type: S3, Location: !Ref PipelineArtifactBucket}
      Stages:
        - Name: Source
          Actions:
            - Name: SourceApp
              ActionTypeId: {Category: Source, Owner: AWS, Provider: CodeCommit, Version: '1'}
              Configuration: {RepositoryName: !Ref AppRepositoryName, BranchName: !Ref BranchName}
              OutputArtifacts: [{Name: AppSource}]
        - Name: AppBuild
          Actions:
            - Name: BuildDocker
              ActionTypeId: {Category: Build, Owner: AWS, Provider: CodeBuild, Version: '1'}
              InputArtifacts: [{Name: AppSource}]
              OutputArtifacts: [{Name: AppBuildArtifact}]
              Configuration: {ProjectName: !Ref BuildAppProject}
        - Name: AppDeploy
          Actions:
            - Name: DeployECS
              ActionTypeId: {Category: Deploy, Owner: AWS, Provider: ECS, Version: '1'}
              InputArtifacts: [{Name: AppBuildArtifact}]
              Configuration: {ClusterName: !Ref ClusterName, ServiceName: !Ref ServiceName}

  # --- IAM Roles ---
  CodeBuildRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement: [{Effect: Allow, Principal: {Service: codebuild.amazonaws.com}, Action: sts:AssumeRole}]
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AdministratorAccess # インフラ構築のため権限強め推奨

  PipelineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement: [{Effect: Allow, Principal: {Service: codepipeline.amazonaws.com}, Action: sts:AssumeRole}]
      Policies:
        - PolicyName: PipelineAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: [s3:GetObject, s3:PutObject, s3:GetBucketLocation]
                Resource: ["arn:aws:s3:::*", "arn:aws:s3:::*/*"]
              - Effect: Allow
                Action:
                  - codecommit:GetBranch
                  - codecommit:GetCommit
                  - codecommit:UploadArchive
                  - codecommit:GetUploadArchiveStatus
                  - codecommit:GetRepository
                Resource: "*"
              - Effect: Allow
                Action: [codebuild:StartBuild, codebuild:BatchGetBuilds]
                Resource: "*"
              # ECSデプロイに必要な権限を網羅
              - Effect: Allow
                Action:
                  - ecs:UpdateService
                  - ecs:DescribeServices
                  - ecs:DescribeTaskDefinition
                  - ecs:RegisterTaskDefinition
                Resource: "*"
              # 新しいECSタスクに実行ロール等を渡すための権限
              - Effect: Allow
                Action: iam:PassRole
                Resource: "*"

  # --- アプリPR検証用 CodeBuild プロジェクト ---
  BuildAppPRProject:
    Type: AWS::CodeBuild::Project
    Properties:
      Name: Test-App-PR-Project
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Source:
        Type: CODECOMMIT
        Location: !Sub "https://git-codecommit.${AWS::Region}.amazonaws.com/v1/repos/${AppRepositoryName}"
        BuildSpec: "buildspec-app-pr.yml"
      Artifacts:
        Type: NO_ARTIFACTS
      Environment:
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:8.0
        Type: LINUX_CONTAINER
        PrivilegedMode: false

  # --- 【追加】PR検証用 EventBridge が CodeBuild を起動するための IAM ロール ---
  EventBridgeInvokeBuildRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: CloudWatchEventsCodeBuildExecutionPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - codebuild:StartBuild
                Resource: !GetAtt BuildAppPRProject.Arn

  # --- PRの作成・更新を検知してCodeBuildを起動するEventBridgeルール ---
  AppPRTriggerRule:
    Type: AWS::Events::Rule
    Properties:
      Description: Trigger CodeBuild when a PR is created or updated for autotrade repo
      EventPattern:
        source:
          - aws.codecommit
        detail-type:
          - 'CodeCommit Pull Request State Change'
        resources:
          - !Sub "arn:aws:codecommit:${AWS::Region}:${AWS::AccountId}:${AppRepositoryName}"
        detail:
          event:
            - pullRequestCreated
            - pullRequestSourceBranchUpdated
          destinationReference:
            - refs/heads/main
      Targets:
        - Arn: !GetAtt BuildAppPRProject.Arn
          RoleArn: !GetAtt EventBridgeInvokeBuildRole.Arn
          Id: TargetCodeBuildPRCheck
          # PRのメタデータを環境変数としてCodeBuildに注入する
          InputTransformer:
            InputPathsMap:
              sourceBranch: "$.detail.sourceReference"
              prId: "$.detail.pullRequestId"
              sourceCommit: "$.detail.sourceCommit"
              destCommit: "$.detail.destinationCommit"
            InputTemplate: |
              {
                "sourceVersion": <sourceBranch>,
                "environmentVariablesOverride": [
                  { "name": "PR_ID", "value": <prId>, "type": "PLAINTEXT" },
                  { "name": "SOURCE_COMMIT", "value": <sourceCommit>, "type": "PLAINTEXT" },
                  { "name": "DEST_COMMIT", "value": <destCommit>, "type": "PLAINTEXT" }
                ]
              }

④インフラのベース初期化

CloudShellでCDK initを実施し、buildspec-infra.ymlを含めた初期のディレクトリツリーをインフラリポジトリにPushします。

CloudShell
# .nvm を有効化して最新のLTS(Node 20など)を隔離インストール
mkdir autotrade-infra && cd autotrade-infra
nvm install 20
nvm use 20

# この隔離環境のnpmを使ってCDKを初期化
npx cdk init app --language typescript
buildspec-infra.yml
version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 22
      python: 3.11
    commands:
      - echo "[INSTALL] 依存関係とスキャンツールのインストール..."
      - pip install checkov
      - npm install -g aws-cdk
      - npm ci

  build:
    commands:
      - echo "[BUILD] CDKの合成(synth)..."
      - npx cdk synth
      
      - echo "[BUILD] Checkov による IaC セキュリティスキャンを実行..."
      - checkov -d cdk.out/
      # IaCスキャンで問題がみつかったら、デプロイに進まずにここで終了する。
      - echo "[BUILD] インフラのデプロイ開始..."
      - npx cdk deploy --all --require-approval never

CDKを初期化すると下記のようなフォルダ構成になりますので、buildspec-infra.ymlをcdk-infraの直下にアップロードします。

autotrade-infra/
├── .git/                 # Git管理ディレクトリ (隠しフォルダ)
├── .gitignore            # node_modules などを除外する設定 (自動生成)
├── README.md
├── bin/
│   └── infra.ts          # CDKアプリのエントリポイント
├── buildspec-infra.yml   # ★追加してください:Checkov等の検証・デプロイ仕様書
├── cdk.json
├── jest.config.js
├── lib/
│   ├── app-stack.ts      # (後で作成・修正するECS等のスタック)
│   └── infra-stack.ts    # (後で作成・修正するVPC等のスタック)
├── package-lock.json
├── package.json
├── test/
│   └── infra.test.ts
└── tsconfig.json

そのままCodeCommitへPushします。

CloudShell
git config user.name "init"
git config user.email "init@example.com"

git add .
git commit -m "feat: initial CDK setup with buildspec-infra.yml"
git branch -M main

# CodeCommit のリモートリポジトリを登録(リポジトリ名は自身の環境に合わせて変更)
git remote add origin codecommit://autotrade-infra
git push -u origin main

フェーズ2 アプリの初期ビルド(イメージの用意)

ここからは、実際に動くアプリがないと先に進むことができません。

⑤アプリ側のCI/CDファイル配置

ローカルで、Dockerfile、SAST検証用の buildspec-app-pr.yml、ビルド用の buildspec-app.yml の3点をアプリのルートディレクトリに作成します。
(※この時、最低限の実行ができるアプリコードも配置してください。)

Dockerfile
# これはサンプルです。作成したアプリにあわせたものをAIエージェントに依頼して作成してもらってください。
FROM public.ecr.aws/docker/library/python:3.13-slim
COPY requirements.txt ./
RUN pip install --no-cache-dir --upgrade pip \
 && pip install --no-cache-dir -r requirements.txt

buildspec-app-pr.yml
version: 0.2

phases:
  install:
    runtime-versions:
      python: 3.11
    commands:
      - echo "[INSTALL] テスト・SAST・シークレットスキャンツールのインストール..."
      - pip install pytest pytest-cov bandit flake8 detect-secrets
      - |
        if [ -f requirements.txt ]; then
          pip install -r requirements.txt
        fi

  pre_build:
    commands:
      - echo "[PRE-BUILD] 静的解析 (SAST) およびシークレットスキャンの実行..."

      # エラーが起きても最後まで完走させるためのフラグと、結果記録用のファイルを用意
      - ERROR_FLAG=0
      - touch report.md

      # ----------------------------------------------------------------------
      # 1. detect-secrets によるパスワード・秘密鍵の直書きスキャン
      # ----------------------------------------------------------------------
      # .secrets.baseline は既知の誤検知(is_verified=True)を記録したファイル (git でコミット)
      - echo "-> シークレットチェック実行中..."
      - |
        # baseline がない場合は作成
        if [ ! -f .secrets.baseline ]; then
          detect-secrets scan \
            --exclude-files '^\.secrets\.baseline$' \
            > .secrets.baseline 2>/dev/null || true
        fi
        # 新規スキャン
        detect-secrets scan \
          --exclude-files '^\.secrets\.baseline$' \
          > .secrets.new 2>/dev/null || true
        # baseline と比較して未確認(is_verified=False)かつ新規の検出のみエラー
        # hashed_secret で比較(同じシークレットなら同じ hash)
        NEW_SECRETS=$(python3 scripts/check_secrets_diff.py 2>&1)
        if echo "$NEW_SECRETS" | grep -q "FOUND"; then
          echo "- ❌ **Secrets**: 新規シークレットが検出されました" >> report.md
          echo "**baseline に未登録の検出:**" >> report.md
          echo '```' >> report.md
          echo "$NEW_SECRETS" | grep -v "^$" | head -10 >> report.md
          echo '```' >> report.md
          echo "" >> report.md
          echo "修正方法: baseline を更新するか、コードから秘密情報を削除" >> report.md
          ERROR_FLAG=1
        else
          echo "- ✅ **Secrets**: 異常なし (既知の偽陽性は baseline で除外)" >> report.md
        fi
        rm -f .secrets.new

      # ----------------------------------------------------------------------
      # 2. Flake8 によるコードスタイル・構文チェック
      # ----------------------------------------------------------------------
      - echo "-> 構文チェック実行中..."
      - cd $CODEBUILD_SRC_DIR
      - flake8 ui/ scripts/ --select=E9,F63,F7,F82 --show-source --statistics | tee flake8_critical.log
      - flake8 ui/ scripts/ --max-line-length=130 --ignore=E1,E2,E3,E5,W5 --exit-zero | tee flake8_warnings.log
      - |
        # クリティカルエラーがあればフラグを立てる
        if [ -s flake8_critical.log ]; then
          echo "❌ **Flake8**: 致命的な構文エラーが検出されました" >> report.md
          ERROR_FLAG=1
        fi
        if [ -s flake8_warnings.log ]; then
          WARN_COUNT=$(wc -l < flake8_warnings.log)
          echo "- ⚠️ **Flake8**: ${WARN_COUNT} 件のスタイル警告(純粋なスタイルは除外済み)" >> report.md
          echo "" >> report.md
          echo "<details><summary>Flake8 詳細 (最初の 10 件)</summary>" >> report.md
          echo "" >> report.md
          echo '```' >> report.md
          head -10 flake8_warnings.log >> report.md
          echo '```' >> report.md
          echo "" >> report.md
          echo "</details>" >> report.md
        else
          echo "- ✅ **Flake8**: 異常なし" >> report.md
        fi
        rm -f flake8_critical.log flake8_warnings.log

      # ----------------------------------------------------------------------
      # 3. Bandit による Python 脆弱性スキャン
      # ----------------------------------------------------------------------
      - echo "-> セキュリティスキャン実行中..."
      - bandit -r ui/ scripts/ -ll > bandit.log 2>&1 || true
      - |
        if grep -q "No issues identified" bandit.log; then
          echo "- ✅ **SAST (Bandit)**: 異常なし" >> report.md
        else
          HIGH=$(grep "Severity: High" bandit.log | wc -l | tr -d ' ')
          MED=$(grep "Severity: Medium" bandit.log | wc -l | tr -d ' ')
          LOW=$(grep "Severity: Low" bandit.log | wc -l | tr -d ' ')
          echo "- ⚠️ **SAST (Bandit)**: High=${HIGH} Medium=${MED} Low=${LOW} 件の検出" >> report.md
          echo "" >> report.md
          echo "<details><summary>Bandit 詳細 (最初の 10 件)</summary>" >> report.md
          echo "" >> report.md
          echo '```' >> report.md
          head -50 bandit.log >> report.md
          echo '```' >> report.md
          echo "" >> report.md
          echo "</details>" >> report.md
          # High があれば失敗、Medium/Low のみは警告
          if [ "$HIGH" -gt 0 ]; then
            ERROR_FLAG=1
          fi
        fi
        rm -f bandit.log

  build:
    commands:
      - echo "[BUILD] pytest による単体/結合テストの実行..."
      - cd $CODEBUILD_SRC_DIR
      - pytest tests/ -q --tb=short --cov=scripts --cov=ui > pytest.log 2>&1 || true
      - |
        # テスト結果を集計
        PASSED=$(grep -oE "[0-9]+ passed" pytest.log | tail -1 | grep -oE "^[0-9]+" || echo 0)
        FAILED=$(grep -oE "[0-9]+ failed" pytest.log | tail -1 | grep -oE "^[0-9]+" || echo 0)
        ERRORS=$(grep -oE "[0-9]+ error" pytest.log | tail -1 | grep -oE "^[0-9]+" || echo 0)
        SKIPPED=$(grep -oE "[0-9]+ skipped" pytest.log | tail -1 | grep -oE "^[0-9]+" || echo 0)
        # 最終行に 'XXX passed in Y.YYs' または 'no tests ran' が来る
        COVERAGE=$(grep -oE "TOTAL [0-9]+ +[0-9]+ +[0-9]+%?" pytest.log | tail -1 | grep -oE "[0-9]+%" || echo "N/A")
        TOTAL=$((PASSED + FAILED + ERRORS))

        if [ "$PASSED" -eq 0 ] && [ "$FAILED" -eq 0 ] && [ "$ERRORS" -eq 0 ]; then
          # テストが実行されなかった(カバレッジ測定の失敗など)
          if grep -q "no tests ran" pytest.log; then
            echo "- ❌ **Pytest**: テストが実行されていません (テストファイルが認識されない)" >> report.md
            ERROR_FLAG=1
          else
            echo "- ⚠️ **Pytest**: テスト結果を取得できません(ログを確認)" >> report.md
            ERROR_FLAG=1
          fi
        elif [ "$FAILED" -gt 0 ] || [ "$ERRORS" -gt 0 ]; then
          echo "- ❌ **Pytest**: ${PASSED}/${TOTAL} 通過, ${FAILED} 失敗, ${ERRORS} エラー" >> report.md
          ERROR_FLAG=1
        else
          echo "- ✅ **Pytest**: ${PASSED}/${TOTAL} 通過 (カバレッジ: ${COVERAGE})" >> report.md
        fi

        # 失敗時の詳細
        if [ "$FAILED" -gt 0 ] || [ "$ERRORS" -gt 0 ] || [ "$PASSED" -eq 0 ]; then
          echo "" >> report.md
          echo "<details><summary>Pytest ログ (最後の 30 行)</summary>" >> report.md
          echo "" >> report.md
          echo '```' >> report.md
          tail -30 pytest.log >> report.md
          echo '```' >> report.md
          echo "" >> report.md
          echo "</details>" >> report.md
        fi
        rm -f pytest.log

  post_build:
    commands:
      - echo "[POST-BUILD] PR コメント投稿処理"
      - |
        if [ -n "$PR_ID" ]; then
          if [ "$ERROR_FLAG" -eq 0 ]; then
            OVERALL="✅ **SUCCESS**: 全ての検証をクリアしました。マージ可能です。"
          else
            OVERALL="❌ **FAILED**: 検証エラーがあります。ログを確認して修正してください。"
          fi

          # AWS CodeBuildコンソールの詳細ログへの直リンクを組み立て
          LOG_URL="https://${AWS_DEFAULT_REGION}.console.aws.amazon.com/codesuite/codebuild/projects/${CODEBUILD_BUILD_ID%:*}/build/${CODEBUILD_BUILD_ID}/?region=${AWS_DEFAULT_REGION}"

          # コメント本文の組み立て
          {
            echo "### 🔍 CI/CD 自動検証レポート"
            echo ""
            echo "$OVERALL"
            echo ""
            echo "| 検証項目 | 結果 |"
            echo "|---------|------|"
            cat report.md
            echo ""
            echo "---"
            echo "🔍 **[CodeBuildの詳細ログを確認する]($LOG_URL)**"
            echo ""
            echo "<!-- buildspec-app-pr-auto-comment -->"
          } > comment.md

          aws codecommit post-comment-for-pull-request \
            --repository-name autotrade \
            --pull-request-id "$PR_ID" \
            --before-commit-id "$DEST_COMMIT" \
            --after-commit-id "$SOURCE_COMMIT" \
            --content "$(cat comment.md)"

          if [ $? -eq 0 ]; then
            echo "[OK] PR コメント投稿完了"
          else
            echo "[WARN] PR コメント投稿に失敗 (CodeCommit API の権限を確認)"
          fi
          rm -f comment.md report.md
        else
          echo "[INFO] PR トリガーではないため、コメント投稿をスキップ"
        fi

      # 最後にビルドの成否をAWSへ伝達(1つでもエラーがあればFAILさせる)
      - |
        if [ "$ERROR_FLAG" -ne 0 ]; then
          exit 1
        fi

buildspec-app.yml
version: 0.2

phases:
  pre_build:
    commands:
      - echo "[PRE-BUILD] ECRへのログイン..."
      - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
      - REPOSITORY_URI=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/autotrade
      - IMAGE_TAG=latest
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $REPOSITORY_URI

  build:
    commands:
      - echo "[BUILD] Dockerイメージのビルド開始..."
      - docker build -t $REPOSITORY_URI:$IMAGE_TAG .

  post_build:
    commands:
      - echo "[POST-BUILD] DockerイメージをECRへプッシュ..."
      - docker push $REPOSITORY_URI:$IMAGE_TAG
      - echo "[POST-BUILD] ECSデプロイ用の定義ファイルを生成..."
      # ※ "autotrade-container" の部分は、CDKの AppStack で定義するコンテナ名と完全に一致させる必要があります
      - printf '[{"name":"autotrade-container","imageUri":"%s"}]' $REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json

artifacts:
  files:
    - imagedefinitions.json

⑥PR作成と自動検証の実行

AIエージェント(または手動)でアプリコードを develop ブランチにPushし、PRを作成します。EventBridgeが検知してCodeBuildが走り、SAST(シークレットスキャン、脆弱性診断、Pytest)とPRへの結果コメント自動投稿が行われるようになります。

⑦マージとコンテナイメージの保存

PR検証成功後、main にマージします。マージを実行すると、アプリ側の CodePipeline が自動的に開始され、Dockerビルド → ECRへ最初のコンテナイメージがPushされます。
(※インフラが未構築のため、パイプラインの最後の「ECSへのデプロイ」フェーズが失敗して止まります)

フェーズ3 インフラの実装とデプロイ

⑧ インフラコード (CDK) の実装とパラメータ登録

CloudShellで、ngrokの認証トークン等をSSMパラメータストアに登録します。その後、autotrade-infra リポジトリに、VPC、ECR(参照)、EFS(アクセスポイント付き)、ECS(Fargate)、ngrokサイドカー、スケジュール起動ロジックを含めた本格的なCDKコードを実装します。

CloudShell
aws ssm put-parameter \
  --name "/autotrade/prod/ngrok-auth-token" \
  --value "ここにngrokの実際のトークンを貼り付けます" \
  --type "SecureString" \
  --overwrite
# 必要なパラメータを登録してください。

CDKデフォルトのものを下記のように変更します。

bin/infra.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { InfraStack } from '../lib/infra-stack';
import { AppStack } from '../lib/app-stack';

const app = new cdk.App();

// 1. 変数として InfraStack をインスタンス化する
const infraStack = new InfraStack(app, 'InfraStack', {});

// 2. AppStack をインスタンス化し、props として infraStack を渡す
new AppStack(app, 'AppStack', {
  infra: infraStack,
});

インフラのリソース定義を作成します。
下記はサンプルです。作成するリソースや、リソース名は自身のプロジェクトに応じて適宜変更してください。AIエージェントに依頼すればだいたい作成してくれます。

lib/infra-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as efs from 'aws-cdk-lib/aws-efs';
import * as ecr from 'aws-cdk-lib/aws-ecr';

export class InfraStack extends cdk.Stack {
  public readonly vpc: ec2.Vpc;
  public readonly efsFileSystem: efs.FileSystem;
  public readonly appSecurityGroup: ec2.SecurityGroup;
  public readonly repository: ecr.Repository;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 1. VPCの作成
    this.vpc = new ec2.Vpc(this, 'AutoTradeVpc', {
      maxAzs: 2,
      natGateways: 0,
    });

    // CDKの裏側の処理に対するCheckovの指摘(LambdaのDLQ、同時実行数、VPC配置)をスキップする設定
    // CDKが裏側で自動作成するVPCクリーンアップ用のLambda(CustomResourceProvider)を取得
    const cfnVpcRestrictDefaultSG = this.node.tryFindChild('Custom::VpcRestrictDefaultSGCustomResourceProvider') as cdk.CustomResourceProvider;
    if (cfnVpcRestrictDefaultSG) {
      // 内部のLambdaカスタムリソースのメタデータにCheckovスキップを注入
      const lambdaFunction = cfnVpcRestrictDefaultSG.node.findChild('Handler') as cdk.CfnResource;
      lambdaFunction.cfnOptions.metadata = {
        checkov: {
          skip: [
            { id: 'CKV_AWS_116', comment: 'CDK internal VPC custom resource Lambda does not need DLQ' },
            { id: 'CKV_AWS_115', comment: 'CDK internal VPC custom resource Lambda does not need concurrency limit' },
            { id: 'CKV_AWS_117', comment: 'CDK internal VPC custom resource Lambda is safe outside VPC' }
          ]
        }
      };
    }

    // 2. セキュリティグループの作成 (インバウンドはデフォルト全拒否)
    this.appSecurityGroup = new ec2.SecurityGroup(this, 'AppSecurityGroup', {
      vpc: this.vpc,
      allowAllOutbound: true,
      securityGroupName: 'autotrade-sg',
    });

    // 3. EFSの作成
    this.efsFileSystem = new efs.FileSystem(this, 'AutoTradeEfs', {
      vpc: this.vpc,
      encrypted: true,
      performanceMode: efs.PerformanceMode.GENERAL_PURPOSE,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
    });
    this.efsFileSystem.connections.allowDefaultPortFrom(this.appSecurityGroup);

    // 4. ECRリポジトリの作成
    this.repository = new ecr.Repository(this, 'AutoTradeRepo', {
      repositoryName: 'autotrade',
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      lifecycleRules: [{ maxImageCount: 3 }],
      // プッシュ時のイメージスキャンを有効化
      imageScanOnPush: true, 
    });

    // タグの不変性、KMS暗号化への対応:
    // パイプライン(latest上書き運用)の維持と、CMKの使用を回避するため、意図的にスキップを設定
    const cfnRepository = this.repository.node.defaultChild as ecr.CfnRepository;
    cfnRepository.cfnOptions.metadata = {
      checkov: {
        skip: [
          { id: 'CKV_AWS_51', comment: 'Allow tag overwriting for latest tag deployment strategy' },
          { id: 'CKV_AWS_136', comment: 'Use AWS managed key instead of KMS CMK to minimize costs' }
        ]
      }
    };
  }
}

次にアプリで利用するリソースの定義を作成します。
こちらもサンプルです。自身のアプリにあわせて作成するリソースや名称、設定を変更してください。AIエージェントにお願いすれば概ね作成してもらえます。

lib/app-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ssm from 'aws-cdk-lib/aws-ssm';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as efs from 'aws-cdk-lib/aws-efs';
import * as appscaling from 'aws-cdk-lib/aws-applicationautoscaling';
import { InfraStack } from './infra-stack';

interface AppStackProps extends cdk.StackProps {
  infra: InfraStack;
}

export class AppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: AppStackProps) {
    super(scope, id, props);
    const { vpc, efsFileSystem, appSecurityGroup, repository } = props.infra;

    // 1. ECS クラスターの作成
    const cluster = new ecs.Cluster(this, 'AutoTradeCluster', {
      vpc,
      clusterName: 'autotrade-cluster'
    });
    const cfnCluster = cluster.node.defaultChild as ecs.CfnCluster;
    cfnCluster.cfnOptions.metadata = {
      checkov: { skip: [{ id: 'CKV_AWS_65', comment: 'Container Insights incurs additional custom metric costs. Not required for this environment.' }] }
    };

    // 2. SSMパラメータの参照取得
    const googleKey = ecs.Secret.fromSsmParameter(ssm.StringParameter.fromSecureStringParameterAttributes(this, 'GoogleKey', { parameterName: '/autotrade/prod/google-api-key' }));
    
    const appLogGroup = new logs.LogGroup(this, 'AppLogGroup', {
      logGroupName: '/ecs/autotrade-prod',
      retention: logs.RetentionDays.ONE_MONTH,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    const cfnLogGroup = appLogGroup.node.defaultChild as logs.CfnLogGroup;
    cfnLogGroup.cfnOptions.metadata = {
      checkov: { skip: [{ id: 'CKV_AWS_158', comment: 'Using AWS default encryption to avoid $1/month CMK cost.' }] }
    };

    // 3. Fargate タスク定義
    const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', {
      cpu: 1024,
      memoryLimitMiB: 2048,
    });

    const efsPolicy = new iam.PolicyStatement({
      actions: [
        'elasticfilesystem:ClientMount',
        'elasticfilesystem:ClientWrite',
        'elasticfilesystem:ClientRootAccess'
      ],
      resources: [efsFileSystem.fileSystemArn],
    });
    taskDefinition.addToExecutionRolePolicy(efsPolicy);
    taskDefinition.addToTaskRolePolicy(efsPolicy);

    // =========================================================
    // EFS アクセスポイントの定義(パーミッションエラー解消)
    // =========================================================
    const accessPoint = new efs.AccessPoint(this, 'AutoTradeAccessPoint', {
      fileSystem: efsFileSystem,
      path: '/autotrade',
      posixUser: { uid: '1000', gid: '1000' },
      createAcl: { ownerUid: '1000', ownerGid: '1000', permissions: '755' },
    });

    // EFSボリュームをタスク定義に追加
    taskDefinition.addVolume({
      name: 'autotrade-efs-volume',
      efsVolumeConfiguration: {
        fileSystemId: efsFileSystem.fileSystemId,
        transitEncryption: 'ENABLED',
        authorizationConfig: {
          iam: 'ENABLED',
          accessPointId: accessPoint.accessPointId, // アクセスポイントをバインド
        }
      },
    });

    // コンテナ定義
    const container = taskDefinition.addContainer('autotrade-container', {
      image: ecs.ContainerImage.fromEcrRepository(repository, 'latest'),
      logging: ecs.LogDrivers.awsLogs({
        logGroup: appLogGroup,
        streamPrefix: 'app'
      }),
      command: ['./entrypoint.sh'],
      environment: {
        TZ: 'Asia/Tokyo',
        // アプリが利用する環境変数を定義
      },
      secrets: {
        GOOGLE_API_KEY: googleKey,
        // アプリが利用するシークレットキーを定義
      },
    });
    container.addPortMappings({ containerPort: 5000 });
    container.addMountPoints({
      containerPath: '/mnt/efs',
      sourceVolume: 'autotrade-efs-volume',
      readOnly: false,
    });

    // 本来はALBを利用すべきです。あくまで個人利用のアプリなので無料で利用できる下記を代用します。
    // ngrok サイドカーコンテナの定義
    const ngrokToken = ecs.Secret.fromSsmParameter(ssm.StringParameter.fromSecureStringParameterAttributes(this, 'NgrokToken', { parameterName: '/autotrade/prod/ngrok-auth-token' }));
    const ngrokContainer = taskDefinition.addContainer('ngrok-sidecar', {
      image: ecs.ContainerImage.fromRegistry('ngrok/ngrok:latest'),
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: 'ngrok',
        logGroup: appLogGroup
      }),
      command: [
        'http',
        'localhost:5000',
        '--log', 'stdout'
      ],
      secrets: {
        NGROK_AUTHTOKEN: ngrokToken,
      },
    });

    ngrokContainer.addContainerDependencies({
      container: container,
      condition: ecs.ContainerDependencyCondition.START,
    });

    // 4. Fargate サービスの作成
    const appService = new ecs.FargateService(this, 'AutoTradeService', {
      cluster,
      serviceName: 'autotrade-prod',
      taskDefinition,
      desiredCount: 1,
      capacityProviderStrategies: [{ capacityProvider: 'FARGATE_SPOT', weight: 1 }],
      securityGroups: [appSecurityGroup],
      assignPublicIp: true,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
      enableExecuteCommand: true,
      minHealthyPercent: 0,
      maxHealthyPercent: 100,
      availabilityZoneRebalancing: ecs.AvailabilityZoneRebalancing.DISABLED,
    });

    // =========================================================
    // ECS タスクのスケジュール起動・停止設定
    // =========================================================
    const scalableTarget = appService.autoScaleTaskCount({
      minCapacity: 0,
      maxCapacity: 1,
    });

    // ① 平日 7:00 起動
    scalableTarget.scaleOnSchedule('StartMorning', {
      schedule: appscaling.Schedule.cron({ minute: '0', hour: '7', weekDay: 'MON-FRI' }),
      timeZone: cdk.TimeZone.ASIA_TOKYO,
      minCapacity: 1,
      maxCapacity: 1,
    });

    // ② 平日 16:00 停止
    scalableTarget.scaleOnSchedule('StopAfternoon', {
      schedule: appscaling.Schedule.cron({ minute: '0', hour: '16', weekDay: 'MON-FRI' }),
      timeZone: cdk.TimeZone.ASIA_TOKYO,
      minCapacity: 0,
      maxCapacity: 0,
    });
  }
}

⑨ インフラのデプロイ実行

インフラコードを main にPushします。インフラ側の CodePipeline が走り、Checkovのスキャンを通過した後、AWS環境が構築されます。
autotrade-infraリポジトリの3つのファイルをCodeCommit上で編集した場合、Pipelineは3回実行されますが、気にせず進めてください。最初の2回は定義が完成しないため、かならず失敗します。

CodeBuildのログで、Checkovスキャンが実行されていることが確認できます。
※ここまで到達していない場合は、CDKコードのSyntax Errorになっている場合があります。

Checkovのセキュリティチェックに引っかかった例です。対応する必要があるかどうかを確認し、CDKコード修正を行ってください。下記ではCloudWatch Logsのロググループに保持期間を設定していないことを指摘されています。対応不要であればCDKコードにSkipのタグを追加します。

成功するとCloudFormationスタックが実行され、インフラリソースが作成されます。

⑩アプリパイプラインの最終同期

フェーズ2の最後で止まっていたアプリ側の CodePipeline を開き、エラーになっていたECSデプロイフェーズを「再試行」します。最新のタスクが稼働し、構築完了します。

おわりに

今回は個々の仕組みの解説はせず、パイプライン構築の最低限の内容を一式詰め込み、すぐに利用できるものをコンセプトにしました。内容をアレンジすれば他の環境にも適用できるのではないかと思います。誰かのお役に立てれば幸いです。

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?