AWS CDKで複数アカウント・リージョンにまたがるAppをデプロイする

## TL;DR

- `Stack` に `env` を渡すことでアカウントとリージョンを指定できる
  - `env` の違う `Stack` を1Appに紐付けることが可能
- クロスアカウント参照するリソースの `name` を固定する
  - CDKに自動で決めさせたければ `PhysicalName.GENERATE_IF_NEEDED` を指定する
- クロスリージョン参照をする/されるリソースを持つ `Stack` は `crossRegionReferences = true` にする
- CDKはブートストラップロールに `assumeRole` する権限さえあれば、自動でアカウントやリージョンをまたいでくれる
  - `cdk bootstrap` の `--trust` オプションで、ブートストラップロールが他のアカウントからの `assumeRole` を受け入れるよう設定できる
- `Stack` 毎に適切な `--profile` を指定してもデプロイはできる

## やりたいこと

以下のような条件のCDKアプリを記述したい。

- アプリケーションアカウント( `appAccount:111111111111` )とデータ集積用アカウント( `lakeAccount:222222222222` )を運用している
  - 基本的に `appAccount` にアプリを配置して、ログを `lakeAccount` に送りたい
- 基本的には東京リージョンで運用したいが、東京リージョンで未提供の機能を使いたい(ex. SESの受信)

たとえばこんな感じ。

```mermaid
graph LR
  subgraph appAccount
    subgraph app:us-east-1[us-east-1]
      SES-->Lambda
    end
    subgraph app:ap-northeast-1[ap-northeast-1]
      Lambda-->ECSTask-->CloudWatchLog
    end
  end

  subgraph lakeAccount
    subgraph lake:ap-northeast-1[ap-northeast-1]
      CloudWatchLog-->KinesisStream
    end
  end
```

## CDKアプリ

今回はTypeScriptで書いてみたが、他の言語でも同じように書けるはず。

```diff_typescript
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import * as ses from "aws-cdk-lib/aws-ses";
import * as ses_actions from "aws-cdk-lib/aws-ses-actions";
import * as lambda_nodejs from "aws-cdk-lib/aws-lambda-nodejs";
import * as logs from "aws-cdk-lib/aws-logs";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as ecr_assets from "aws-cdk-lib/aws-ecr-assets";
import * as kinesis from "aws-cdk-lib/aws-kinesis";
import * as logs_destinations from "aws-cdk-lib/aws-logs-destinations";

const app = new cdk.App();

// アプリケーション(us-east-1)
const appVirginia = new cdk.Stack(app, "AppVirginia", {
+ env: {
+   account: "111111111111",
+   region: "us-east-1",
+ },
+ crossRegionReferences: true,
});

const func = new lambda_nodejs.NodejsFunction(appVirginia, "Function");
const receiptRuleSet = new ses.ReceiptRuleSet(appVirginia, "ReceiptRuleSet");
const receiptRule = new ses.ReceiptRule(appVirginia, "ReceiptRule", {
  ruleSet: receiptRuleSet,
  actions: [new ses_actions.Lambda({ function: func })],
});

// アプリケーション(ap-northeast-1)
const appTokyo = new cdk.Stack(app, "AppTokyo", {
+ env: {
+   account: "111111111111",
+   region: "ap-northeast-1",
+ },
+ crossRegionReferences: true,
});

const logGroup = new logs.LogGroup(appTokyo, "LogGroup");
const taskDefinition = new ecs.TaskDefinition(appTokyo, "TaskDefinition", {
  compatibility: ecs.Compatibility.FARGATE,
  cpu: "1024",
  memoryMiB: "2048",
});
taskDefinition.addContainer("Container", {
  image: ecs.ContainerImage.fromDockerImageAsset(
    new ecr_assets.DockerImageAsset(appTokyo, "ContainerImage", {
      directory: ".",
    })
  ),
  logging: new ecs.AwsLogDriver({ streamPrefix: "main", logGroup: logGroup }),
});

// データ集積(ap-northeast-1)
const logTokyo = new cdk.Stack(app, "LogTokyo", {
+ env: {
+   account: "222222222222",
+   region: "ap-northeast-1",
+ },
});

const stream = new kinesis.Stream(logTokyo, "Stream", {
+ streamName: cdk.PhysicalName.GENERATE_IF_NEEDED,
});

// スタックをまたいでも参照操作は同じ
taskDefinition.grantRun(func);
logGroup.addSubscriptionFilter("SubscriptionFilter", {
  destination: new logs_destinations.KinesisDestination(stream),
  filterPattern: logs.FilterPattern.allEvents(),
});
```

肝となるのは3つ。

### Stackのenv

`Stack` に `env` で、アカウントIDとリージョンを渡せる。
複数の `Stack` を同じ `App` に紐づければ、マルチスタックアプリケーションになるが、 `Stack` 間の `env` が相違していても問題ない。

```diff_typescript
// アプリケーション(us-east-1)
const appVirginia = new cdk.Stack(app, "AppVirginia", {
+ env: {
+   account: "111111111111",
+   region: "us-east-1",
+ },
  crossRegionReferences: true,
});

// ...

// アプリケーション(ap-northeast-1)
const appTokyo = new cdk.Stack(app, "AppTokyo", {
+ env: {
+   account: "111111111111",
+   region: "ap-northeast-1",
+ },
  crossRegionReferences: true,
});

// ...

// データ集積(ap-northeast-1)
const logTokyo = new cdk.Stack(app, "LogTokyo", {
+ env: {
+   account: "222222222222",
+   region: "ap-northeast-1",
+ },
});
```

ステージ別でアカウントを切り替えたい場合は、 `env` に与える値を `context` あたりを使ってプログラムすれば良い。

### クロスリージョン参照

クロスリージョン参照する/される `Stack` の `props` で、 `crossRegionReferences = true` に設定する。

```diff_typescript
// アプリケーション(us-east-1)
const appVirginia = new cdk.Stack(app, "AppVirginia", {
  env: {
    account: "111111111111",
    region: "us-east-1",
  },
+ crossRegionReferences: true,
});

// ...

// アプリケーション(ap-northeast-1)
const appTokyo = new cdk.Stack(app, "AppTokyo", {
  env: {
    account: "111111111111",
    region: "ap-northeast-1",
  },
+ crossRegionReferences: true,
});
```

- くどいようだが、する/される側の両方に設定が必要
- クロスリージョン参照を実現するために、CDKはカスタムリソースを生成する
  - 余分なリソースを嫌う場合は、クロスアカウント参照の手法(後述)も要検討
- 同一アカウントに属する `Stack` 間でしか機能しないので注意
- 執筆(2023/12)時点ではexperimentalなので一応注意

### クロスアカウント参照

クロスアカウント参照されるリソースのリソースネームに相当するプロパティに、固定値を使用する。

ただし、明示的にリソースネームに固定値を与えることは、ベストプラクティスでは推奨されていない。

https://aws.amazon.com/jp/blogs/news/best-practices-for-developing-cloud-applications-with-aws-cdk/

CDKに名前を決めさせつつ、クロスアカウント参照をしたい場合は、リソースネームに `PhysicalName.GENERATE_IF_NEEDED` を指定する。

```diff_typescript
const stream = new kinesis.Stream(logTokyo, "Stream", {
+ streamName: cdk.PhysicalName.GENERATE_IF_NEEDED,
});
```

- こちらは参照される側だけでOK
- `PhysicalName.GENERATE_IF_NEEDED` はクロスリージョン参照でも使えるが、そもそもリージョンをまたげないものには使用してもエラーになる
  - 今回の例で使っているECSのタスク定義が該当している
- リソースが内部でサブリソースを自動生成する場合、リソースに `PhysicalName.GENERATE_IF_NEEDED` を指定してもサブリソースには波及しない
  - 例: lambdaのサービスロール
  - このような場合 `PhysicalName.GENERATE_IF_NEEDED` を命名したリソースを明示的に与える必要がある
    - 例:
      ```diff_typescript
      new lambda_nodejs.NodejsFunction(stack, "Function", {
        role: new iam.Role(stack, "FunctionRole", {
          assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
      +   roleName: cdk.PhysicalName.GENERATE_IF_NEEDED,
        }),
      });
      ```
      このように他に必須パラメータがあると煩わしい(公式対応してくれないかな)

## デプロイする

条件さえ満たしていれば `cdk deploy --all` でデプロイ可能。

その条件とは、実行ユーザorロールが、以下のロールに `assumeRole` できること。

- `arn:aws:iam::${利用したいアカウントID}:role/cdk-${QUALIFIER}-file-publishing-role-${利用したいアカウントID}-${利用したいリージョン}`
- `arn:aws:iam::${利用したいアカウントID}:role/cdk-${QUALIFIER}-image-publishing-role-${利用したいアカウントID}-${利用したいリージョン}`
- `arn:aws:iam::${利用したいアカウントID}:role/cdk-${QUALIFIER}-lookup-role-${利用したいアカウントID}-${利用したいリージョン}`
- `arn:aws:iam::${利用したいアカウントID}:role/cdk-${QUALIFIER}-deploy-role-${利用したいアカウントID}-${利用したいリージョン}`

これらのロールは、CDKのブートストラップによって作成され、CDKから利用されている。
特にいじらすブートストラップした場合は、 `QUALIFIER=hnb659fds` 。

https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html

先述した `env` をもとに、CDKは自動的に `Stack` にあったロールにassumeしてデプロイを実行してくれる。

- `cdk bootstrap` に `--trust <アカウントID>` を渡すことで、ブートストラップロール側のPrincipalは設定してくれる
  - 複数のアカウントを `trust` したい場合はカンマで区切る
  - `trust` が済んでいれば、デプロイに使うユーザ/ロールにブートストラップロールへの `iam:assumeRole` を設定するだけで良い 
- 具体的な仕組みは、公式のSecurity And Safety Dev Guideがわかりやすい
  
https://github.com/aws/aws-cdk/wiki/Security-And-Safety-Dev-Guide#authentication

もちろん、 `Stack` の名前と、その `env` にあった `--profile` を指定してもデプロイは可能。
どちらを使うかは、アカウントの運用方法に依ると思う。

### 蛇足

使用するロールは、厳密には `Stack` に渡す `synthesizer` によって決まる。
今回紹介したものは、これのデフォルト値である `DefaultStackSynthesizer` を、これまたデフォルトの設定で使用した場合の挙動である。

使用するクラスや与える設定によってかなり動作を変えられるので、気になる方は調べてみてほしい。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.StackSynthesizer.html

## おわりに

1つのアプリケーションを複数のアカウント(ステージ)にデプロイする方法は結構転がっているが、
今回のような利用方法についてはあまり情報がなかったので、まとめてみた。
そもそも「そういう」アカウント構成じゃないと使えない知識ではあるが、どなたかのお役に立てば幸いである。