複数のAWSアカウントに対して、同じような構成の仕組みを導入したいと思ったことはないでしょうか?

今回は自分が管理者となり、複数のAWSアカウントへ AWS CloudFormation (以下、CloudFormation) で作成したリソースを同じように構築し、バージョンアップは管理者、バージョン構成はAWSアカウントの担当者の構成に出来る仕組みを作成します。

構成

1つの元となるアカウントから、複数のAWSアカウントへ1つの製品を配布します。
AWSリソースの定義は AWS Cloud Development Kit (以下、AWS CDK) (TypeScript) で作成し、CloudFormation テンプレートに変換します。また配布するリソースには AWS Lambda が含まれているため、ランタイムがEOLになった時に更新できるような仕組みが必要です。

ちなみに、このようにマスターアカウントと子アカウントの関係で一元管理する方法を「ハブアンドスポークモデル」と呼ぶそうです。

参考: https://aws.amazon.com/jp/blogs/mt/aws-service-catalog-hub-and-spoke-model-how-to-automate-the-deployment-and-management-of-service-catalog-to-many-accounts/

AWS Service Catalog とは?

AWS Service Catalog (以下、Service Catalog) は、作成した製品をエンドユーザーに配布できる機能を持つサービスです。管理者が必要なユーザーにのみ製品作成・配布を行い、エンドユーザーはその製品を使用して自分の環境で同じ構成のリソースを構築することができます。

Service Catalog で出てくる用語

  • 製品
    各アカウントに配布するAWSリソースです。
    CloudFormationテンプレートやTerraformテンプレートファイルなどを登録することで、配布することが可能になります。バージョン管理も可能。
  • ポートフォリオ
    製品を紐づける先。ポートフォリオ単位でユーザーに製品の使用を許可できます。
    今回の別のAWSアカウントへの配布で使いますが、このポートフォリオで他のAWSアカウントへの共有設定をしています。

前提

  • 配布元のアカウントは固定されているが、配布先のAWSアカウントはどこになるか開発時は把握できていない。どのくらいのAWSアカウント数になるかも不明だが、複数のAWSアカウントへ配布することは決まっている。
  • なるべく配布先のAWSアカウントへのログインは避けたく、操作は配布先のAWSアカウント担当者に行ってもらいたい
    • そのため cdk deploy を配布先のアカウントで実行出来ないという前提

課題

この構成を作成するにあたり、いくつかの課題がありました。

① S3バケットが参照できない

最初CDKのコードでは、以下のように定義していました。

const myFunction = new NodejsFunction(this, 'MyFunction', {
      runtime: lambda.Runtime.NODEJS_20_X,
      entry: path.join(__dirname, '../src/lambda/index.ts'),
      handler: 'handler',
    });

この時、cdk.outに出力されるCloudFormationテンプレートは以下のようにアカウントIDやリージョン等がプレイスホルダーとなった状態です。

 "XXXXHandler": {
   "Type": "AWS::Lambda::Function",
   "Properties": {
    "Code": {
     "S3Bucket": {
      "Fn::Sub": "cdk-xxxx-assets-${AWS::AccountId}-${AWS::Region}"
     },
     "S3Key": "{Hash値}.zip"
    },

これはLambdaのコードが配置されるアセット情報を示しています。
配布先のアカウントはどこになるか分からないという前提があるため、元のアカウントでアセットを cdk deploy でデプロイします。
このまま Service Catalog で別アカウントへ配布すると、プレイスホルダー部分に配布先のAWSアカウントが設定されるため、存在していない Amazon S3 (以下、S3) バケットを探しに行ってしまい、アクセスエラーとなります。(CloudFormationテンプレートに記載されいるのは、配布元のAWSアカウントでデプロイするときの値のため、配布先にはこのS3バケットは存在しません。)

そのため使用するS3バケットにバケットポリシーを設定し、クロスアカウントでの参照を可能にする必要があります。

課題② 運用コストが高くなる

今のままでは以下の工程を行う必要があります。
1. cdk synth / cdk deploy でCloudFormationテンプレートを取得
2. CloudFormationテンプレートにはS3バケットの参照先にAWSアカウントがプレイスホルダーで設定されているため、AWSアカウントを配布元のアカウントに固定する対応を行う
3. Service Catalogに置換したCloudFormationテンプレートを製品に登録する

手動でテンプレートを書き換えて登録するのは現実的ではありません。なんとか自動化したいところです。

解決策: CDK側でBootstrap用のバケットとは別に共有用のS3を指定する

色々試行錯誤しましたが、CDKのBootstrapで作成されるS3バケットとは別に、共有用のS3バケットを作成し、そこに配布するCloudFormationテンプレート(Asset)を配置する構成にしました。

CDK側の設定

CDK側で出力されるCloudFormationテンプレートのLambdaのAsset(S3バケット)をこの共有バケットに固定します。このS3バケットには、手動かスクリプトを作成してクロスアカウントのバケットポリシーを付与しておきます。

// 省略

this.bucket = new s3.Bucket(this, "SampleBucket", {
  encryption: s3.BucketEncryption.S3_MANAGED,
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  versioned: true,
  enforceSSL: true,
  removalPolicy: cdk.RemovalPolicy.RETAIN,
});

// 配布先のアカウントに対するバケットポリシーの追加
if (props.targetAccountIds && props.targetAccountIds.length > 0) {
  this.bucket.addToResourcePolicy(
    new iam.PolicyStatement({
      sid: "AllowCrossAccountRead",
      effect: iam.Effect.ALLOW,
      principals: props.targetAccountIds.map(
        (id) => new iam.AccountPrincipal(id)
      ),
      actions: ["s3:GetObject", "s3:GetObjectVersion", "s3:ListBucket"],
      resources: [this.bucket.bucketArn, `${this.bucket.bucketArn}/*`],
    })
  );
}

// Service Catalogポートフォリオ
const portfolio = new servicecatalog.Portfolio(this, "Portfolio", {
  displayName: "TestPortfolio",
  providerName,
});

// 配布用のアカウントに対して、ポートフォリオへのアクセス権を付与
targetAccountIds.forEach((accountId) => {
  portfolio.shareWithAccount(accountId);
});

配布先ではCDKではなく、CloudFormationテンプレートを配布するため、CDK独自の情報が含まれてしまわないように generateBootstrapVersionRule: false をつけています。

import * as cdk from 'aws-cdk-lib';
import { SampleCdkStack } from '../lib/sample_cdk-stack';

const app = new cdk.App();
new SampleCdkStack(app, 'SampleCdkStack', {
  synthesizer: new cdk.DefaultStackSynthesizer({
    fileAssetsBucketName: 'sample-bucket'
    generateBootstrapVersionRule: false,
  })
});

参考: https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.DefaultStackSynthesizerProps.html

バケットポリシーの設定

配布先アカウント(root)に対して、s3:GetObject 等の権限を許可します。
※もしS3バケットの暗号化キーをKMSにしている場合は、kms:Decrypt の権限も必要になります。

Amazon S3 バケットのサーバー側の暗号化キーを変更する
デフォルトでは、ブートストラップスタックの Amazon S3 バケットは、サーバー側の暗号化に AWS マネージドキーを使用するように設定されています。既存のカスタマーマネージドキーを使用するには、 –bootstrap-kms-key-idオプションを使用し、使用する AWS Key Management Service (AWS KMS) キーの値を指定します。暗号化キーをより詳細に制御したい場合は、カスタマー管理キーを使用するように –bootstrap-customer-key を指定します。
https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/bootstrapping-customizing.html

{
    "Version": "2012-10-17",
    "Id": "AccessControl",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::{ここにアカウントID}:root"
            },
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::{S3バケット名}"
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::{ここにアカウントID}:root"
            },
            "Action": [
                "s3:GetObject",
                "s3:GetObjectVersion"
            ],
            "Resource": "arn:aws:s3:::{S3バケット名}/*"
        }
    ]
}

CloudFormationテンプレートは、Lambdaのコードと一緒に指定したS3バケットにアップロードされます。この時、cdk.out/cdk.out に記述がありますが、ファイルがハッシュ化されています。

"stackTemplateAssetObjectUrl": "s3://sample-bucket/{ハッシュ}.json",

検討案

Synthesizer でアセットをデプロイする先のS3バケットを知らなかった時に、lambdaを作成する記述でバケットを指定すれば上手くいくと思っていました。

const myFunction2 = new lambda.Function(this, 'MyFunction2', {
  runtime: lambda.Runtime.NODEJS_20_X,
  code: lambda.Code.fromBucket(s3.Bucket.fromBucketName(this, 'Bucket', 'share-bucket-cdk'), 'function.zip'), // コードがある場所を明示
  handler: 'index.handler',
});

しかしこの定義だと、TypeScriptで書いているコードをコンパイルし、指定したS3に配置するまで自分で行わないといけなくなります。
自前でやってもいいんですが、せっかくCDKでL2使って書いているのに意義がなくなってしまうような気がして、採用を見送りました。

コードの更新に備え、自動化スクリプトで製品登録

ここまでで1,2に関して対応できました。
1. cdk synth / cdk deploy でCloudFormationテンプレートを取得
2. CloudFormationテンプレートにはS3バケットの参照先にAWSアカウントがプレイスホルダーで設定されているため、AWSアカウントを配布元のアカウントの共有用に固定する対応を行う
3. Service Catalogに置換したCloudFormationテンプレートを製品に登録する

残りは3を自動化すれば、なんとか運用できそうです。
しかしこの共有用のS3バケットに入っているテンプレートをService Catalogにアップロードする際、Service Catalog の製品が未作成なら新規作成、既存なら Provisioning Artifact として新しいバージョンを追加するのようなことはできません。
よってテンプレートの書き換えと Service Catalog への登録をシェルスクリプトで自動化します。

ざっくりですが、以下のようなスクリプトを書いています。

スクリプトの主な流れ
1. jq を使い、テンプレート内の S3Bucket 参照を共有バケット名に置換します
2. 書き換えたテンプレートを共有S3へ配置
3. aws servicecatalog create-provisioning-artifact で新バージョンまたは新たに製品として登録。

#!/bin/bash

# 汎用的な環境変数(実際はCI/CDの環境変数などから取得)
SHARE_BUCKET="your-shared-bucket-name"
TEMPLATE_FILE="cdk.out/SampleCdkStack.template.json"
TARGET_STACK_NAME="SampleProductTemplate"
PRODUCT_NAME="SampleAppProduct"
PRODUCT_VERSION="v1.0.0" # 例: Gitのコミットハッシュや日付タグ
REGION="ap-northeast-1"

# 1. アセットの同期 (Bootstrap -> Share) に関しては省略(aws s3 sync などで実施)

echo "2. テンプレートの書き換えとアップロード"
# jqを使ってLambda CodeのS3Bucket参照を共有バケットに書き換える
jq --arg bucket "${SHARE_BUCKET}" '
  walk(
    if type == "object" and .S3Bucket? then 
      .S3Bucket = $bucket 
    else . end
  )
' "${TEMPLATE_FILE}" > "${TEMPLATE_FILE}.tmp" && mv "${TEMPLATE_FILE}.tmp" "${TEMPLATE_FILE}"

# 共有S3バケットへアップロード
aws s3 cp "${TEMPLATE_FILE}" "s3://${SHARE_BUCKET}/${TARGET_STACK_NAME}.template.json" --region "${REGION}"
TEMPLATE_URL="https://${SHARE_BUCKET}.s3.${REGION}.amazonaws.com/${TARGET_STACK_NAME}.template.json"

echo "Service Catalog 製品の存在確認"
# 製品が存在するか名前で検索してProductIDを取得
PRODUCT_ID=$(aws servicecatalog search-products-as-admin \
    --region "${REGION}" \
    --query "ProductViewDetails[?ProductViewSummary.Name=='${PRODUCT_NAME}'].ProductViewSummary.ProductId" \
    --output text)

if [ -z "${PRODUCT_ID}" ]; then
    echo "3. 製品が存在しないため、新規作成します"
    aws servicecatalog create-product \
        --name "${PRODUCT_NAME}" \
        --owner "IT_Admin" \
        --product-type "CLOUDFORMATION_TEMPLATE" \
        --provisioning-artifact-parameters "Name=${PRODUCT_VERSION},Info={LoadTemplateFromURL=${TEMPLATE_URL}},Type=CLOUD_FORMATION_TEMPLATE" \
        --region "${REGION}"
else
    echo "4. 製品が既に存在するため、新しいバージョンとして追加します"
    aws servicecatalog create-provisioning-artifact \
        --product-id "${PRODUCT_ID}" \
        --parameters "Name=${PRODUCT_VERSION},Info={LoadTemplateFromURL=${TEMPLATE_URL}},Type=CLOUD_FORMATION_TEMPLATE" \
        --region "${REGION}"
fi

この後の配布の対応はスクリプトで書くと煩雑になるため、手動で行うようにしています。
これでCDKで作成した構成を、別のAWSアカウントにService Catalog経由で配布することを実現できました。

まとめ

最終的にスクリプトによる制御が必要になりましたが、CDKで作成した構成を Service Catalog 経由でスマートに配布する仕組みが実現できました。
マルチアカウント管理や、社内標準インフラのカタログ化を検討している方の参考になれば幸いです。(他にいいやり方があれば、ぜひ教えてください!)

参考

AWS Service Catalog Hub and Spoke Model: How to Automate the Deployment and Management of Service Catalog to Many Accounts
AWS Service Catalog を使ったセルフサービス型機能の提供
AWS CDK を使用して AWS Service Catalog ポートフォリオと製品のデプロイを自動化する
CDK スタック合成をカスタマイズする