Backlog GitでAWS CloudFormationのCI/CDパイプラインを構築する

Backlog GitでAWS CloudFormationのCI/CDパイプラインを構築する

2026.01.20

Backlog GitでAWS CloudFormationのCI/CDパイプラインを構築する

はじめに

AWSのインフラ管理にCloudFormation (IaC) を採用しているプロジェクトは多いですが、ソースコード管理にBacklog Gitを使用している場合、CI/CDの構築に頭を悩ませることがあります。

GitHubやCodeCommitであればAWS CodePipelineとのネイティブな統合が容易ですが、Backlog Gitには「AWSへのGit Sync機能」や「CodePipelineへの直接トリガー機能」が存在しません。(2026/01/12基準)

image1

そこで今回は、API GatewayとLambda、CodeBuildを組み合わせることで、Backlog Gitへのプッシュ(マージ)をトリガーに、変更されたCloudFormationテンプレートのみを自動デプロイするパイプラインを構築しました。

image2

本記事は全2回構成の第1部として、背景、アーキテクチャ、そして構築手順(CloudFormationテンプレート)について解説します。

1. 背景と目的

課題

  • 運用の手間: ローカル端末からCLIでデプロイする場合、オペレーションミスのリスクや履歴管理の複雑さがある。
  • Backlog Gitの制約: AWSサービスとの直接連携がないため、CI/CDの実装が手動またはJenkins等の外部サーバー依存になりがち。

目標

  • 自動デプロイ: mainブランチへのプルリクエスト(PR)がマージされたタイミングで自動デプロイを行う。
  • 安全性: 本番環境に変更を加える前に、**Drift(ドリフト:構成差分)**を検知し、予期せぬ上書きを防ぐ。
  • 可視化: デプロイの成功・失敗・Drift検知の結果、あるいは「変更なし」のステータスを、BacklogのPRコメントに自動通知する。

運用フロー(前提)

  1. 開発者はトピックブランチでテンプレートを修正し、mainブランチへPRを作成する。
  2. レビュー完了後、mainへマージする。
  3. このマージをトリガーとしてCI/CDが作動する。

2. アーキテクチャ

システム構成は以下の通りです。サーバーレス構成により、常時稼働のビルドサーバーは不要です。

  1. Backlog Webhook: GitリポジトリへのPushイベントを検知し、API GatewayへPOSTリクエストを送信。
  2. API Gateway & Lambda: リクエストを受け取り、mainブランチへのマージであるか判定し、CodeBuildを起動。
  3. CodeBuild:
    • リポジトリをクローンし、変更されたYAMLファイルを特定。
    • ChangeSetを作成し、Drift(手動変更による乖離)がないかチェック。
    • 問題なければデプロイ(Execute ChangeSet)を実行。
    • 結果(成功、失敗、変更なし)をBacklog API経由でPRにコメント投稿。

image3 (1)

3. 構築手順

ステップ1: Backlog側の事前準備

パイプラインを動かすために、Backlog側で以下の情報を取得しておきます。

  1. API Keyの発行:
  2. Git認証情報:
    • HTTPSでクローンするためのユーザー名とパスワードを用意します。2段階認証を利用している場合は、専用のパスワードを発行してください。
    • 特別なパスワードを発行する
  3. スペースIDの確認:
    • URL https://[space-id].backlog.jp/git/ から確認できます。

ステップ2: CloudFormationデプロイ

以下のテンプレートを使用して、CI/CD基盤を一撃で構築します。
このテンプレートには、WebHook受信用API、Lambda、権限周り、そしてデプロイロジックを含むCodeBuildプロジェクトが含まれています。(ブログ最後に記載)

  • 必要なパラメーター
キー 備考
BacklogApiKey ステップ1で発行したAPIキー
BacklogDomain backlog.jp backlog プロジェクのURLで確認
BacklogGitPassword ステップ1で発行した特別なパスワード
BacklogGitUser Backlogログイン時のメールアドレス
BacklogSpaceId ステップ1で確認したスペースID
TargetBranch main 運用前提で使用するmainブランチのネーム

ステップ3: Backlog Webhookの設定

CloudFormationのデプロイが完了したら、Outputsに出力された WebhookUrl をコピーします。

  1. BacklogのGit設定画面へ移動します。
  2. 「Webhook」を追加します。
  3. Webhook URL: コピーしたURLを貼り付けます。
  4. 通知するイベント: 「Gitプッシュ」を選択します。

これで導入準備は完了です。次回の第2部では、このテンプレートの中身、特に「どのようにして安全なデプロイを実現しているか」のロジックを詳細に解説します。

動作結果の確認

  1. Backlog Git利用方法を参考しテストとしてSecurityGroupeの設定を一部変更しました。
  2. Backlog で PR(Pull Request)を実施しmainブランチにマージを実施します。

screenshot 2026-01-12 19.06.58

  1. AWS コンソールに移動し CodeBulide ログを確認しデプロイが成功していることを確認します。

screenshot 2026-01-12 19.32.30

  1. Backlog Git のPRコメント履歴から成功されているコメントを確認します。

screenshot 2026-01-12 19.36.26

CloudFormation テンプレート

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Backlog Git CI/CD Pipeline - Auto deploy CloudFormation on PR merge'

Parameters:
  BacklogSpaceId:
    Type: String
    Description: 'Backlog Space ID (Subdomain e.g. "my-space" in my-space.backlog.jp)'

  BacklogDomain:
    Type: String
    Default: 'backlog.jp'
    AllowedValues:
      - backlog.jp
      - backlog.com
    Description: 'Backlog Domain (suffix)'

  BacklogApiKey:
    Type: String
    NoEcho: true
    Description: 'Backlog API Key (for PR comments)'

  BacklogGitUser:
    Type: String
    Description: 'Backlog Git username (email address)'

  BacklogGitPassword:
    Type: String
    NoEcho: true
    Description: 'Backlog Git password (use special password if 2FA enabled)'

  TargetBranch:
    Type: String
    Default: 'main'
    Description: 'Target branch for deployment'

  TemplatePath:
    Type: String
    Default: '.'
    Description: 'Path to CloudFormation templates (use . for repository root)'

Resources:
  # ========================================
  # Secrets Manager - Git credentials
  # ========================================
  BacklogSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Sub '${AWS::StackName}-secret'
      SecretString: !Sub |
        {
          "apiKey": "${BacklogApiKey}",
          "gitUser": "${BacklogGitUser}",
          "gitPassword": "${BacklogGitPassword}"
        }

  # ========================================
  # IAM Role - For Lambda
  # ========================================
  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: TriggerCodeBuild
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: codebuild:StartBuild
                Resource: !GetAtt CodeBuildProject.Arn

  # ========================================
  # IAM Role - For CodeBuild
  # ========================================
  CodeBuildRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: codebuild.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: CodeBuildPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: '*'
              - Effect: Allow
                Action:
                  - cloudformation:*
                Resource: '*'
              - Effect: Allow
                Action:
                  - ec2:*
                  - iam:*
                Resource: '*'
              - Effect: Allow
                Action: secretsmanager:GetSecretValue
                Resource: !Ref BacklogSecret

  # ========================================
  # API Gateway - Webhook endpoint
  # ========================================
  WebhookApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: !Sub '${AWS::StackName}-webhook'

  WebhookResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref WebhookApi
      ParentId: !GetAtt WebhookApi.RootResourceId
      PathPart: webhook

  WebhookMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref WebhookApi
      ResourceId: !Ref WebhookResource
      HttpMethod: POST
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WebhookLambda.Arn}/invocations'

  ApiDeployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn: WebhookMethod
    Properties:
      RestApiId: !Ref WebhookApi
      StageName: prod

  LambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref WebhookLambda
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebhookApi}/*/POST/webhook'

  # ========================================
  # Lambda - Webhook handler
  # ========================================
  WebhookLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${AWS::StackName}-webhook'
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt LambdaRole.Arn
      Timeout: 30
      Environment:
        Variables:
          CODEBUILD_PROJECT: !Ref CodeBuildProject
          TARGET_BRANCH: !Ref TargetBranch
      Code:
        ZipFile: |
          import json, boto3, os, logging

          logger = logging.getLogger()
          logger.setLevel(logging.INFO)
          codebuild = boto3.client('codebuild')

          def handler(event, context):
              logger.info(f"Event: {json.dumps(event)}")

              try:
                  body = json.loads(event.get('body', '{}'))
                  activity_type = body.get('type')
                  target_branch = os.environ.get('TARGET_BRANCH', 'main')

                  # Only process Git push (type: 12)
                  if activity_type != 12:
                      return {'statusCode': 200, 'body': json.dumps({'message': f'Ignored type {activity_type}'})}

                  # Get repository info
                  project = body.get('project', {})
                  content = body.get('content', {})
                  repository = content.get('repository', {})

                  project_key = project.get('projectKey', '')
                  repo_name = repository.get('name', '')

                  # Get branch name (refs/heads/main ? main)
                  ref = content.get('ref', '')
                  pushed_branch = ref.replace('refs/heads/', '') if ref.startswith('refs/heads/') else ref

                  # Get commit message
                  revisions = content.get('revisions', [])
                  commit_msg = revisions[0].get('comment', '') if revisions else ''

                  logger.info(f"Push: {repo_name}, Branch: {pushed_branch}, Target: {target_branch}")

                  # Ignore non-target branches
                  if pushed_branch != target_branch:
                      return {'statusCode': 200, 'body': json.dumps({'message': f'Ignored branch {pushed_branch}'})}

                  # Ignore test data (Backlog "Test" button sends dummy data)
                  if repo_name == 'app' or body.get('id') == 0:
                      return {'statusCode': 200, 'body': json.dumps({'message': 'Test data ignored'})}

                  # Trigger CodeBuild
                  response = codebuild.start_build(
                      projectName=os.environ['CODEBUILD_PROJECT'],
                      environmentVariablesOverride=[
                          {'name': 'PROJECT_KEY', 'value': project_key, 'type': 'PLAINTEXT'},
                          {'name': 'REPO_NAME', 'value': repo_name, 'type': 'PLAINTEXT'},
                          {'name': 'BRANCH', 'value': pushed_branch, 'type': 'PLAINTEXT'},
                          {'name': 'COMMIT_MSG', 'value': commit_msg[:200], 'type': 'PLAINTEXT'},
                      ]
                  )

                  build_id = response['build']['id']
                  logger.info(f"Build started: {build_id}")

                  return {'statusCode': 200, 'body': json.dumps({'build_id': build_id})}

              except Exception as e:
                  logger.error(str(e))
                  return {'statusCode': 500, 'body': str(e)}

  # ========================================
  # CodeBuild - Deploy execution
  # ========================================
  CodeBuildProject:
    Type: AWS::CodeBuild::Project
    Properties:
      Name: !Sub '${AWS::StackName}-deploy'
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Artifacts:
        Type: NO_ARTIFACTS
      Environment:
        Type: LINUX_CONTAINER
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0
        EnvironmentVariables:
          - Name: BACKLOG_SPACE_ID
            Value: !Ref BacklogSpaceId
          - Name: BACKLOG_DOMAIN
            Value: !Ref BacklogDomain
          - Name: SECRET_ARN
            Value: !Ref BacklogSecret
          - Name: TARGET_BRANCH
            Value: !Ref TargetBranch
          - Name: TEMPLATE_PATH
            Value: !Ref TemplatePath
      TimeoutInMinutes: 30
      Source:
        Type: NO_SOURCE
        BuildSpec: |
          version: 0.2
          phases:
            install:
              runtime-versions:
                python: 3.12
              commands:
                - echo "=== Backlog Git CI/CD ==="
                - echo "Repo=$REPO_NAME Branch=$BRANCH"
            pre_build:
              commands:
                - SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_ARN --query SecretString --output text)
                - GIT_USER=$(echo $SECRET_JSON | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('gitUser',''))")
                - GIT_PASS=$(echo $SECRET_JSON | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('gitPassword',''))")
                - GIT_USER_ENC=$(echo "$GIT_USER" | python3 -c "import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read().strip(),safe=''))")
                - GIT_PASS_ENC=$(echo "$GIT_PASS" | python3 -c "import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read().strip(),safe=''))")
                - GIT_URL="https://${GIT_USER_ENC}:${GIT_PASS_ENC}@${BACKLOG_SPACE_ID}.${BACKLOG_DOMAIN}/git/${PROJECT_KEY}/${REPO_NAME}.git"
                - echo "Cloning $REPO_NAME..."
                - git clone -b $BRANCH $GIT_URL /tmp/repo
                - cd /tmp/repo && git log --oneline -3
                - COMMIT_LOG=$(git log --oneline -1)
                - PR_NUMBER=$(echo "$COMMIT_LOG" | sed -n 's/.*Merge pull request[^0-9]*\([0-9][0-9]*\).*/\1/p')
                - echo "PR_NUMBER=$PR_NUMBER" && echo $PR_NUMBER > /tmp/pr_number.txt
                - CHANGED=$(git diff --name-only HEAD~1..HEAD -- "$TEMPLATE_PATH/*.yaml" 2>/dev/null || true)
                - echo "Changed files:" && echo "$CHANGED"
                - echo "$CHANGED" > /tmp/changed.txt
            build:
              commands:
                - cd /tmp/repo
                - echo "=== Processing stacks ==="
                - RESULTS=
                - FAILED=0
                - FAIL_REASON=
                - |
                  for FILE in $(cat /tmp/changed.txt); do
                    if [ -z "$FILE" ]; then continue; fi
                    STACK=$(basename $FILE .yaml)
                    echo "--- Stack $STACK ---"
                    if ! aws cloudformation describe-stacks --stack-name $STACK 2>/dev/null; then
                      echo "Creating new stack..."
                      aws cloudformation create-stack --stack-name $STACK --template-body file://$FILE --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM || true
                      aws cloudformation wait stack-create-complete --stack-name $STACK 2>/dev/null || true
                      RESULTS="$RESULTS$STACK:CREATED "
                      continue
                    fi
                    CS="cs-$(date +%s)"
                    echo "Creating change set $CS..."
                    aws cloudformation create-change-set --stack-name $STACK --change-set-name $CS --template-body file://$FILE --deployment-mode REVERT_DRIFT --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM 2>&1 || true
                    sleep 10
                    STATUS=$(aws cloudformation describe-change-set --stack-name $STACK --change-set-name $CS --query Status --output text 2>/dev/null || echo "UNKNOWN")
                    REASON=$(aws cloudformation describe-change-set --stack-name $STACK --change-set-name $CS --query StatusReason --output text 2>/dev/null || echo "none")
                    echo "Status=$STATUS Reason=$REASON"
                    if [ "$STATUS" = "FAILED" ]; then
                      if echo "$REASON" | grep -q "didn't contain changes"; then
                        RESULTS="$RESULTS$STACK:NO_CHANGE "
                      else
                        RESULTS="$RESULTS$STACK:FAILED "
                        FAILED=1
                        FAIL_REASON="$FAIL_REASON ChangeSet-failed-$STACK"
                      fi
                      aws cloudformation delete-change-set --stack-name $STACK --change-set-name $CS 2>/dev/null || true
                    elif [ "$STATUS" = "CREATE_COMPLETE" ]; then
                      DRIFT_COUNT=$(aws cloudformation describe-change-set --stack-name $STACK --change-set-name $CS --output json 2>/dev/null | grep "ACTUAL_STATE" | wc -l)
                      DRIFT_COUNT=$(echo $DRIFT_COUNT | tr -d ' ')
                      echo "Drift changes detected: $DRIFT_COUNT"
                      if [ "$DRIFT_COUNT" != "0" ]; then
                        echo "DRIFT DETECTED - Failing deployment"
                        RESULTS="$RESULTS$STACK:DRIFT_DETECTED "
                        FAILED=1
                        FAIL_REASON="$FAIL_REASON Drift-in-$STACK"
                        aws cloudformation delete-change-set --stack-name $STACK --change-set-name $CS 2>/dev/null || true
                      else
                        echo "No drift - Executing..."
                        aws cloudformation execute-change-set --stack-name $STACK --change-set-name $CS
                        aws cloudformation wait stack-update-complete --stack-name $STACK 2>/dev/null || true
                        RESULTS="$RESULTS$STACK:DEPLOYED "
                      fi
                    else
                      RESULTS="$RESULTS$STACK:UNKNOWN "
                      aws cloudformation delete-change-set --stack-name $STACK --change-set-name $CS 2>/dev/null || true
                    fi
                  done
                - echo "=== RESULTS ===" && echo $RESULTS
                - echo $RESULTS > /tmp/results.txt
                - echo "$FAIL_REASON" > /tmp/fail_reason.txt
                - if [ "$FAILED" = "1" ]; then exit 1; fi
            post_build:
              commands:
                - RESULTS=$(cat /tmp/results.txt 2>/dev/null)
                - |
                  if [ -z "$RESULTS" ]; then
                    RESULTS="CloudFormation templates were not changed. Deployment skipped."
                  fi
                - FAIL_REASON=$(cat /tmp/fail_reason.txt 2>/dev/null || true)
                - PR_NUMBER=$(cat /tmp/pr_number.txt 2>/dev/null || true)
                - if [ "$CODEBUILD_BUILD_SUCCEEDING" = "1" ]; then STATUS="SUCCESS"; else STATUS="FAILED"; fi
                - echo "Build $STATUS - $RESULTS"
                - SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id $SECRET_ARN --query SecretString --output text)
                - API_KEY=$(echo $SECRET_JSON | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('apiKey',''))")
                - if [ -n "$PR_NUMBER" ] && [ -n "$API_KEY" ]; then curl -s -X POST "https://${BACKLOG_SPACE_ID}.${BACKLOG_DOMAIN}/api/v2/projects/${PROJECT_KEY}/git/repositories/${REPO_NAME}/pullRequests/${PR_NUMBER}/comments?apiKey=${API_KEY}" -H "Content-Type:application/x-www-form-urlencoded" --data-urlencode "content=[CI/CD $STATUS] $RESULTS $FAIL_REASON" || echo "Failed to post comment"; fi

Outputs:
  WebhookUrl:
    Description: 'Webhook URL to register in Backlog'
    Value: !Sub 'https://${WebhookApi}.execute-api.${AWS::Region}.amazonaws.com/prod/webhook'

  CodeBuildProject:
    Description: 'CodeBuild project name'
    Value: !Ref CodeBuildProject

クラスメソッドオペレーションズ株式会社について

クラスメソッドオペレーションズ株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。少しでもご興味あれば、クラスメソッドオペレーションズ株式会社WEBサイトをご覧ください。

この記事をシェアする

FacebookHatena blogX

関連記事