クロスアカウントなECRを利用するCodeBuildをCDK + Pythonで構築してみる
はじめに
こんにちは、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用リソースを作成します。以下の記事を参考に、スタックは自作コンストラクトで構造化しました。
また、環境情報や一部のリソース名は cdk.json
に記述し、コンテキストで参照しています。作成するリソースは、主に以下の4つです。
- ソースコード配置先S3バケット:
cb-source-bucket
- CodeBuildプロジェクト:
cross-account-ecr-codebuild-prj
- CodeBuild実行用IAMロール:
codebuild-service-role
- CodeBuildでビルドしたDockerイメージの配置先ECRリポジトリ:
delivery-repo
コード全体は長いため折りたたんでいますが、重要な部分を抽出して説明します。ポイントは以下の3点です。
- IAMロール名は明示的に作成する
- IAMロールにECRリポジトリへのプッシュ/プル権限を付与する
- CodeBuildビルドコンテナは
Priviledged
モードで起動する
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
...
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()
{
...,
"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)。そのため、BuildEnvironment
で priviledged=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
アカウントBのECRリポジトリ作成とリソースポリシーの設定
次に、アカウントBのECRリポジトリを作成します。ポイントは、先ほど作成したCodeBuild用IAMロールによるイメージのプルをリソースポリシーで許可することです。aws_ecr.Repository
コンストラクトには、指定したプリンシパルにプル権限を付与するメソッド(grant_pull()
)が用意されているので、こちらを利用しています。
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
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
最後に、今のうちに source-repo
リポジトリにベースイメージを配置しておきます。
ベースイメージは、Pythonの公式イメージにライブラリを数個追加するだけの簡単なものにしました。
以下の通りDockerfileを用意してビルド・プッシュします。ECRへのログイン方法の詳細に関しては公式ドキュメントを参照してください。
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
タグでイメージが配置されていることを確認しておきましょう。
以上で、CodeBuildの実行に必要なCDKリソースは全て用意できました。
CodeBuildを実行してみる
構築したCodeBuildを実際に動かしてみましょう。
まず、ビルド対象となるDockerfileとbuildspecファイルを用意します。ベースイメージに、先にアカウントBのリポジトリにプッシュしておいたイメージを指定しています。buildspecでは、pre_build
フェーズでアカウントA、BそれぞれのECRにログインしたのち、docker build
及び docker push
を実行します。
Dockerfile
と buildspec.yml
をzipで固めて、cb-source-bucket
直下にアップロードしましょう。
FROM YYYYYYYYYYYY.dkr.ecr.ap-northeast-1.amazonaws.com/source-repo:latest
RUN pip install numpy
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
では、CodeBuildのコンソール画面からビルドを開始してみましょう。
ビルドに成功しました。アカウントAのECRを確認すると、確かに 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を実行すると、ベースイメージをプルできないというエラーメッセージが出ました。
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ロールを新たに作成してスイッチロールする方法もあるかと思います。この方法については以下記事が参考になりましたので、興味があればご一読ください。
まとめ
本記事では、クロスアカウントなECRに接続するCodeBuildを CDK + Pythonで構築する方法について解説してみました。少々ニッチなユースケースではあるかもしれませんが、少しでもお役に立てましたら幸いです。
私自身、業務では今回初めてCDKを利用しましたが、Cfnに比べて少ない記述量で記述でき、またIDEの補完機能の恩恵を受けられる点が非常に魅力的に感じました。今後も積極的に利用し、インフラ構築の効率化を図っていきたいと思います。
CDKは多言語で記述可能であることを謳ってはいるものの、実際にはCDKに関する日本語記事はTypeScriptによるものが圧倒的に多いと感じます (元実装がTypeScriptで書かれている以上当然なのかもしれませんが)。一方で、Pythonを触った経験がある人もかなり多いと思うので、CDK + Pythonへの潜在的な需要は大きいのではないかと私は思っています。本記事がそういった需要に少しでも応えるものであることを願っています。
最後までお読みいただきありがとうございました。
参考: 今回参照した公式ドキュメント
株式会社D2C d2c.co.jp のテックブログです。 D2Cは、NTTドコモと電通などの共同出資により設立されたデジタルマーケティング企業です。 ドコモの膨大なデータを活用した最適化を行える広告配信システムの開発をしています。
Discussion