AWS BackupでのクロスリージョンバックアップをCDKで一撃でデプロイする

cdk-remote-stackを使ってAWS Backupによるクロスリージョンバックアップ構成を一撃で構築するCDKアプリケーションを作成します。
2023.11.27

はじめに

こんにちは、AWS事業本部のイシザワです。

先日、AWS BackupでのクロスリージョンバックアップをCloudFormationで構築する機会がありました。

CloudFormationでは1つのスタックには1つのリージョンのリソースしか含めることができないため、クロスリージョンの構成をとると複数のスタックを作成する必要があります。

CloudFormationでのスタックのFn::ImportValueでの参照や、SSMパラメータの動的参照では異なるリージョンの値を参照できません。 そのため、クロスリージョンの構成の場合はリージョン間での値の受け渡しは、テンプレートのパラメータ経由で入力する等の間接的な方法を取る必要があります。

一方、CDKでは cdk-remote-stack を使うことで、異なるリージョンのスタックの値を参照することが出来ます。

今回は、これを利用してクロスリージョンのAWS Backupの構成を一撃で構築するCDKアプリケーションを作成してみました。

構成

以下のアーキテクチャを構築します。

作成するスタックは以下の通りです。矢印は依存先のスタックを示しています。

ソースコード

ソースコードは以下のリポジトリに置いてあります。

https://github.com/cm-ishizawa-takuya/cross-region-backup

各コンポーネントの構成を解説します。

BackupRoleStack

ソースコード

lib/stack/backup-role-stack.ts

import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
import { Stack, StackProps, CfnOutput } from 'aws-cdk-lib';

export interface BackupRoleStackProps extends StackProps {
  backupRoleName?: string;
}

export class BackupRoleStack extends Stack {
  public readonly backupRole: iam.IRole;

  public static readonly BACKUP_ROLE_ARN_OUTPUT_KEY = 'BackupRoleArn';

  constructor(scope: Construct, id: string, props: BackupRoleStackProps) {
    super(scope, id, props);

    const roleName = props.backupRoleName ?? 'cross-region-backup-role';

    this.backupRole = new iam.Role(this, 'BackupRole', {
      roleName,
      assumedBy: new iam.ServicePrincipal('backup.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          'service-role/AWSBackupServiceRolePolicyForBackup',
        ),
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          'service-role/AWSBackupServiceRolePolicyForRestores',
        ),
      ],
      inlinePolicies: {
        'iam-pass-policy': new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: ['iam:PassRole'],
              resources: ['*'],
            }),
          ],
        }),
      },
    });

    // クロスリージョン参照用
    new CfnOutput(this, BackupRoleStack.BACKUP_ROLE_ARN_OUTPUT_KEY, {
      value: this.backupRole.roleArn,
    });
  }
}
  • BackupRoleStackはAWS Backupのバックアップジョブを実行するためのIAMロールを作成します。
  • 22~29行目:バックアップとリストア用のAWS Managed Policyをアタッチします。
  • 30~40行目:リストアにiam:PassRoleが必要なため、iam:PassRoleを許可するインラインポリシーをアタッチします。
  • 43~46行目:クロスリージョン参照用の出力を作成します。

PrimaryKeyStack

ソースコード

lib/stack/primary-key-stack.ts

import * as kms from 'aws-cdk-lib/aws-kms';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Stack, StackProps, Duration, RemovalPolicy, Aws, CfnOutput } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export interface PrimaryKeyStackProps extends StackProps {
  keyAdminUserArns: string[];
  subRegion: string;
  backupRole: iam.IRole;
  pendingWindow?: Duration;
  removalPolicy?: RemovalPolicy;
}

export class PrimaryKeyStack extends Stack {
  public readonly key: kms.IKey;

  public static readonly KEY_ARN_OUTPUT_KEY = 'KeyArn';

  constructor(scope: Construct, id: string, props: PrimaryKeyStackProps) {
    super(scope, id, props);

    this.key = new kms.Key(this, 'Key', {
      enableKeyRotation: true,
      pendingWindow: props.pendingWindow,
      alias: 'alias/backup-key',
      keySpec: kms.KeySpec.SYMMETRIC_DEFAULT,
      keyUsage: kms.KeyUsage.ENCRYPT_DECRYPT,
      policy: new iam.PolicyDocument({
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            principals: props.keyAdminUserArns.map((arn) => new iam.ArnPrincipal(arn)),
            actions: ['kms:*'],
            resources: ['*'],
          }),
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            principals: [new iam.AccountRootPrincipal()],
            actions: ['kms:*'],
            resources: ['*'],
            conditions: {
              ArnLike: {
                'aws:PrincipalArn': [
                  `arn:${Aws.PARTITION}:iam::${Aws.ACCOUNT_ID}:role/cdk-*-cfn-exec-role-${Aws.ACCOUNT_ID}-${Aws.REGION}`,
                  `arn:${Aws.PARTITION}:iam::${Aws.ACCOUNT_ID}:role/cdk-*-cfn-exec-role-${Aws.ACCOUNT_ID}-${props.subRegion}`,
                ],
              },
            },
          }),
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            principals: [new iam.ArnPrincipal(props.backupRole.roleArn)],
            actions: [
              'kms:Encrypt',
              'kms:Decrypt',
              'kms:ReEncrypt*',
              'kms:GenerateDataKey*',
              'kms:DescribeKey',
            ],
            resources: ['*'],
          }),
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            principals: [new iam.ArnPrincipal(props.backupRole.roleArn)],
            actions: ['kms:CreateGrant'],
            resources: ['*'],
            conditions: {
              StringEquals: {
                'kms:GrantIsForAWSResource': true,
              },
            },
          }),
        ],
      }),
      removalPolicy: props.removalPolicy,
    });

    const cfnKey = this.key.node.defaultChild as kms.CfnKey;
    cfnKey.addPropertyOverride('MultiRegion', true);

    // クロスリージョン参照用
    new CfnOutput(this, PrimaryKeyStack.KEY_ARN_OUTPUT_KEY, { value: this.key.keyArn });
  }
}
  • PrimaryKeyStackはAWS Backupのバックアップジョブで使用するキーを作成します。
  • 30~35行目:キーの管理者として指定されたユーザーに対してkms:*を許可するキーポリシーを作成します。
  • 36~49行目:CDKによるスタックのデプロイ時に利用されるIAMロールに対してkms:*を許可するキーポリシーを作成します。
    • バックアップ先リージョンのスタックのデプロイ時にも参照されるため、バックアップ元とバックアップ先の2つのロールに対して許可する必要があります。
  • 50~72行目:AWS Backupのバックアップジョブに対して必要な権限を許可するキーポリシーを作成します。
  • 81~82行目:クロスリージョン参照用の出力を作成します。

ReplicaKeyStack

ソースコード

lib/stack/replica-key-stack.ts

import * as kms from 'aws-cdk-lib/aws-kms';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Stack, StackProps, Duration, RemovalPolicy, Aws } from 'aws-cdk-lib';
import { Construct } from 'constructs';

import { RemoteOutputs } from 'cdk-remote-stack';

import { BackupRoleStack } from './backup-role-stack';
import { PrimaryKeyStack } from './primary-key-stack';

export interface ReplicaKeyStackProps extends StackProps {
  keyAdminUserArns: string[];
  backupRoleStack: BackupRoleStack;
  primaryKeyStack: PrimaryKeyStack;
  pendingWindow?: Duration;
  removalPolicy?: RemovalPolicy;
}

export class ReplicaKeyStack extends Stack {
  public readonly key: kms.CfnReplicaKey;

  constructor(scope: Construct, id: string, props: ReplicaKeyStackProps) {
    super(scope, id, props);

    const backupRoleStackOutputs = new RemoteOutputs(this, 'BackupRoleStackOutputs', {
      stack: props.backupRoleStack,
    });
    const backupRoleArn = backupRoleStackOutputs.get(BackupRoleStack.BACKUP_ROLE_ARN_OUTPUT_KEY);

    const primaryKeyStackOutputs = new RemoteOutputs(this, 'PrimaryKeyStackOutputs', {
      stack: props.primaryKeyStack,
    });
    const primaryKeyArn = primaryKeyStackOutputs.get(PrimaryKeyStack.KEY_ARN_OUTPUT_KEY);

    this.key = new kms.CfnReplicaKey(this, 'Key', {
      pendingWindowInDays: props.pendingWindow?.toDays(),
      primaryKeyArn,
      keyPolicy: JSON.stringify(
        new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              principals: props.keyAdminUserArns.map((arn) => new iam.ArnPrincipal(arn)),
              actions: ['kms:*'],
              resources: ['*'],
            }),
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              principals: [new iam.AccountRootPrincipal()],
              actions: ['kms:*'],
              resources: ['*'],
              conditions: {
                ArnLike: {
                  'aws:PrincipalArn': `arn:${Aws.PARTITION}:iam::${Aws.ACCOUNT_ID}:role/cdk-*-cfn-exec-role-${Aws.ACCOUNT_ID}-${Aws.REGION}`,
                },
              },
            }),
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              principals: [new iam.ArnPrincipal(backupRoleArn)],
              actions: [
                'kms:Encrypt',
                'kms:Decrypt',
                'kms:ReEncrypt*',
                'kms:GenerateDataKey*',
                'kms:DescribeKey',
              ],
              resources: ['*'],
            }),
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              principals: [new iam.ArnPrincipal(backupRoleArn)],
              actions: ['kms:CreateGrant'],
              resources: ['*'],
              conditions: {
                StringEquals: {
                  'kms:GrantIsForAWSResource': true,
                },
              },
            }),
          ],
        }).toJSON(),
      ),
    });
    this.key.applyRemovalPolicy(props.removalPolicy);
  }
}
  • ReplicaKeyStackはバックアップ先リージョンで使用するキーを作成します。このキーはバックアップ元リージョンのキーをレプリケーションしたものです。
  • 25~28行目:RemoteOutputsを使用してバックアップ元リージョンのスタックの出力からバックアップ元リージョンのバックアップロールのARNを取得します。
  • 30~33行目:RemoteOutputsを使用してバックアップ元リージョンのスタックの出力からバックアップ元リージョンのキーのARNを取得します。
  • 38~83行目:キーポリシーを設定しています。内容はPrimaryKeyStackとほぼ同様です。

SubRegionBackupStack

ソースコード

lib/stack/sub-region-backup-stack.ts

import * as backup from 'aws-cdk-lib/aws-backup';
import { Stack, StackProps, Aws, RemovalPolicy, CfnOutput } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export interface SubRegionBackupStackProps extends StackProps {
  keyArn: string;
  backupVaultName?: string;
  removalPolicy?: RemovalPolicy;
}

export class SubRegionBackupStack extends Stack {
  public readonly vault: backup.CfnBackupVault;

  public static readonly BACKUP_VAULT_ARN_OUTPUT_KEY = 'BackupVaultArn';

  constructor(scope: Construct, id: string, props: SubRegionBackupStackProps) {
    super(scope, id, props);

    const vaultName = props.backupVaultName ?? `backup-vault-${Aws.REGION}`;

    this.vault = new backup.CfnBackupVault(this, 'Vault', {
      backupVaultName: vaultName,
      encryptionKeyArn: props.keyArn,
    });
    this.vault.applyRemovalPolicy(props.removalPolicy);

    // クロスリージョン参照用
    new CfnOutput(this, SubRegionBackupStack.BACKUP_VAULT_ARN_OUTPUT_KEY, {
      value: this.vault.attrBackupVaultArn,
    });
  }
}
  • SubRegionBackupStackはバックアップ先リージョンのAWS Backupのバックアップジョブで使用するバックアップボールトを作成します。
  • 27~30行目:クロスリージョン参照用の出力を作成します。

MainRegionBackupStack

ソースコード

lib/stack/main-region-backup-stack.ts

import * as backup from 'aws-cdk-lib/aws-backup';
import { Stack, StackProps, Aws, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';

import { RemoteOutputs } from 'cdk-remote-stack';

import { SubRegionBackupStack } from './sub-region-backup-stack';

export interface MainRegionBackupStackProps extends StackProps {
  subRegionBackupStack: SubRegionBackupStack;
  keyArn: string;
  backupRoleArn: string;
  backupVaultName?: string;
  cronExpression: string;
  backupTagKey: string;
  backupTagValue: string;
  deleteAfterDays?: number;
  removalPolicy?: RemovalPolicy;
}

export class MainRegionBackupStack extends Stack {
  vault: backup.CfnBackupVault;

  constructor(scope: Construct, id: string, props: MainRegionBackupStackProps) {
    super(scope, id, props);

    const subRegionBackupStackOutputs = new RemoteOutputs(this, 'SubRegionBackupStackOutputs', {
      stack: props.subRegionBackupStack,
    });
    const subRegionBackupVaultArn = subRegionBackupStackOutputs.get(
      SubRegionBackupStack.BACKUP_VAULT_ARN_OUTPUT_KEY,
    );

    const vaultName = props.backupVaultName ?? `backup-vault-${Aws.REGION}`;
    const deleteAfterDays = props.deleteAfterDays ?? 7;

    this.vault = new backup.CfnBackupVault(this, 'Vault', {
      backupVaultName: vaultName,
      encryptionKeyArn: props.keyArn,
    });
    this.vault.applyRemovalPolicy(props.removalPolicy);

    const plan = new backup.CfnBackupPlan(this, 'Plan', {
      backupPlan: {
        backupPlanName: 'cross-region-backup-plan',
        backupPlanRule: [
          {
            ruleName: 'cross-region-backup-plan-rule',
            targetBackupVault: this.vault.ref,
            scheduleExpression: props.cronExpression,
            startWindowMinutes: 60,
            completionWindowMinutes: 180,
            lifecycle: {
              deleteAfterDays,
            },
            copyActions: [
              {
                destinationBackupVaultArn: subRegionBackupVaultArn,
              },
            ],
          },
        ],
      },
    });

    new backup.CfnBackupSelection(this, 'Selection', {
      backupPlanId: plan.ref,
      backupSelection: {
        selectionName: 'cross-region-backup-selection',
        iamRoleArn: props.backupRoleArn,
        resources: ['arn:aws:ec2:*:*:instance/*'],
        listOfTags: [
          {
            conditionType: 'STRINGEQUALS',
            conditionKey: props.backupTagKey,
            conditionValue: props.backupTagValue,
          },
        ],
        conditions: {
          StringEquals: [
            {
              ConditionKey: `aws:ResourceTag/${props.backupTagKey}`,
              ConditionValue: props.backupTagValue,
            },
          ],
        },
      },
    });
  }
}
  • MainRegionBackupStackはバックアップ元リージョンのAWS Backupのバックアップジョブを作成します。
  • 27~32行目:RemoteOutputsを使用してバックアップ先リージョンのスタックの出力からバックアップ先リージョンのバックアップボールトのARNを取得します。
  • 49行目:上記で取得したバックアップ先リージョンのバックアップボールトのARNを指定します。

BackupStage

ソースコード

lib/stage/backup-stage.ts

import { Stage, StageProps, Duration, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';

import { BackupRoleStack } from '../stack/backup-role-stack';
import { PrimaryKeyStack } from '../stack/primary-key-stack';
import { ReplicaKeyStack } from '../stack/replica-key-stack';
import { SubRegionBackupStack } from '../stack/sub-region-backup-stack';
import { MainRegionBackupStack } from '../stack/main-region-backup-stack';

export interface BackupStageProps extends StageProps {
  keyAdminUserArns: string[];
  mainRegion: string;
  subRegion: string;
  backupRoleName?: string;
  primaryKeyPendingWindow?: Duration;
  replicaKeyPendingWindow?: Duration;
  mainRegionBackupVaultName?: string;
  subRegionBackupVaultName?: string;
  cronExpression: string;
  backupTagKey: string;
  backupTagValue: string;
  removalPolicy?: RemovalPolicy;
}

export class BackupStage extends Stage {
  constructor(scope: Construct, id: string, props: BackupStageProps) {
    super(scope, id, props);

    const backupRoleStack = new BackupRoleStack(this, 'BackupRoleStack', {
      backupRoleName: props.backupRoleName,
      env: {
        region: props.mainRegion,
      },
    });

    const primaryKeyStack = new PrimaryKeyStack(this, 'PrimaryKeyStack', {
      keyAdminUserArns: props.keyAdminUserArns,
      subRegion: props.subRegion,
      backupRole: backupRoleStack.backupRole,
      pendingWindow: props.primaryKeyPendingWindow,
      removalPolicy: props.removalPolicy,
      env: {
        region: props.mainRegion,
      },
    });

    const replicaKeyStack = new ReplicaKeyStack(this, 'ReplicaKeyStack', {
      keyAdminUserArns: props.keyAdminUserArns,
      backupRoleStack,
      primaryKeyStack,
      pendingWindow: props.replicaKeyPendingWindow,
      removalPolicy: props.removalPolicy,
      env: {
        region: props.subRegion,
      },
    });
    replicaKeyStack.addDependency(backupRoleStack);
    replicaKeyStack.addDependency(primaryKeyStack);

    const subRegionBackupStack = new SubRegionBackupStack(this, 'SubRegionBackupStack', {
      keyArn: replicaKeyStack.key.attrArn,
      backupVaultName: props.subRegionBackupVaultName,
      removalPolicy: props.removalPolicy,
      env: {
        region: props.subRegion,
      },
    });

    const mainRegionBackupStack = new MainRegionBackupStack(this, 'MainRegionBackupStack', {
      subRegionBackupStack,
      keyArn: primaryKeyStack.key.keyArn,
      backupRoleArn: backupRoleStack.backupRole.roleArn,
      backupVaultName: props.mainRegionBackupVaultName,
      cronExpression: props.cronExpression,
      backupTagKey: props.backupTagKey,
      backupTagValue: props.backupTagValue,
      removalPolicy: props.removalPolicy,
      env: {
        region: props.mainRegion,
      },
    });
    mainRegionBackupStack.addDependency(subRegionBackupStack);
  }
}
  • BackupStageはAWS Backupのクロスリージョンバックアップの構成を一括で構築するためのステージです。
  • 29~34行目:バックアップロールを作成するスタックBackupRoleStackを作成します。
  • 36~45行目:バックアップ元リージョンで使用するキーを作成するスタックPrimaryKeyStackを作成します。
  • 47~56行目:バックアップ先リージョンで使用するキーを作成するスタックReplicaKeyStackを作成します。
  • 57~58行目:ReplicaKeyStackBackupRoleStackPrimaryKeyStackに暗黙的に依存するため、依存関係を追加します。
    • この暗黙的な依存はクロスリージョン参照によるものです。
  • 60~67行目:バックアップ先リージョンのバックアップボールトを作成するスタックSubRegionBackupStackを作成します。
  • 69~81行目:バックアップ元リージョンのバックアップジョブを作成するスタックMainRegionBackupStackを作成します。
  • 82行目:MainRegionBackupStackSubRegionBackupStackに暗黙的に依存するため、依存関係を追加します。
    • この暗黙的な依存もクロスリージョン参照によるものです。

デプロイ

bin/cross-region-backup.ts内のBackupStageのパラメータkeyAdminUserArnsを適切な値に変更してから、以下のコマンドを実行することでデプロイします。

npm install
npm run cdk deploy BackupStage/**

デプロイ後、テスト用のEC2インスタンスを作成し、Backupタグに値Trueを設定します。(バックアップ対象のタグキーとタグ値はデプロイ時のパラメータで変更可能です)

デプロイ時に指定した時刻になるとバックアップが実行され、バックアップ元リージョンのバックアップボールトに復旧ポイントが作成されます。

その後、バックアップ先リージョンのバックアップボールトにも復旧ポイントが作成されます。

復元の際はデフォルトのIAMロールで無く、BackupRoleStackで作成したバックアップロールを指定する必要があるためご注意ください。

おわりに

今回はAWS Backupのクロスリージョンバックアップの構成を一撃で構築するCDKアプリケーションを作成してみました。

このブログが誰かの助けになれば幸いです。