Amazon EventBridgeの「リージョン間イベント転送」をCloudFormation/Terraformで一撃設定する

もう、マネジメントコンソールの右上で「リージョン」を何度も切り替えるのはサヨナラしましょう。
2021.11.30

みなさん、こんにちは!
福岡オフィスの青柳です。

前回、Amazon EventBridgeの「リージョン間イベント転送」に関して、利用するメリットや設定手順について解説しました。

マネジメントコンソールから各リージョンのEventBridgeを設定していく訳ですが、対象のリージョンが増えると、各リージョンで同じような設定を繰り返すのは大変です。

ということで、前回予告しました通り、「CloudFormation」や「Terraform」などのIaC (Infrastructure as Code) を使った「複数リージョンの一括設定」について解説したいと思います。

構築する環境 (前回のおさらい)

下図のような環境を構築します。

個々のリソースの設定内容などは、前回のブログ記事を併せて参照して頂くと幸いです。

なお、前回のサンプルと同様に、通知する対象のイベントは「EC2インスタンスの状態変化」としています。

CloudFormationを使った構築

前回マネジメントコンソールから作成した手順と同様に、「受信側 (Receiver)」→「送信側 (Sender)」の順に設定を行っていきます。

受信側のCloudFormationテンプレート

受信側リージョンにリソースを展開するためのテンプレートを用意します。

eventbridge-receiver.yaml

個々のリソース別に説明していきます。

EventBridgeカスタムイベントバス

  ReceiverEventBus:
    Type: AWS::Events::EventBus
    Properties:
      Name: !Sub "${SystemName}-${EnvironmentName}-eventbridge-bus-receiver"

設定項目は名前のみです。

EventBridgeイベントルール

  ReceiverEventRule:
    Type: AWS::Events::Rule
    Properties:
      Name: !Sub "${SystemName}-${EnvironmentName}-eventbridge-rule-receiver"
      Description: "Publish event notificatons to SNS"
      EventBusName: !Ref ReceiverEventBus
      EventPattern: |
        {
          "source": ["aws.ec2"],
          "detail-type": ["EC2 Instance State-change Notification"],
          "detail": {
            "state": ["running", "stopped"]
          }
        }
      Targets:
        - Id: "SNSTopic1"
          Arn: !Ref SNSTopic

EventBusName:で、イベントルールを紐付けるカスタムイベントバスを指定しています。
(これを省略してしまうと、デフォルトのイベントバスに紐付いて作成されてしまいます)

EventPattern:で、イベントのフィルタリングを行うための「イベントパターン」を指定します。
一からJSON形式で記述してもよいのですが、マネジメントコンソールのイベントルール作成画面で入力した内容に応じてJSONテキストが自動生成されますので、そこからコピー&ペーストする方法が楽だと思います。

Targets:には、イベント送信先のターゲットとなる「SNSトピック」をARN形式で指定します。
ここで、マネジメントコンソールで作成する時には無かったId:という項目が登場しています。
この項目は必須であり省略不可のため、忘れないように記述してください。
IDは任意かつ一意な文字列を指定します。(あまり考えずに適当な文字列を指定して問題ないと思います)

SNSトピック

  SNSTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: !Sub "${SystemName}-${EnvironmentName}-sns-topic"

  SNSTopicPolicy:
    Type: AWS::SNS::TopicPolicy
    Properties:
      Topics:
        - !Ref SNSTopic
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "events.amazonaws.com"
            Action:
              - "sns:Publish"
            Resource: !Ref SNSTopic

SNSトピック自体の設定項目は名前のみです。

ただし、マネジメントコンソールでは意識することが無かった「アクセスポリシー」を明示的に設定する必要があります。
(マネジメントコンソールで操作する場合は、EventBridgeのイベントルールでSNSトピックをターゲットに指定したタイミングで、アクセスポリシーが自動的に設定されるため、意識する必要がありません)

上記のテンプレート内容によって設定されるポリシードキュメントは下記の通りです。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "events.amazonaws.com"
      },
      "Action": "sns:Publish",
      "Resource": "arn:aws:sns:ap-northeast-1:123456789012:example-dev-sns-topic"
    }
  ]
}

(SNSサブスクリプション)

今回のCloudFormationテンプレートでは、SNSサブスクリプションについて記述していません。

通知先に指定したメールアドレスに対して「確認」の対応が必要である点などから、敢えて自動化せず手動で都度作成した方がよいだろうという考え方です。

IAMロールおよびIAMポリシー

「送信側」のEventBridgeイベントルールを作成する際に必要となる「IAMロール」および「IAMポリシー」を作成します。
(受信側EventBridgeイベントバスに対するアクセス権限を指定するため、受信側のCloudFormationテンプレートで作成しています)

マネジメントコンソールでEventBridgeイベントルールを作成する場合、ターゲットとして「別のアカウントまたはリージョンのイベントバス」を選択すると、送信先イベントバスに対するアクセス許可を設定するためのIAMロールを指定する項目が現れます。
ここで「この特定のリソースに対して新しいロールを作成する」を選択すると、必要なIAMロール・IAMポリシーが自動的に作成されます。

一方、CloudFormationを使用して構築する場合は、自分でIAMロール・IAMポリシーを作成する必要があります。

まずIAMロールから:

  IAMRoleEventBridge:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${SystemName}-${EnvironmentName}-eventbridge-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "events.amazonaws.com"
            Action:
              - "sts:AssumeRole"

このIAMロールは送信側のEventBridgeイベントルールにアタッチする必要があるため、events.amazonaws.comを信頼エンティティとする信頼関係ポリシーを定義しています。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "events.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

続いてIAMポリシー:

  IAMPolicyEventBridge:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: !Sub "${SystemName}-${EnvironmentName}-eventbridge-policy"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Action:
              - "events:PutEvents"
            Resource:
              - !GetAtt ReceiverEventBus.Arn
      Roles:
        - !Ref IAMRoleEventBridge

受信側イベントバスに対してイベント送信 (events:PutEvents) を許可するポリシーを定義しています。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "events:PutEvents"
      ],
      "Resource": [
        "arn:aws:events:ap-northeast-1:123456789012:event-bus/example-dev-eventbridge-bus-receiver"
      ],
      "Effect": "Allow"
    }
  ]
}

リソースARNのエクスポート

最後に、受信側で作成したリソースのうち、送信側の設定で必要となる情報をOutputs:でエクスポートしています。

Outputs:
  ReceiverEventBusARN:
    Value: !GetAtt ReceiverEventBus.Arn
    Export:
      Name: !Sub "${AWS::StackName}::ReceiverEventBusARN"

  IAMRoleEventBridgeARN:
    Value: !GetAtt IAMRoleEventBridge.Arn
    Export:
      Name: !Sub "${AWS::StackName}::IAMRoleEventBridgeARN"

送信側のCloudFormationテンプレート

送信側では複数リージョンにリソースを展開する必要があるため、CloudFormationの「スタックセット」を使用します。

まず、単一リージョン分のテンプレートを用意します。

eventbridge-sender.yaml

送信側で作成するリソースは1つだけです。

EventBridgeイベントルール

Resources:
  SenderEventRule:
    Type: AWS::Events::Rule
    Properties:
      Name: !Sub "${SystemName}-${EnvironmentName}-eventbridge-rule-sender"
      Description: "Send events to receiver EventBridge"
      EventPattern: |
        {
          "source": ["aws.ec2"],
          "detail-type": ["EC2 Instance State-change Notification"]
        }
      Targets:
        - Id: "ReceiverEventBus"
          Arn: !Ref TargetEventBusARN
          RoleArn: !Ref IAMRoleARN

受信側のイベントルールの定義と似ていますが、一部が異なります。

まず、送信側のイベントルールは「デフォルト」のイベントバスに紐付けるため、EventBusName:の指定は不要です。(省略することで「default」が使用されます)

EventPattern:には、受信側と同様に、イベントのフィルタリングを行うための「イベントパターン」を指定します。(こちらもマネジメントコンソールで自動生成されたJSONテキストをコピー&ペーストするのが楽でしょう)

Targets:には、イベント送信先のターゲットとなる「受信側EventBridgeのカスタムイベントバス」をARN形式で指定します。
受信側と同様にId:の指定が必須です。
受信側に無かった項目としてRoleArnがあります。ここに、受信側で作成した「IAMロール」のARNを指定します。

受信側の環境構築

受信側となるリージョンを選択した状態で、用意したCloudFormationテンプレートを使ってスタックを作成します。

スタックの完了後、「出力」タブに表示される情報は、送信側の環境構築で必要となります。

  • IAMRoleEventBridgeARN (IAMロールのARN)
  • ReceiverEventBusARN (EventBridgeイベントバスのARN)

送信側の環境構築

CloudFormationの「スタックセット」を使って複数リージョンに対してリソースを展開します。

まず、事前準備として、スタックセットの実行元から実行先に対してアクセスを許可するための「IAMロール」を作成しておく必要があります。(スタックセットを初めて利用する場合のみ)
下記リンク先を参照して準備を行っておいてください。
セルフマネージド型のアクセス許可を付与する - AWS CloudFormation

準備が整いましたら、スタックセットを作成していきます。
なお、スタックセットを作成するのはどのリージョンでも構いません。(後でスタック展開先のリージョンを個別に指定するため)

ClaudFormationのメニューから「StackSets」を選択して、「StackSetの作成」をクリックします。

「テンプレートファイルのアップロード」を選択して、「ファイルの選択」からCloudFormationテンプレートファイルの場所を指定します。

StackSet名を入力します。(何でも構いません)

4つあるパラメータのうち、以下の2つは「受信側のCloudFormationスタック」を実行した時の「出力」を参照して、値をコピー&ペーストしてください。

  • TargetEventBusARN (EventBridgeイベントバスのARN)
  • IAMRoleARN (IAMロールのARN)

StackSetオプションの設定はデフォルトのままで構いません。

デプロイオプションの設定を行います。

まず、「スタックをアカウントにデプロイ」が選択されていることを確認した上で、「アカウント番号」欄にAWSアカウントIDを入力します。
(今回はアカウントを跨がず単一アカウント内の構成ですので、現在スタックセットの設定を行っているAWSアカウントIDを入力してください)

続いて、スタックの展開先リージョンを指定します。

今回は試しに「東京」「フランクフルト」「バージニア北部」の3か所を指定してみます。
(もちろん「全てのリージョンを追加」を選択して全リージョンを指定してもよいです)

最後に、これは必須ではありませんが、「リージョンの同時実行」の選択を「並行」に設定しておくとよいでしょう。 こうすることでスタックセット全体の実行時間を短縮することができます。

次のページでサマリーが表示されるので内容を確認して、スタックセットを作成します。

各リージョンへのスタックの展開が始まると、ステータス欄が「RUNNING」となります。

しばらく待って、ステータス欄が「SUCCEEDED」になれば、全てのリージョンへの展開が成功です。
もし「ERROR」等になってしまった場合は、テンプレートファイルの内容や、スタックセットの設定内容を再確認してみてください。

これで、受信側・送信側それぞれでCloudFormationによる環境構築が終わりました。

受信側と送信側でそれぞれ操作が必要だったため「一撃」ならぬ「二撃」になってしまいましたが、マネジメントコンソールでリージョンを切り替えながら設定を行うことに比べれれば、遥かにラクだと思います。

Terraformを使った構築

構成ファイルのディレクトリ構造

├── environments
│   └── dev
│       ├── locals.tf
│       ├── main.tf
│       └── terraform.tf
└── modules
    ├── eventbridge_receiver
    │   ├── iam.tf
    │   ├── main.tf
    │   ├── outputs.tf
    │   ├── sns.tf
    │   └── variables.tf
    └── eventbridge_sender
        ├── main.tf
        └── variables.tf

構成ファイルの内容は、以下のリポジトリを参照してください。

https://github.com/hideakiaoyagi/developers-io/tree/main/eventbridge-cross-region-iac/terraform

受信側環境を定義するモジュール

全ファイルの解説はしませんが、ポイントとなる個所について説明します。
(コードが長くなるため、タグの設定などは省略しています)

まず、modulesの構成ファイル群です。

modules/eventbridge_receiver/ディレクトリに、受信側の環境定義をモジュール化しています。

main.tf

resource "aws_cloudwatch_event_bus" "this" {
  name = "${var.sys}-${var.env}-eventbridge-bus-receiver"
}

受信側のEventBridgeカスタムイベントバスの定義です。
取り立てて説明が必要な個所は無いと思います。

main.tf

resource "aws_cloudwatch_event_rule" "this" {
  name           = "${var.sys}-${var.env}-eventbridge-rule-receiver"
  description    = "Publish event notificatons to SNS"
  event_bus_name = aws_cloudwatch_event_bus.this.name

  event_pattern = jsonencode({
    "source": ["aws.ec2"],
    "detail-type": ["EC2 Instance State-change Notification"],
    "detail": {
      "state": ["running", "stopped"]
    }
  })
}

resource "aws_cloudwatch_event_target" "this" {
  rule           = aws_cloudwatch_event_rule.this.name
  event_bus_name = aws_cloudwatch_event_bus.this.name
  arn            = aws_sns_topic.this.arn
}

受信側のEventBridgeイベントルールの定義です。
カスタムイベントバスに紐付いたイベントルールとするために、event_bus_nameを指定しています。
(省略するとdefautになってしまいます)

また、CloudFormationの章で説明した内容と同様に、イベントパターンはJSON形式で指定しています。
(マネジメントコンソールのイベントルール作成画面からのコピー&ペーストでも問題ありません)

なお、ターゲットの設定で、CloudFormationではIDの指定が必須でしたが、Terraformでは指定が不要です。(自動的にランダムなIDが付与されます)

sns.tf

resource "aws_sns_topic" "this" {
  name = "${var.sys}-${var.env}-sns-topic"
}

resource "aws_sns_topic_policy" "this" {
  arn = aws_sns_topic.this.arn

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        "Effect": "Allow",
        "Principal": {
          "Service": "events.amazonaws.com"
        },
        "Action": "sns:Publish",
        "Resource": aws_sns_topic.this.arn
      }
    ]
  })
}

SNSトピックの定義です。
「アクセスポリシー」の設定内容については、CloudFormationと同様です。

なお、CloudFormationの際と同じく、SNSサブスクリプションはTerraformの自動構築の対象外としています。(理由はCloudFormationの章を参照ください)

iam.tf

resource "aws_iam_role" "this" {
  name                = "${var.sys}-${var.env}-eventbridge-role"
  assume_role_policy  = data.aws_iam_policy_document.assume_role_policy.json
  managed_policy_arns = [aws_iam_policy.this.arn]
}

data "aws_iam_policy_document" "assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["events.amazonaws.com"]
    }
  }
}

IAMロールの定義です。
events.amazonaws.comを信頼エンティティとする信頼関係ポリシーを設定しています。

iam.tf

resource "aws_iam_policy" "this" {
  name   = "${var.sys}-${var.env}-eventbridge-policy"
  policy = data.aws_iam_policy_document.this.json
}

data "aws_iam_policy_document" "this" {
  statement {
    actions = [
      "events:PutEvents",
    ]
    resources = [
      "${aws_cloudwatch_event_bus.this.arn}",
    ]
  }
}

IAMポリシーの定義です。
受信側EventBridgeカスタムイベントバスに対するアクセス権限を設定しています。

outputs.tf

output "eventbridge_eventbus_arn" {
  value = aws_cloudwatch_event_bus.this.arn
}

output "iam_role_arn" {
  value = aws_iam_role.this.arn
}

送信側の構築で必要となる「カスタムイベントバスのARN」「IAMロールのARN」をエクスポートしています。

送信側環境を定義するモジュール

modules/eventbridge_sender/ディレクトリでは、送信側の環境定義をモジュール化しています。

main.tf

resource "aws_cloudwatch_event_rule" "this" {
  name        = "${var.sys}-${var.env}-eventbridge-rule-sender"
  description = "Send events to receiver EventBridge"

  event_pattern = jsonencode({
    "source": ["aws.ec2"],
    "detail-type": ["EC2 Instance State-change Notification"]
  })
}

resource "aws_cloudwatch_event_target" "this" {
  rule     = aws_cloudwatch_event_rule.this.name
  arn      = var.target_eventbus_arn
  role_arn = var.iam_role_arn
}

送信側のEventBridgeイベントルールの定義です。
ターゲットのrole_arnでIAMロールの指定を行うのを忘れないでください。

メイン処理 (ルートモジュール)

environments/dev/ディレクトリでは、メイン処理となるルートモジュールを記述しています。

main.tf

module "eventbridge_receiver" {
  source = "../../modules/eventbridge_receiver"
  region = "ap-northeast-1"
  sys    = local.system_name
  env    = local.environment_name.dev
}

まず、受信側モジュールを呼び出して、環境を構築します。

main.tf

module "eventbridge_sender_us-east-1" {
  source              = "../../modules/eventbridge_sender"
  region              = "us-east-1"
  sys                 = local.system_name
  env                 = local.environment_name.dev
  target_eventbus_arn = module.eventbridge_receiver.eventbridge_eventbus_arn
  iam_role_arn        = module.eventbridge_receiver.iam_role_arn
}

module "eventbridge_sender_us-east-2" {
  source              = "../../modules/eventbridge_sender"
  region              = "us-east-2"
  sys                 = local.system_name
  env                 = local.environment_name.dev
  target_eventbus_arn = module.eventbridge_receiver.eventbridge_eventbus_arn
  iam_role_arn        = module.eventbridge_receiver.iam_role_arn
}

...

次に、送信側モジュールを呼び出します。

この時、モジュールに渡しているいくつかの「変数」に着目してみましょう。

  • region: 構築先リージョンの指定
  • target_eventbus_arn: イベントルールのターゲットとなるイベントバスのARN
  • iam_role_arn: IAMロールのARN

改めて、送信側モジュールの内容を確認してみましょう。

変数の宣言は以下のようになっています。

variables.tf

variable "region" {
  type    = string
  default = ""
}

...

variable "target_eventbus_arn" {
  type    = string
  default = ""
}

variable "iam_role_arn" {
  type    = string
  default = ""
}

また、main.tfの先頭で、以下のようにawsプロバイダーのリージョンを指定しています。

main.tf

provider "aws" {
  region = var.region
}

これによって、同じモジュールの呼び出しでも変数regionの指定を変えることで、指定したリージョンへ送信側環境を構築することが可能となっています。

~~~

なお、モジュールの呼び出しが対象リージョンの数だけベタに記述していて冗長な感じを受けるかもしれません。

マップ変数にリージョン名を格納しておいてfor_eachで繰り返せばシンプルに書けそうな気もしますが、Terraformの文法上の制約により、それはできないようでした。
(モジュール呼び出しをfor_eachcountでループする場合、モジュールに含まれるプロバイダー構成がループ毎に異なっていてはならない)

詳細は下記リンクを参照してください。
https://www.terraform.io/docs/language/modules/develop/providers.html

とううことで、落とし所としてベタな書き方になってしまいましたが、ご了承ください。

環境構築

Terraformによる環境構築は、文字通り「一撃」です。

$ cd terraform/environments/dev/
$ terraform init

まず、ルートモジュールのあるディレクトリに移動して、terraform initコマンドを実行します。

$ terraform apply

そしてterraform applyコマンドを実行すれば、あとは待つだけで環境が出来上がるはずです。(エラーが無ければ)

おわりに

長くなりましたが、今回の解説は以上です。

マネジメントコンソールでリージョン毎に設定を行うのが大変な場合、「Infrastructure as Code」の力を使って楽に、確実に設定を行うのがオススメです。

是非、今回ご紹介したコードを参考にアレンジなどしてみて、皆さんの環境構築にお役立ていただけると幸いです。