🏗️

クロスアカウントなECRを利用するCodeBuildをCDK + Pythonで構築してみる

2023/08/24に公開

はじめに

こんにちは、D2Cエンジニアの穐澤です。

本記事では、クロスアカウントのECRリポジトリにあるベースイメージからDockerイメージをビルドするCodeBuildプロジェクトをCDK + Pythonで構築する手順を解説します。

調査をする中で、CodePipelineをCDK + Pythonで構築する事例は多く見られたものの、CodeBuildに特化し、さらにクロスアカウントECRと接続させる事例(特に日本語記事)は少なく、少々実装に苦戦しました。本記事が、私と同じような状況の方にとって少しでもお役に立てればと思います。

本記事で言及しないこと

本記事では、CodeBuildと外部アカウントのECRとの接続、及びCodeBuildでDockerイメージをビルド・プッシュするための設定に重点を置いて言及し、以下については扱いませんので予めご了承ください。

  • AWS認証情報の設定方法
  • CDK Appの構成やスタック分割、マルチアカウントでの運用に関するベストプラクティス
  • ECRとの接続に関わる部分を除くCodeBuildの細かな設定(ビルドトリガなど)

開発環境

以下の環境をVS Code + Dev Containers で用意し利用しました。
(Dev Containers の詳細については今回は触れません。)

  • AWS CDK: 2.92.0
  • AWS CLI: 2.13.10
  • Node.js: 18.17.1
  • Python: 3.11.4
  • Docker: 20.10.23
  • OS: Debian GNU/Linux 11

実装準備

cdk init で初期化したのち、生成された requirements.txt から pip install しておきます。
cdk init を実行するとテスト用リソースのサンプル等も作成されますが、今回はそれらは不要です。必要なもののみ残し、最終的に以下のツリー構成としました。

$ cdk init sample-app --language=python
$ pip install -r requirements.txt
ツリー構成
.
├── app.py
├── cdk.json
├── cross_account_ecr_codebuild
│   ├── __init__.py
│   ├── account_a.py  # CodeBuildはこちらに構築
│   └── account_b.py  # 上記のCodeBuildでアカウントBのECRからイメージをpullする
└── requirements.txt

また、2アカウント分のアクセスキー等認証情報を設定し、cdk bootstrap も実行しておきます。
認証情報の設定については公式ドキュメントを参照してください。

$ cdk bootstrap aws://XXXXXXXXXXXX/ap-northeast-1 --profile account-a
$ cdk bootstrap aws://YYYYYYYYYYYY/ap-northeast-1 --profile account-b

全体の構成・実装

今回作成するリソースの全体図は以下のとおりです。

全体アーキテクチャ

アカウントAのCodeBuildから、アカウントBのECRへベースイメージをプルしに行きます。
本記事では扱いませんが、アカウントBのECRへイメージをプッシュする場合も権限設定以外は基本的にやることは同じだと思います。

では、実際にCDKを組んでいきましょう。

アカウントA: CodeBuild用リソース作成

アカウントAのCodeBuild用リソースを作成します。以下の記事を参考に、スタックは自作コンストラクトで構造化しました。
https://tmokmss.hatenablog.com/entry/20221212/1670804620

また、環境情報や一部のリソース名は cdk.json に記述し、コンテキストで参照しています。作成するリソースは、主に以下の4つです。

  • ソースコード配置先S3バケット: cb-source-bucket
  • CodeBuildプロジェクト: cross-account-ecr-codebuild-prj
  • CodeBuild実行用IAMロール: codebuild-service-role
  • CodeBuildでビルドしたDockerイメージの配置先ECRリポジトリ: delivery-repo

コード全体は長いため折りたたんでいますが、重要な部分を抽出して説明します。ポイントは以下の3点です。

  1. IAMロール名は明示的に作成する
  2. IAMロールにECRリポジトリへのプッシュ/プル権限を付与する
  3. CodeBuildビルドコンテナは Priviledged モードで起動する
cross_account_ecr_codebuild/account_a.py
cross_account_ecr_codebuild/account_a.py
from aws_cdk import (
    RemovalPolicy,
    Stack,
    aws_codebuild as cb,
    aws_ecr as ecr,
    aws_iam as iam,
    aws_logs as logs,
    aws_s3 as s3,
)
from constructs import Construct


class AccountACICDStack(Stack):
    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        source_bucket = SourceS3Bucket(self, "SourceS3Bucket")
        delivery_repo = DeliveryECRRepository(self, "DeliveryECRRepository")
        CodeBuild(
            self,
            "CodeBuild",
            source_bucket=source_bucket.bucket,
            delivery_repo=delivery_repo.repo,
        )


class SourceS3Bucket(Construct):
    def __init__(self, scope: Construct, id: str) -> None:
        super().__init__(scope, id)

        self.bucket = s3.Bucket(
            self,
            "Default",
            bucket_name="cb-source-bucket",
            removal_policy=RemovalPolicy.DESTROY,
            auto_delete_objects=True,
        )


class DeliveryECRRepository(Construct):
    def __init__(self, scope: Construct, id: str) -> None:
        super().__init__(scope, id)

        self.repo = ecr.Repository(
            self,
            "Default",
            image_scan_on_push=True,
            repository_name="delivery-repo",
            removal_policy=RemovalPolicy.DESTROY,
            auto_delete_images=True,
        )


class CodeBuild(Construct):
    def __init__(
        self,
        scope: Construct,
        id: str,
        source_bucket: s3.Bucket,
        delivery_repo: ecr.Repository,
    ) -> None:
        super().__init__(scope, id)

	# ログ出力設定
        log_group = logs.LogGroup(
            self,
            "LogGroup",
            removal_policy=RemovalPolicy.DESTROY,
        )
        logging_options = cb.LoggingOptions(
            cloud_watch=cb.CloudWatchLoggingOptions(
                enabled=True,
                log_group=log_group,
            )
        )

        # アカウントBにIAMロール名を渡す必要があるため、ロール名を指定して明示的に作成
        account_a_context = self.node.get_context("account_a")
        service_role = iam.Role(
            self,
            "ServiceRole",
            role_name=account_a_context["codebuild_role_name"],
            assumed_by=iam.ServicePrincipal("codebuild.amazonaws.com"),
        )

        service_role.add_to_principal_policy(
            iam.PolicyStatement(
                sid="ECRGetAuthorizationToken",
                effect=iam.Effect.ALLOW,
                actions=["ecr:GetAuthorizationToken"],
                resources=["*"],
            )
        )
        service_role.add_to_principal_policy(
            iam.PolicyStatement(
                sid="ECRPushImages",
                effect=iam.Effect.ALLOW,
                actions=[
                    "ecr:BatchCheckLayerAvailability",
                    "ecr:CompleteLayerUpload",
                    "ecr:InitiateLayerUpload",
                    "ecr:PutImage",
                    "ecr:UploadLayerPart",
                ],
                resources=[delivery_repo.repository_arn],
            )
        )

        account_b_context = self.node.get_context("account_b")
        source_repo_arn = "arn:aws:ecr:{}:{}:repository/{}".format(
            account_b_context["region"],
            account_b_context["id"],
            account_b_context["ecr_repo_name"],
        )
        service_role.add_to_principal_policy(
            iam.PolicyStatement(
                sid="ECRPullImagesFromSourceRepo",
                effect=iam.Effect.ALLOW,
                actions=[
                    "ecr:BatchCheckLayerAvailability",
                    "ecr:GetDownloadUrlForLayer",
                    "ecr:BatchGetImage",
                ],
                resources=[source_repo_arn],
            )
        )

        cb.Project(
            self,
            "Project",
            project_name=account_a_context["codebuild_project_name"],
            environment=cb.BuildEnvironment(
                build_image=cb.LinuxBuildImage.AMAZON_LINUX_2_4,
                compute_type=cb.ComputeType.SMALL,
                privileged=True,  # docker build/push に必要
            ),
            role=service_role,
            source=cb.Source.s3(bucket=source_bucket, path="source.zip"),
            build_spec=cb.BuildSpec.from_source_filename("buildspec.yml"),
            logging=logging_options,
            cache=cb.Cache.local(cb.LocalCacheMode.DOCKER_LAYER),
        )
app.py, cdk.json
app.py
...
import aws_cdk as cdk

from cross_account_ecr_codebuild.account_a import AccountACICDStack

app = cdk.App()

account_a_context = app.node.get_context("account_a")
env_account_a = cdk.Environment(
    account=account_a_context["id"],
    region=account_a_context["region"],
)

AccountACICDStack(
    scope=app,
    construct_id="account-a-cicd-stack",
    env=env_account_a,
)

app.synth()
cdk.json
{
  ...,
  "context": {
    ...,

    "account_a": {
      "id": "XXXXXXXXXXXX",
      "region": "ap-northeast-1",
      "codebuild_project_name": "cross-account-ecr-codebuild-prj",
      "codebuild_role_name": "codebuild-service-role"
    },
    "account_b": {
      "id": "YYYYYYYYYYYY",
      "region": "ap-northeast-1",
      "ecr_repo_name": "source-repo",
    }
  }
}

1. IAMロールは明示的に作成する

aws_codebuild.Project コンストラクトでは、デフォルトでCodeBuild用IAMロールを自動作成しますが、その場合ロール名を指定することができません。アカウントBのECRに接続するためには、後ほどアカウントBにこのIAMロール名を渡す必要があることから、今回はロール名を指定してIAMロールを事前に作成しておきます。

class CodeBuild(Construct):
    def __init__(
        ...,
    ) -> None:
        ...,

        # アカウントBにIAMロール名を渡す必要があるため、ロール名を指定して明示的に作成
        account_a_context = self.node.get_context("account_a")
        service_role = iam.Role(
            self,
            "ServiceRole",
            role_name=account_a_context["codebuild_role_name"],
            assumed_by=iam.ServicePrincipal("codebuild.amazonaws.com"),
        )

2. IAMロールにECRリポジトリへのプッシュ/プル権限を付与する

公式ドキュメント(プッシュ権限プル権限)を参考に、以下の権限をIAMロールに付与します。

  • ECRに docker login するために認証情報を取得する権限(ecr:GetAuthorizationToken)
  • delivery-repo リポジトリへのプッシュ権限
  • アカウントBの source-repo リポジトリからのプル権限
class CodeBuild(Construct):
    def __init__(
        self,
        scope: Construct,
        id: str,
        source_bucket: s3.Bucket,
        delivery_repo: ecr.Repository,
    ) -> None:
        ...,

        # 認証情報取得権限
        service_role.add_to_principal_policy(
            iam.PolicyStatement(
                sid="ECRGetAuthorizationToken",
                effect=iam.Effect.ALLOW,
                actions=["ecr:GetAuthorizationToken"],
                resources=["*"],
            )
        )
        # プッシュ権限 
        service_role.add_to_principal_policy(
            iam.PolicyStatement(
                sid="ECRPushImages",
                effect=iam.Effect.ALLOW,
                actions=[
                    "ecr:BatchCheckLayerAvailability",
                    "ecr:CompleteLayerUpload",
                    "ecr:InitiateLayerUpload",
                    "ecr:PutImage",
                    "ecr:UploadLayerPart",
                ],
                resources=[delivery_repo.repository_arn],
            )
        )

        # プル権限
        account_b_context = self.node.get_context("account_b")
        source_repo_arn = "arn:aws:ecr:{}:{}:repository/{}".format(
            account_b_context["region"],
            account_b_context["id"],
            account_b_context["ecr_repo_name"],
        )
        service_role.add_to_principal_policy(
            iam.PolicyStatement(
                sid="ECRPullImagesFromSourceRepo",
                effect=iam.Effect.ALLOW,
                actions=[
                    "ecr:BatchCheckLayerAvailability",
                    "ecr:GetDownloadUrlForLayer",
                    "ecr:BatchGetImage",
                ],
                resources=[source_repo_arn],
            )
        )

3. CodeBuildビルドコンテナはPriviledgedモードで起動する

aws_codebuild.Project コンストラクトを用いてプロジェクトを作成します。先述の通り、実行用ロールとして自前のIAMロールを渡し、ビルドソースにS3バケットとオブジェクトパスを指定します。今回は cb-source-bucket 直下の source.zip としました。また、source.zip 展開後のbuildspecファイルの場所もここで指定しておきます。

さらに、CodeBuild上のビルド環境はコンテナベースであり、docker build を実行するためにはコンテナをPriviledgedモードで起動する必要があります(いわゆる docker-in-docker)。そのため、BuildEnvironmentpriviledged=True を指定します。

class CodeBuild(Construct):
    def __init__(
        self,
        scope: Construct,
        id: str,
        source_bucket: s3.Bucket,
        delivery_repo: ecr.Repository,
    ) -> None:
        ...,

        cb.Project(
            self,
            "Project",
            project_name=account_a_context["codebuild_project_name"],
            environment=cb.BuildEnvironment(
                build_image=cb.LinuxBuildImage.AMAZON_LINUX_2_4,
                compute_type=cb.ComputeType.SMALL,
                privileged=True,  # docker build/push に必要
            ),
            role=service_role,
            source=cb.Source.s3(bucket=source_bucket, path="source.zip"),
            build_spec=cb.BuildSpec.from_source_filename("buildspec.yml"),
            logging=logging_options,
            cache=cb.Cache.local(cb.LocalCacheMode.DOCKER_LAYER),
        )

では、追加したスタック account-a-cicd-stack をアカウントAにデプロイしましょう。
cdk synth でCfnテンプレートを生成できることを確認したのち、cdk deploy でデプロイを実行できます。実行後、アカウントAのCloudFormationコンソール画面から、account-a-cicd-stack が作成されていることを確認してみましょう。

$ cdk synth account-a-cicd-stack
$ cdk deploy account-a-cicd-stack --profile account-a

アカウントAでaccount-a-cicd-stackが作成されたことを確認

アカウントBのECRリポジトリ作成とリソースポリシーの設定

次に、アカウントBのECRリポジトリを作成します。ポイントは、先ほど作成したCodeBuild用IAMロールによるイメージのプルをリソースポリシーで許可することです。aws_ecr.Repository コンストラクトには、指定したプリンシパルにプル権限を付与するメソッド(grant_pull())が用意されているので、こちらを利用しています。

cross_account_ecr_codebuild/account_b.py
from aws_cdk import (
    RemovalPolicy,
    Stack,
    aws_ecr as ecr,
    aws_iam as iam,
)
from constructs import Construct


class AccountBECRStack(Stack):
    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        SourceECRRepository(self, "SourceECRRepository")


class SourceECRRepository(Construct):
    def __init__(self, scope: Construct, id: str) -> None:
        super().__init__(scope, id)

        account_b_context = self.node.get_context("account_b")
        self.repo: ecr.Repository = ecr.Repository(
            scope=self,
            id="Default",
            image_scan_on_push=True,
            repository_name=account_b_context["ecr_repo_name"],
            removal_policy=RemovalPolicy.DESTROY,
            auto_delete_images=True,
        )

        # アカウントAのCodeBuild用IAMロールにプル権限を許可
        account_a_context = self.node.get_context("account_a")
        self.repo.grant_pull(
            iam.ArnPrincipal(
                "arn:aws:iam::{}:role/{}".format(
                    account_a_context["id"], account_a_context["codebuild_role_name"]
                )
            )
        )
app.py
app.py
  import aws_cdk as cdk

  from cross_account_ecr_codebuild.account_a import AccountACICDStack
+ from cross_account_ecr_codebuild.account_b import AccountBECRStack

  app = cdk.App()

  account_a_context = app.node.get_context("account_a")
  env_account_a = cdk.Environment(
      account=account_a_context["id"],
      region=account_a_context["region"],
  )
+ account_b_context = app.node.get_context("account_b")
+ env_account_b = cdk.Environment(
+     account=account_b_context["id"],
+     region=account_b_context["region"],
+ )

  AccountACICDStack(
      scope=app,
      construct_id="account-a-cicd-stack",
      env=env_account_a,
  )
+ AccountBECRStack(scope=app, construct_id="account-b-ecr-stack", env=env_account_b)

  app.synth()

アカウントB用のプロファイルを指定して、cdk deploy します。
これでアカウントBにECRリポジトリが作成されました。アカウントAと同様に、アカウントBのコンソール画面でも確認してみましょう。

$ cdk deploy account-b-ecr-stack --profile account-b

アカウントBでaccount-b-ecr-stackが作成されたことを確認

最後に、今のうちに source-repo リポジトリにベースイメージを配置しておきます。
ベースイメージは、Pythonの公式イメージにライブラリを数個追加するだけの簡単なものにしました。
以下の通りDockerfileを用意してビルド・プッシュします。ECRへのログイン方法の詳細に関しては公式ドキュメントを参照してください。

base.Dockerfile
FROM python:3.11
RUN pip install black flake8 isort
$ docker build -f base.Dockerfile -t YYYYYYYYYYYY.dkr.ecr.ap-northeast-1.amazonaws.com/source-repo:latest
$ aws ecr get-login-password --region ap-northeast-1 --profile account-b | docker login YYYYYYYYYYYY.dkr.ecr.ap-northeast-1.amazonaws.com --username AWS --password-stdin
$ docker push YYYYYYYYYYYY.dkr.ecr.ap-northeast-1.amazonaws.com/source-repo:latest

コンソール画面から、source-repo リポジトリに latest タグでイメージが配置されていることを確認しておきましょう。

アカウントBのsource-repoリポジトリにlatestが配置されていることを確認

以上で、CodeBuildの実行に必要なCDKリソースは全て用意できました。

CodeBuildを実行してみる

構築したCodeBuildを実際に動かしてみましょう。

まず、ビルド対象となるDockerfileとbuildspecファイルを用意します。ベースイメージに、先にアカウントBのリポジトリにプッシュしておいたイメージを指定しています。buildspecでは、pre_build フェーズでアカウントA、BそれぞれのECRにログインしたのち、docker build 及び docker push を実行します。
Dockerfilebuildspec.yml をzipで固めて、cb-source-bucket 直下にアップロードしましょう。

Dockerfile
FROM YYYYYYYYYYYY.dkr.ecr.ap-northeast-1.amazonaws.com/source-repo:latest
RUN pip install numpy
buildspec.yml
version: 0.2
env:
  variables:
    ACCOUNT_A_ECR_URI: XXXXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com
    ACCOUNT_B_ECR_URI: YYYYYYYYYYYY.dkr.ecr.ap-northeast-1.amazonaws.com
    DELIVERY_REPO_NAME: delivery-repo
phases:
  pre_build:
    on-failure: ABORT
    commands:
      # アカウントA,BそれぞれのECRにログイン
      - aws ecr get-login-password --region ap-northeast-1 | docker login $ACCOUNT_A_ECR_URI --username AWS --password-stdin
      - aws ecr get-login-password --region ap-northeast-1 | docker login $ACCOUNT_B_ECR_URI --username AWS --password-stdin
  build:
    on-failure: ABORT
    commands:
      # アカウントBからベースイメージを取得しビルド
      - docker build . -t ${ACCOUNT_A_ECR_URI}/${DELIVERY_REPO_NAME}:latest
  post_build:
    on-failure: ABORT
    commands:
      # アカウントAのECRにプッシュ
      - docker push ${ACCOUNT_A_ECR_URI}/${DELIVERY_REPO_NAME}:latest
$ zip source Dockerfile buildspec.yml
$ aws s3 cp source.zip s3://cb-source-bucket --profile account-a

cb-source-bucketにsource.zipがアップロードされたことを確認

では、CodeBuildのコンソール画面からビルドを開始してみましょう。

CodeBuildをコンソール画面からビルド開始

CodeBuildでビルド成功

ビルドに成功しました。アカウントAのECRを確認すると、確かに delivery-repo リポジトリにビルドしたイメージが配置されています。

delivery-repoリポジトリにビルドしたイメージが配置されたことを確認

ECRのリソースポリシーを設定する際の注意点

今回のように、ECRリポジトリのリソースポリシーで外部アカウントのCodeBuildからの接続を許可する場合、サービスプリンシパルを用いた権限の付与はできないので注意が必要です。
(以下公式ドキュメントより引用)

以下のいずれかに該当する場合は、AWS CodeBuild が Docker イメージをビルド環境にプルできるように、Amazon ECR のイメージリポジトリにアクセス許可を割り当てる必要があります。

  • プロジェクトで CodeBuild の認証情報を使用して Amazon ECR のイメージをプルしている場合。これは、CODEBUILD の imagePullCredentialsType 属性で ProjectEnvironment の値で示されます。
  • プロジェクトでクロスアカウントの Amazon ECR イメージを使用している場合。この場合は、プロジェクトでサービスロールを使用して Amazon ECR イメージをプルする必要があります。この動作を有効にするには、imagePullCredentialsType の ProjectEnvironment 属性を SERVICE_ROLE に設定します。

実際、principals にCodeBuildサービスプリンシパル(codebuild.amazonaws.com)を、conditions で CodeBuildプロジェクトのARNをそれぞれ指定し、デプロイ後CodeBuildを実行すると、ベースイメージをプルできないというエラーメッセージが出ました。

cross_account_ecr_codebuild/account_b.py
  class SourceECRRepository(Construct):
      def __init__(self, scope: Construct, id: str) -> None:
          super().__init__(scope, id)
          ...,

          account_a_context = self.node.get_context("account_a")
+         account_a_codebuild_arn = "arn:aws:codebuild:{}:{}:project/{}".format(
+             account_a_context["region"],
+             account_a_context["id"],
+             account_a_context["codebuild_project_name"],
+         )
+         self.repo.add_to_resource_policy(
+             iam.PolicyStatement(
+                 sid="AllowAccountAToPullImages",
+                 effect=iam.Effect.ALLOW,
+                 actions=[
+                     "ecr:BatchCheckLayerAvailability",
+                     "ecr:GetDownloadUrlForLayer",
+                     "ecr:BatchGetImage",
+                 ],
+                 principals=[iam.ServicePrincipal("codebuild.amazonaws.com")],
+                 conditions={"ArnEquals": {"aws:SourceArn": account_a_codebuild_arn}},
+             )
+         )
-         self.repo.grant_pull(
-             iam.ArnPrincipal(
-                 "arn:aws:iam::{}:role/{}".format(
-                     account_a_context["id"], account_a_context["codebuild_role_name"]
-                 )
-             )
-         )

エラーメッセージ抜粋
denied: User: arn:aws:sts::XXXXXXXXXXXX:assumed-role/codebuild-service-role/AWSCodeBuild-... is not authorized to perform: ecr:BatchGetImage on resource: arn:aws:ecr:ap-northeast-1:YYYYYYYYYYYY:repository/source-repo because no resource-based policy allows the ecr:BatchGetImage action

これを回避する方法としては、本記事のようにリソースポリシーでCodeBuild用IAMロールのARNをIAMプリンシパルとして設定する方法以外に、アカウントBでECR接続用IAMポリシーをアタッチしたIAMロールを新たに作成してスイッチロールする方法もあるかと思います。この方法については以下記事が参考になりましたので、興味があればご一読ください。
https://dev.classmethod.jp/articles/assumerole-in-codebuild/
https://qiita.com/risuoku/items/f04c0011e6098c092821

まとめ

本記事では、クロスアカウントなECRに接続するCodeBuildを CDK + Pythonで構築する方法について解説してみました。少々ニッチなユースケースではあるかもしれませんが、少しでもお役に立てましたら幸いです。

私自身、業務では今回初めてCDKを利用しましたが、Cfnに比べて少ない記述量で記述でき、またIDEの補完機能の恩恵を受けられる点が非常に魅力的に感じました。今後も積極的に利用し、インフラ構築の効率化を図っていきたいと思います。

CDKは多言語で記述可能であることを謳ってはいるものの、実際にはCDKに関する日本語記事はTypeScriptによるものが圧倒的に多いと感じます (元実装がTypeScriptで書かれている以上当然なのかもしれませんが)。一方で、Pythonを触った経験がある人もかなり多いと思うので、CDK + Pythonへの潜在的な需要は大きいのではないかと私は思っています。本記事がそういった需要に少しでも応えるものであることを願っています。

最後までお読みいただきありがとうございました。

参考: 今回参照した公式ドキュメント

https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html
https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/image-push.html
https://docs.aws.amazon.com/ja_jp/codebuild/latest/userguide/sample-ecr.html
https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/getting-started-cli.html#:~:text=停止します。-,ステップ,-2%3A デフォルトレジスト

D2C m-tech

Discussion