Backlog の Git リポジトリを AWS CodeCommit に同期する

これは フェンリル デザインとテクノロジー Advent Calendar 2022 2日目の記事です。

GIMLE チームの野田です。 GIMLE では、お客様によるクラウドサービスを活用したビジネスの推進を支援しています。 この記事では、以前お客様が DevOps 領域で抱えていた課題を題材に、クラウドサービスを活用して解決する方法を紹介します。

背景

アプリケーションのコードを変更し、すぐにビルド・テスト・デプロイできるようにする CI/CD は、ビジネスニーズに即したサービスを提供し続ける上で、もはや必須とも言える開発技法です。 AWS では、AWS CodeCommit・AWS CodeBuild・AWS CodeDeploy・AWS CodePipeline といった Code シリーズが提供されているため、サービスの提供に必要な一通りの CI/CD 環境を整えることができます。

AWS では、各システム毎や環境毎に AWS アカウントを分離していくことが推奨されています。 この方針に従うと、各 AWS アカウントで個別に CI/CD フローを構築していくことになります。 しかし、各システムのコードリポジトリは、システム毎に用意するよりも、組織内での共有を図るため、共通でアクセスできる場所に用意しておきたくなるケースがあるかと思います。 また、システムが不要になった際にもコードは残しておきたいはずですが、AWS アカウントごと削除することになっても、コードが中央のリポジトリで管理されていれば、退避させずに済みます。

フェンリルでは、組織内のコードリポジトリとして主に GitHub Enterprise Server、または GitHub Enterprise Cloud を利用し、CodeBuild 等と連携させて CI/CD を構成しています。 一方、先述のお客様は、組織内のコードリポジトリとして Backlog で提供される Git リポジトリを使用し、AWS 上で構築したシステムへのデプロイをしたいと考えておられました。

CodePipeline や CodeBuild は、ソースリポジトリとして GitHub・GitHub Enterprise・Bitbucket などをサポートしているため、簡単に連携できます。 また、GitLab ではリポジトリのミラーリング機能が提供されているため、GitLab と CodeCommit のリポジトリを同期させ、CI/CD フローを構成することができます。 しかし、Backlog では上記のようなサポートが提供されていないため、どうにか Code シリーズへ効率良く連携する方法がないかと調べ、以下に紹介する方法を考えてみました。

方針

API Gateway + Lambda

まず思いつく方法としては、Backlog の Webhook を Amazon API Gateway と AWS Lambda で処理して、リポジトリのデータを取得する方法です。 これは、クラスメソッドさんのブログでも紹介されています。

この方法の懸念点として、毎回のソースコードの変更は部分的であるにも関わらず、リポジトリ全体を取得してしまうため、データ転送量が多くなってしまいがちです。 この対策としては、Git の Shallow Clone や Partial Clone といった機能を利用し、ビルドに必要なデータのみ取得する方法が挙げられます。 また、実際に試してはいないですが、Lambda Function から Amazon EFS のファイルシステムをマウントし、以前に取得した時点から更新差分のみ取得できるようにすると、データ転送量を抑えて、効率良く扱えそうです。 ただ、ここまで考慮してコードを記述するのは、なかなか骨が折れそうですね。

API Gateway + CodeBuild

ところで、API Gataway には、別の AWS サービスを呼び出すための、サービスプロキシとして使う方法があるのをご存知でしょうか。 この機能を利用すると、Lambda に限らず、様々な AWS のサービスを API Gateway から呼び出すことができます。

ここでは、Backlog の Webhook を API Gateway で受けて CodeBuild を動作させ、Backlog の Git リポジトリが更新される度に、CodeCommit に自動的に同期させる方法を考えてみます。 リポジトリの同期処理に Lambda ではなく CodeBuild を利用するのは、CodeBuild が備える Amazon S3 によるキャッシュ機能を利用したいためです。 また、CodeBuild でソースコードを取得して直接アプリケーションをビルドしても良いのですが、一旦 CodeCommit にソースコードをミラーリングすることで、Amazon CodeGuru Reviewer によるコードの分析機能を利用できるようにもなります。

今回構成するアーキテクチャの全体像

この記事の後半では、これらの構成を実現する CloudFormation テンプレートも紹介します。

必要なリソースを準備

前提

アプリケーションのソースコードが保管されている Backlog のプロジェクトとリポジトリは既にあると想定します。

本記事では CodeBuild を VPC 外で動作させますが、Backlog の接続元 IP アドレスによるアクセス制限機能を利用している場合は、CodeBuild を Elastic IP を割り当てた NAT Gateway のある VPC 内で動作させ、アクセス元 IP アドレスを制限するなどの追加設定が必要になると考えられます。

Backlog の認証情報を登録

本記事では、Backlog の Git リポジトリには、公開鍵認証による SSH でアクセスします。 ssh-keygen コマンド等で鍵ペアを生成し、Backlog の個人設定から公開鍵を登録しておきます。

秘密鍵は、Systems Manager のパラメータストアに、安全な文字列として登録します。

PEM 形式で秘密鍵を登録しておきます

CodeCommit リポジトリを作成

ミラー先の CodeCommit リポジトリを作成します。

sample という名前のリポジトリを作成しました

S3 バケットを作成

CodeBuild のビルドキャッシュを保管する、空の S3 バケットを作成します。

S3 バケット名はグローバルでユニークになるよう適当に suffix を付与しておきます

S3 バケットのライフサイクルルールで、保管してから一定期間が経過したオブジェクトを自動削除するように設定しておくと、不要になったオブジェクトが自動的に削除されて良いでしょう。

30 日後に自動削除されるように設定しておきます

CodeBuild でリポジトリをミラーする

CodeBuild で使う IAM ロールを作成します。 信頼されたエンティティには、CodeBuild(codebuild.amazonaws.com)を指定します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "codebuild.amazonaws.com"
      }
    }
  ]
}

許可ポリシーでは、以下のサービスへのアクセス権限を割り当てます。 <...> の箇所は、自身の環境に合わせて書き換えてください。

  • CloudWatch Logs へのログ出力
  • キャッシュ保管用 S3 バケットへの読み書き
  • CodeCommit リポジトリへの Push
  • Systems Manager パラメータストアで暗号化して保管する SSH 秘密鍵の取得
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::<s3-cache-bucket-name>/*"
    },
    {
      "Effect": "Allow",
      "Action": "codecommit:GitPush",
      "Resource": "arn:aws:codecommit:<aws-region>:<aws-account-id>:<codecommit-repository-name>"
    },
    {
      "Effect": "Allow",
      "Action": "ssm:GetParameters",
      "Resource": "arn:aws:ssm:<aws-region>:<aws-account-id>:parameter/<ssm-parameter-name-without-first-slash>"
    },
    {
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "*",
      "Condition": {
        "StringLike": {
          "kms:RequestAlias": "aws/ssm"
        }
      }
    }
  ]
}

IAM ロールを作成したら、次は CodeBuild でビルドプロジェクトを作成します。 ソースプロバイダの指定は不要です。

同時ビルド数を 1 に制限しておいても良いでしょう

ビルド環境には Amazon Linux 2 ベースのイメージ(amazonlinux2-aarch64-standard:2.0)を使用します。 IAM ロールには、先ほど作成した CodeBuild 用のロールを指定します。

タイムアウトはデフォルトの時間から少し短めに変更しておくと良いでしょう

Buildspec には、以下のビルドコマンドを挿入します。 env: ブロック内の <...> の箇所は、自身の環境に合わせて書き換えてください。

version: 0.2
env:
  variables:
    BACKLOG_SPACE_KEY: "<backlog-space-key>"  # xxx.backlog.zzz の xxx
    BACKLOG_DOMAIN_NAME: "<backlog-domain-name>"  # backlog.com または backlog.jp
    BACKLOG_PROJECT_KEY: "<backlog-project-key>"
    BACKLOG_REPOSITORY_NAME: "<baclog-repository-name>"
    CODECOMMIT_REPOSITORY_NAME: "<codecommit-repository-name>"
  parameter-store:
    SRC_GIT_CREDENTIAL: "<systems-manager-secure-parameter-name>"
phases:
  install:
    commands:
      - pip3 install git-remote-codecommit
  pre_build:
    commands:
      - eval $(ssh-agent)
      - ssh-add - <<< "$SRC_GIT_CREDENTIAL"
      - ssh-keyscan "${BACKLOG_SPACE_KEY}.git.${BACKLOG_DOMAIN_NAME}" >> ~/.ssh/known_hosts
  build:
    commands: |
      if [ ! -d "${BACKLOG_REPOSITORY_NAME}.git" ]; then
        git clone --mirror "${BACKLOG_SPACE_KEY}@${BACKLOG_SPACE_KEY}.git.${BACKLOG_DOMAIN_NAME}:/${BACKLOG_PROJECT_KEY}/${BACKLOG_REPOSITORY_NAME}.git"
        cd "${BACKLOG_REPOSITORY_NAME}.git"
        git remote add codecommit "codecommit::${AWS_REGION}://${CODECOMMIT_REPOSITORY_NAME}"
      else
        cd "${BACKLOG_REPOSITORY_NAME}.git"
        git fetch --prune origin
      fi
      git push --mirror --prune codecommit
cache:
  paths:
    - ./**/*
    - /root/.cache/pip/**/*

Buildspec の内容を解説しておきます。

parameter-store ブロックで指定している環境変数には、Systems Manager パラメータトアから取得して復号された値がセットされます。 この値は機密情報のため、ログに出力されてしまうとマズいですが、CodeBuild では自動的に **** のようにマスクされるため、安全に扱うことができます。

install フェーズで追加している git-remote-codecommit は、AWS の一時的な認証情報を使用して CodeCommit にアクセスするために、Git を拡張するユーティリティです。

pre_build フェーズでは ssh-agent 動作させ、ソースリポジトリへアクセスするための SSH 秘密鍵を読み込ませています。 また、ssh-keyscan コマンドで、接続先のサーバーの公開鍵を取得し、known_hosts ファイルを生成しています。

build フェーズでは、 ソースリポジトリ名.git というディレクトリの有無を確認し、初回実行時のようにディレクトリが存在しない場合は git clone --mirror コマンドを実行し、ソースリポジトリのベアリポジトリ(作業ディレクトリを持たないリポジトリ)をローカルに作成します。 その後、 git remote add コマンドで CodeCommit をリモートリポジトリとして登録し、 git push --mirror コマンドで CodeCommit にリポジトリの内容を登録します。

build フェーズで ソースリポジトリ名.git というディレクトリが既に存在する場合は、前回実行時のキャッシュが復元されている状態のため、git fetch コマンドでソースリポジトリの更新を取得します。 --prune オプションを付与しているため、ソースリポジトリでブランチや Pull Request が削除された場合は、その状態も同期されます。

cache ブロックでは、次回実行時のデータ転送量を抑えて効率化するため、リポジトリの内容と、Python パッケージインストール時のキャッシュ領域を保存するように指定しています。

ビルドプロジェクトの設定に戻ります。 このビルドプロジェクトにはビルドアーティファクト(成果物)はありませんが、先の cache ブロックで指定したデータを保管するため、事前に作成しておいた S3 バケットでのキャッシュを有効化しておきます。

既に S3 バケットのライフサイクルポリシーを設定しているためキャッシュのライフサイクルは設定不要です

ここまで入力してビルドプロジェクトを作成したら、一旦手動でビルドを開始し、動作を確認してみましょう。 ビルドが正常に完了すると、CodeCommit リポジトリに Backlog リポジトリの内容が同期されているでしょう。 また、キャッシュ保管用の S3 バケットには、UUID が付与されたオブジェクトが生成されているはずです。 さらに、CloudWatch ロググループには、/aws/codebuild/<build-project-name> というロググループが生成され、ビルド時のログが出力されていることが期待されます。 CloudWatch ロググループのログ保持期間は、デフォルトでは無期限になっているため、一定期間が経過した場合に自動削除するように設定しておくと良いでしょう。 ビルドがうまくいかなかった場合は、各設定が合っているか、出力されたログなどを頼りに確認してみてください。

API Gateway から CodeBuild を動作させる

CodeBuild が正常に動作することが確認できたら、次は API Gateway の設定をしていきます。

API Gateway に CodeBuild のプロジェクトを開始する権限を付与するため、API Gateway 用の IAM ロールを作成します。

信頼されたエンティティには、API Gateway(apigateway.amazonaws.com)を指定します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "apigateway.amazonaws.com"
      }
    }
  ]
}

許可ポリシーでは、CodeBuild のプロジェクトを開始する権限を付与します。 <...> の箇所は、自身の環境に合わせて書き換えてください。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "codebuild:StartBuild",
      "Resource": "arn:aws:codebuild:<aws-region>:<aws-account-id>:project/<codebuild-project-name>"
    }
  ]
}

API Gateway のログを CloudWatch に出力する場合は、マネージドポリシーの AmazonAPIGatewayPushToCloudWatchLogs を付与しておくと良いでしょう。

IAM ロールを作成したら、API Gateway で REST API を作成していきます。

リージョンタイプの新しい REST API を作成します

リソースの / 直下に POST メソッドを追加します。 追加した POST メソッドの統合タイプで AWS サービス を選択し、以下のパラメータを設定していきます。

  • AWS リージョン:CodeBuild でビルドプロジェクトを作成したリージョン
  • AWS サービス:CodeBuild
  • AWS サブドメイン:(指定しない)
  • HTTP メソッド:POST
  • アクションの種類:アクション名の使用
  • アクション:StartBuild
  • 実行ロール:上記で作成した API Gateway 用 IAM ロールの ARN
  • コンテンツの種類:パススルー
  • デフォルトタイムアウトの使用:✓

ここで、アクションに入力する内容は、API Gateway の API Reference から、該当するものを探しています(IAM ロール作成時の許可ポリシーで設定した Action とも適合していますね)。

全ての入力が正しいことを確認したら 保存 ボタンをクリックします

このあと、セットアップした POST メソッドの編集画面から統合リクエストを修正するのですが、その前に StartBuild を呼び出すために必要なパラメータを確認します。 StartBuild の API Reference を確認すると、リクエストに必須のパラメータ(Required: Yes の項目)は projectName のみです。

CloudShell を開き、以下の通り AWS CLI でデバッグモードを有効化(--debug)して start-build サブコマンドを実行し、その際に生成される HTTP リクエストヘッダーを確認します(パラメータが正しい場合は CodeBuild ビルドプロジェクトが実行されます)。

$ aws codebuild start-build \
  --region ap-northeast-1 \
  --project-name mirror-repository-sample \
  --debug \
|& grep 'Making request for OperationModel'

'headers': 以降にリクエストヘッダーに設定される値が出力されます

ここで重要なのが、X-Amz-Target: CodeBuild_20161006.StartBuildContent-Type: application/x-amz-json-1.1 の部分です。

では、必要なパラメータが確認できたので、統合リクエストを修正していきます。

赤枠の部分を選択します

先の手順で確認した、CodeBuild StartBuild API を呼び出すために必要な 2 つのパラメータを、HTTP ヘッダー に追加します。 マッピング元の値は固定の文字列にしたいため、'(シングルクォート)で囲みます。

マッピングテンプレート では、Content-Type: application/x-www-form-urlencoded に対して、リクエスト本文として CodeBuild StartBuild API の必須パラメータより、{"projectName": "<build-project-name>"} を設定します。 例によって、<build-project-name> のところは自身のビルドプロジェクトの名前に置き換えてください。

(ここの UI はもうちょっとわかりやすくできないものかなーと思いつつ)保存ボタンをクリックします

ここまでの設定ができたら、API をデプロイします。

適当なステージ名を設定して API をデプロイします

ステージエディター画面でデプロイされた API の URL を確認し、CloudShell から curl コマンドを使って API の動作を確認します。

$ curl -XPOST "https://<rest-api-id>.execute-api.<aws-region>.amazonaws.com/<stage-name>" \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d ''

デプロイした REST API から、CodeBuild ビルドプロジェクトが実行されていれば成功です🎉

curl コマンドの実行結果からも buildStatus や currentPhase が確認できます

あとは、デプロイした REST API のエンドポイントを Backlog の Git の設定から WebフックURL に登録し、Backlog の Git リポジトリへの操作が CodeCommit リポジトリに反映されるかを確認していきましょう。 新しいコミットの Push、ブランチの作成、プルリクエストの作成、マージ、ブランチの削除などの操作が期待通りに反映されるか、試してみてください。

なお、ここで構成した API Gateway REST API のエンドポイントはインターネット向けに公開されています。 不正アクセスのリスクを低減させるためにアクセス制限が必要であれば、AWS WAF を適用するなどの対応を検討してください。

また、Backlog の Git リポジトリは LFS (Large File Storage) に対応 していますが、本記事で紹介した仕組みだけでは、LFS オブジェクトを転送することができないため、ご注意ください。

CloudFormation で構築する

ここまで、(仕組みを理解するために)手作業で構築してきましたが、できれば IaC ツールを使って再利用可能なテンプレートとして記述し、必要な時にすぐに構築できるようにしておきたいところです。 ということで、AWS CloudFormation のテンプレートに起こしてみました🙌

先の構成では、CodeBuild で Backlog から取得した Git リポジトリのデータを S3 バケットでキャッシュしていましたが、リポジトリのサイズが大きい場合はキャッシュデータの転送コストが大きくなってしまう課題があります。 この課題に対応するため、Amazon EFS を利用し、CodeBuild のビルドプロジェクトから共有ファイルシステムをマウントし、そこにリポジトリの一時データを置くようにしています。

CloudFormation で展開される構成

EFS を利用するため、CodeBuild ビルドプロジェクトを VPC 内で動作させるための設定を加えていますが、API Gateway + CodeBuild の基本的な構成は、本記事で解説したものに沿っているため、興味のある方はテンプレートファイルを眺めてみて、もし使えそうであれば使ってみてください。

AWSTemplateFormatVersion: "2010-09-09"
Description: "AWS CodeCommit repository synchronized with Backlog"
# SPDX-FileCopyrightText: 2022 Fenrir Inc.
# SPDX-License-Identifier: Apache-2.0

Parameters:
  ProjectName:
    Description: "Project name"
    Type: "String"
    Default: "devops-demo"
  LogExpirationInDays:
    Description: "Number of days to keep logs"
    Type: "Number"
    Default: 30
 
  BacklogSpaceKey:
    Description: "Backlog space key"
    Type: "String" 
    AllowedPattern: '^[0-9a-zA-Z-]*$'
  BacklogDomainName:
    Description: "Backlog domain name"
    Type: "String"
    AllowedValues:
      - "backlog.jp"
      - "backlog.com"
  BacklogProjectKey:
    Description: "Code name of the Backlog project"
    Type: "String"
  BacklogRepositoryName:
    Description: "Git repository name of the Backlog project"
    Type: "String"
  BacklogUserCredentialKey:
    Description: "Path to the Systems Manager parameter store where the Backlog user credential is stored (must begin with /)"
    Type: "String"
    AllowedPattern: '^/.*'

  VpcId:
    Description: "VPC Id"
    Type: "AWS::EC2::VPC::Id"
  PrivateSubnetId1:
    Description: "1st private subnet id"
    Type: "AWS::EC2::Subnet::Id"
  PrivateSubnetId2:
    Description: "2nd private subnet id"
    Type: "AWS::EC2::Subnet::Id"

  CodeCommitRepositoryName:
    Description: "CodeCommit repository name"
    Type: "String"

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label: { default: "General" }
        Parameters:
          - "ProjectName"
          - "LogExpirationInDays"
      - Label: { default: "Backlog" }
        Parameters:
          - "BacklogSpaceKey"
          - "BacklogDomainName"
          - "BacklogProjectKey"
          - "BacklogRepositoryName"
          - "BacklogUserCredentialKey"
      - Label: { default: "VPC" }
        Parameters:
          - "VpcId"
          - "PrivateSubnetId1"
          - "PrivateSubnetId2"
      - Label: { default: "AWS CodeCommit" }
        Parameters:
          - "CodeCommitRepositoryName"

Resources:
  # CodeCommit repository
  CodeCommitRepository:
    Type: "AWS::CodeCommit::Repository"
    UpdateReplacePolicy: "Delete"
    DeletionPolicy: "Delete"
    Properties:
      RepositoryName: !Ref "CodeCommitRepositoryName"

  # S3 bucket for storing build cache
  BuildCacheBucket:
    Type: "AWS::S3::Bucket"
    Properties:
      BucketName: !Sub "${ProjectName}-build-cache-${AWS::AccountId}"
      OwnershipControls: { Rules: [ ObjectOwnership: "BucketOwnerEnforced" ]}  # disable ACL
      BucketEncryption: { ServerSideEncryptionConfiguration: [ ServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" }]}
      PublicAccessBlockConfiguration: { BlockPublicAcls: true, BlockPublicPolicy: true, IgnorePublicAcls: true, RestrictPublicBuckets: true }
      LifecycleConfiguration:
        Rules:
          - Status: "Enabled"
            ExpirationInDays: 30  # delete current version after 1 month
            AbortIncompleteMultipartUpload: { DaysAfterInitiation: 7 }

  # Security Group for EFS
  SharedFileSystemSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupName: !Sub "${ProjectName}-file-sharing"
      GroupDescription: !Sub "${ProjectName}-file-sharing"
      VpcId: !Ref "VpcId"
      SecurityGroupEgress:
        - CidrIp: "127.0.0.1/32"
          Description: "block default outbound"
          IpProtocol: "-1"
      Tags: [{ Key: "Name", Value: !Sub "${ProjectName}-file-sharing" }]

  # EFS shared file system
  SharedFileSystem:
    Type: "AWS::EFS::FileSystem"
    DeletionPolicy: "Delete"
    UpdateReplacePolicy: "Delete"
    Properties:
      PerformanceMode: "generalPurpose"
      ThroughputMode: "bursting"
      Encrypted: true
      BackupPolicy: { Status: "DISABLED" }
      FileSystemTags: [{ Key: "Name", Value: !Sub "${ProjectName}-${CodeCommitRepositoryName}" }]

  # EFS mount target
  MountTarget1:
    Type: "AWS::EFS::MountTarget"
    Properties:
      FileSystemId: !Ref "SharedFileSystem"
      SubnetId: !Ref "PrivateSubnetId1"
      SecurityGroups: [ !Ref "SharedFileSystemSecurityGroup" ]
  MountTarget2:
    Type: "AWS::EFS::MountTarget"
    Properties:
      FileSystemId: !Ref "SharedFileSystem"
      SubnetId: !Ref "PrivateSubnetId2"
      SecurityGroups: [ !Ref "SharedFileSystemSecurityGroup" ]

  # Security Group for CodeBuild
  BuildSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupName: !Sub "${ProjectName}-codebuild"
      GroupDescription: !Sub "${ProjectName}-codebuild"
      VpcId: !Ref "VpcId"
      SecurityGroupEgress:
        - Description: "allow SSH access to internet"
          CidrIp: "0.0.0.0/0"
          IpProtocol: "tcp"
          FromPort: 22
          ToPort: 22
        - Description: "allow HTTPS access to internet"
          CidrIp: "0.0.0.0/0"
          IpProtocol: "tcp"
          FromPort: 443
          ToPort: 443
      Tags: [{ Key: "Name", Value: !Sub "${ProjectName}-codebuild" }]
  # Allow NFS communication from CodeBuild to EFS
  BuildSGEgressAllowNFSToSharedFileSystemSG:
    Type: "AWS::EC2::SecurityGroupEgress"
    Properties:
      Description: "allow NFS access to shared file system"
      GroupId: !GetAtt "BuildSecurityGroup.GroupId"
      DestinationSecurityGroupId: !GetAtt "SharedFileSystemSecurityGroup.GroupId"
      IpProtocol: "tcp"
      FromPort: 2049
      ToPort: 2049
  SharedFileSystemSGIngressAllowNFSFromBuildSG:
    Type: "AWS::EC2::SecurityGroupIngress"
    Properties:
      Description: "allow NFS access from codebuild"
      GroupId: !GetAtt "SharedFileSystemSecurityGroup.GroupId"
      SourceSecurityGroupId: !GetAtt "BuildSecurityGroup.GroupId"
      IpProtocol: "tcp"
      FromPort: 2049
      ToPort: 2049

  # IAM role for CodeBuild
  BuildRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub "${ProjectName}-codebuild"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal: { Service: "codebuild.amazonaws.com" }
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: "Base"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # CloudWatch Logs
              - Effect: "Allow"
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource: "*"
              # S3 BuildCacheBucket
              - Effect: "Allow"
                Action:
                  - "s3:GetObject"
                  - "s3:PutObject"
                Resource: !Sub "${BuildCacheBucket.Arn}/*"
              # CodeCommit
              - Effect: "Allow"
                Action: "codecommit:GitPush"
                Resource: !GetAtt "CodeCommitRepository.Arn"
        # Systems Manager Secure Parameter
        - PolicyName: "SSMSecureParameter"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action: "ssm:GetParameters"
                Resource: !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${BacklogUserCredentialKey}"
              - Effect: "Allow"
                Action: "kms:Decrypt"
                Resource: "*"
                Condition:
                  StringLike:
                    kms:RequestAlias: "aws/ssm"
        # Required when accessing and debugging the build environment with Systems Manager Session Manager
        - PolicyName: "SSMSessionManager"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                  - "ssmmessages:CreateControlChannel"
                  - "ssmmessages:CreateDataChannel"
                  - "ssmmessages:OpenControlChannel"
                  - "ssmmessages:OpenDataChannel"
                Resource: "*"
        # Required when launching a build project in a VPC
        - PolicyName: "VPCAccess"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                  - "ec2:CreateNetworkInterface"
                  - "ec2:DescribeDhcpOptions"
                  - "ec2:DescribeNetworkInterfaces"
                  - "ec2:DeleteNetworkInterface"
                  - "ec2:DescribeSubnets"
                  - "ec2:DescribeSecurityGroups"
                  - "ec2:DescribeVpcs"
                Resource: "*"
              - Effect: "Allow"
                Action: "ec2:CreateNetworkInterfacePermission"
                Resource: !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:network-interface/*"
                Condition:
                  StringEquals:
                    ec2:Subnet:
                      - !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:subnet/${PrivateSubnetId1}"
                      - !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:subnet/${PrivateSubnetId2}"
                    ec2:AuthorizedService: "codebuild.amazonaws.com"

  # CodeBuild project to mirror the Backlog Git repository
  # If you want to pause a build to debug, enable session connection from the "Advanced build overrides" option, add codebuild-breakpoint line to Buildspec and run
  MirrorRepositoryProject:
    Type: "AWS::CodeBuild::Project"
    Properties:
      Name: !Sub "${ProjectName}-mirror-backlog-repository"
      Description: "mirror git repository"
      ServiceRole: !GetAtt "BuildRole.Arn"
      Source:
        Type: "NO_SOURCE"
        BuildSpec: !Sub |
          version: 0.2
          env:
            parameter-store:
              SRC_GIT_CREDENTIAL: "${BacklogUserCredentialKey}"
          phases:
            install:
              commands:
                - pip3 install git-remote-codecommit
            pre_build:
              commands:
                - eval $(ssh-agent)
                - ssh-add - <<< "$SRC_GIT_CREDENTIAL"
                - ssh-keyscan ${BacklogSpaceKey}.git.${BacklogDomainName} >> ~/.ssh/known_hosts
            build:
              commands:
                - cd "$CODEBUILD_REPO_DIR"
                - |
                  set -eux
                  if [ ! -e ${BacklogRepositoryName}.git ]; then
                    git clone --mirror "${BacklogSpaceKey}@${BacklogSpaceKey}.git.${BacklogDomainName}:/${BacklogProjectKey}/${BacklogRepositoryName}.git"
                    cd ${BacklogRepositoryName}.git
                    git remote add codecommit codecommit::${AWS::Region}://${CodeCommitRepository.Name}
                  else
                    cd ${BacklogRepositoryName}.git
                    git fetch --prune origin
                  fi
                  set +x
                - git push --mirror --prune codecommit
            # post_build:
          # artifacts:
          cache:
            paths:
              - /root/.cache/pip/**/*
      Environment:
        Type: "ARM_CONTAINER"
        Image: "aws/codebuild/amazonlinux2-aarch64-standard:2.0"
        ComputeType: "BUILD_GENERAL1_SMALL"
        PrivilegedMode: true  # Access to EFS requires privileges
      VpcConfig:
        VpcId: !Ref "VpcId"
        Subnets: [ !Ref "PrivateSubnetId1", !Ref "PrivateSubnetId2" ]
        SecurityGroupIds: [ !GetAtt "BuildSecurityGroup.GroupId" ]
      TimeoutInMinutes: 5
      QueuedTimeoutInMinutes: 10
      FileSystemLocations:
        - Identifier: "repo_dir"  # The environment variable CODEBUILD_<Identifier> points to a mount point
          Type: "EFS"
          Location: !Sub "${SharedFileSystem}.efs.${AWS::Region}.amazonaws.com:/"  # efs-dns-name:/[directory-path]
          MountPoint: "/mnt/shared"
      Artifacts: { Type: "NO_ARTIFACTS" }
      Cache: { Type: "S3", Location: !Sub "${BuildCacheBucket.Arn}" }
      LogsConfig: { CloudWatchLogs: { Status: "ENABLED" }}
      ConcurrentBuildLimit: 1
      Visibility: "PRIVATE"

  # CloudWatch log group to store build logs
  MirrorRepositoryProjectLogGroup:
    Type: "AWS::Logs::LogGroup"
    UpdateReplacePolicy: "Delete"
    DeletionPolicy: "Delete"
    Properties:
      LogGroupName: !Sub "/aws/codebuild/${MirrorRepositoryProject}"
      RetentionInDays: !Ref "LogExpirationInDays"

  # IAM role for API Gateway
  ServiceProxyRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub "${ProjectName}-mirror-repository-service-proxy"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal: { Service: "apigateway.amazonaws.com" }
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: "Build"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action: "codebuild:StartBuild"
                Resource: !GetAtt "MirrorRepositoryProject.Arn"

  # REST API for webhooks to start CodeBuild project
  StartBuildAPI:
    Type: "AWS::ApiGateway::RestApi"
    Properties:
      Name: !Sub "${ProjectName}-start-build"
      EndpointConfiguration: { Types: [ "REGIONAL" ]}
  StartBuildMethod:
    Type: "AWS::ApiGateway::Method"
    Properties:
      RestApiId: !Ref "StartBuildAPI"
      ResourceId: !GetAtt "StartBuildAPI.RootResourceId"
      HttpMethod: "POST"
      ApiKeyRequired: false
      AuthorizationType: "NONE"
      Integration:
        Type: "AWS"
        IntegrationHttpMethod: "POST"
        Uri: !Sub "arn:${AWS::Partition}:apigateway:${AWS::Region}:codebuild:action/StartBuild"
        Credentials: !GetAtt "ServiceProxyRole.Arn"
        RequestParameters:
          integration.request.header.Content-Type: "'application/x-amz-json-1.1'"
          integration.request.header.X-Amz-Target: "'CodeBuild_20161006.StartBuild'"
        RequestTemplates:
          application/x-www-form-urlencoded: !Sub |
            {
              "projectName": "${MirrorRepositoryProject}"
            }
        PassthroughBehavior: "NEVER"
        IntegrationResponses: [{ StatusCode: "200", ResponseTemplates: {}}]
      MethodResponses: [{StatusCode: "200", ResponseModels: { "application/json": "Empty" }}]
  StartBuildAPIDeployment:
    Type: "AWS::ApiGateway::Deployment"
    DependsOn: [ "StartBuildMethod" ]
    Properties:
      RestApiId: !Ref "StartBuildAPI"
      StageName: "prod"

Outputs:
  WebhookURL:
    Description: "Webhook URL"
    Value: !Sub "https://${StartBuildAPI}.execute-api.${AWS::Region}.amazonaws.com/prod"

まとめ

以上、Backlog の Git リポジトリを AWS CodeCommit に効率良く連携する方法を検討し、実装してみました。 私たちのお客様は、ここで紹介した方法を参考に AWS 上で CI/CD のワークフローを構築し、DevOps 領域で抱えていた課題を解決されました。 引き続き GIMLE チームでは、お客様がクラウド領域で抱える課題に対して、デザインとテクノロジーで解決に取り組んでいきます。