Backlog GitでAWS CloudFormationのCI/CDパイプラインを構築する
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基準)

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

本記事は全2回構成の第1部として、背景、アーキテクチャ、そして構築手順(CloudFormationテンプレート)について解説します。
1. 背景と目的
課題
- 運用の手間: ローカル端末からCLIでデプロイする場合、オペレーションミスのリスクや履歴管理の複雑さがある。
- Backlog Gitの制約: AWSサービスとの直接連携がないため、CI/CDの実装が手動またはJenkins等の外部サーバー依存になりがち。
目標
- 自動デプロイ:
mainブランチへのプルリクエスト(PR)がマージされたタイミングで自動デプロイを行う。 - 安全性: 本番環境に変更を加える前に、**Drift(ドリフト:構成差分)**を検知し、予期せぬ上書きを防ぐ。
- 可視化: デプロイの成功・失敗・Drift検知の結果、あるいは「変更なし」のステータスを、BacklogのPRコメントに自動通知する。
運用フロー(前提)
- 開発者はトピックブランチでテンプレートを修正し、
mainブランチへPRを作成する。 - レビュー完了後、
mainへマージする。 - このマージをトリガーとしてCI/CDが作動する。
2. アーキテクチャ
システム構成は以下の通りです。サーバーレス構成により、常時稼働のビルドサーバーは不要です。
- Backlog Webhook: GitリポジトリへのPushイベントを検知し、API GatewayへPOSTリクエストを送信。
- API Gateway & Lambda: リクエストを受け取り、
mainブランチへのマージであるか判定し、CodeBuildを起動。 - CodeBuild:
- リポジトリをクローンし、変更されたYAMLファイルを特定。
- ChangeSetを作成し、Drift(手動変更による乖離)がないかチェック。
- 問題なければデプロイ(Execute ChangeSet)を実行。
- 結果(成功、失敗、変更なし)をBacklog API経由でPRにコメント投稿。

3. 構築手順
ステップ1: Backlog側の事前準備
パイプラインを動かすために、Backlog側で以下の情報を取得しておきます。
- API Keyの発行:
- Git認証情報:
- HTTPSでクローンするためのユーザー名とパスワードを用意します。2段階認証を利用している場合は、専用のパスワードを発行してください。
- 特別なパスワードを発行する
- スペースIDの確認:
- URL
https://[space-id].backlog.jp/git/から確認できます。
- URL
ステップ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 をコピーします。
- BacklogのGit設定画面へ移動します。
- 「Webhook」を追加します。
- Webhook URL: コピーしたURLを貼り付けます。
- 通知するイベント: 「Gitプッシュ」を選択します。
これで導入準備は完了です。次回の第2部では、このテンプレートの中身、特に「どのようにして安全なデプロイを実現しているか」のロジックを詳細に解説します。
動作結果の確認
- Backlog Git利用方法を参考しテストとしてSecurityGroupeの設定を一部変更しました。
- Backlog で PR(Pull Request)を実施し
mainブランチにマージを実施します。

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

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

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サイトをご覧ください。







