Serverless Framework で別リージョンの SNS トピックをサブスクライブする

記事タイトルとURLをコピーする

こんにちは。技術4課の保田(ほだ)です。

最近 Uber Eats にお世話になり倒しています。

近くのユーザーと同時配達することで配送手数料が0円になるシステム、あれは素晴らしいと思います。

背景

さて突然ですが、 Lambda で別リージョンの Simple Notification Service (SNS) のトピックをサブスクライブできるのって地味に凄くないですか?

例えばある Lambda の異常終了時に、直接 SNS トピックにパブリッシュするかもしくは CloudWatch Alarm 経由で SNS トピックにパブリッシュし、それを受けて別の Lambda が起動してしかるべき通知処理をする、というような設計を考えます。

でこで、この通知用の Lambda を別リージョンに配置すると、もしリージョンレベルで Lambda の基盤に障害が起きてしまった場合も SNS のパブリッシュさえ出来ていれば、ちゃんと通知は行われることになります。

リージョンを跨いだ堅牢なシステムも簡単に作れてしまう AWS 凄いですね。

ではこれを皆さん大好き Serverless Framework で実現しようとすると…、ちょっとした落とし穴がありましたのでまとめてみました。

要約

別リージョンのトピックにサブスクライブしようとすると上手くいかないパターンがあるぞ

本題

私の書く記事では毎回要約のセクションですべて結論が出ますが、今回はいくつかパターンがあるので、流れに沿ってハマりどころをご説明します。

準備

まず東京リージョン(ap-northeast-1)に SNS トピックを作成します。 簡単のためこれは手動で良いです。

$ aws sns create-topic --name test-topic
{
    "TopicArn": "arn:aws:sns:ap-northeast-1:123456789012:test-topic"
}

serverless.yml を書くぞ

次に Serverless Framework のアプリケーションを作成します。 ベースはこんな感じだとします

service:
  name: myService

provider:
  name: aws
  runtime: python3.7
  stage: ${opt:stage, 'dev'}
  region: ${opt:region, 'ap-southeast-1'}
  profile: default
  timeout: 30

package:
  exclude:
    - node_modules/**
    - package-lock.json
    - package.json
    - Pipfile
    - Pipfile.lock

functions:
  SampleFunction:
    handler: src/handler.main
    name: ${self:provider.stage}-SampleFunction

デプロイ先のリージョン名はデフォルトだとシンガポールリージョン(ap-southeast-1)になるようにしています。

Serverless Framework で SNS トピックへのパブリッシュをトリガーとする Lambda を定義するときは大きく分けて次の二つやり方があります。

  1. トピック名を指定する
  2. トピックの ARN を指定する

公式ドキュメント には簡潔な説明がありますので未読の方は是非ご一読頂ければと思います。

1の「トピック名を指定する」ですが、これはデプロイ時の挙動として、 serverless.yml 内に指定した名前のトピックを新規で作成し同時に新規作成する Lambda がサブスクライブしてくれます。

ただこれだと、 Lambda 関数をデプロイするリージョンと SNS トピックを作成するリージョンを区別することができませんので、今回はこのパターンは使いません。

もう一つの 2「トピックの ARN を指定する」については、既にそのトピックが存在する場合に使用します。

そして こちらのドキュメント の末尾にあるように、この ARN には必然的にリージョン名が入りますので、別リージョンのトピックにサブスクライブできます。

書き方としては以下の二通りがあります。

  1. キー sns の値にトピック ARN が文字列としてそのまま入るパターン
functions:
  SampleFunction:
    handler: src/handler.main
    name: ${self:provider.stage}-SampleFunction
    events:
      - sns: arn:aws:sns:ap-northeast-1:123456789012:test-topic
  1. キー sns の値に arn: arn:aws:sns:xxx... のようにオブジェクトが入るパターン
functions:
  SampleFunction:
    handler: src/handler.main
    name: ${self:provider.stage}-SampleFunction
    events:
      - sns:
          arn: arn:aws:sns:ap-northeast-1:123456789012:test-topic

では 1 から試してみます。

  Serverless Error ---------------------------------------

  An error occurred: SampleFunctionSnsSubscriptionTesttopic - Invalid parameter: TopicArn (Service: AmazonSNS; Status Code: 400; Error Code: InvalidParameter; Request ID: 6aef85d4-8547-5097-b0b4-d20dc4fe6fc5; Proxy: null).

どういうわけか失敗します。

TopicArn の形式が不正だと言われています。どういうことでしょうか。

serverless.yml から変換された CloudFormation テンプレートが .serverless/cloudformation-template-update-stack.json に作成されますので、サブスクリプションを作成する所をみてみます。

"SampleFunctionSnsSubscriptionTesttopic": {
      "Type": "AWS::SNS::Subscription",
      "Properties": {
        "TopicArn": "arn:aws:sns:ap-northeast-1:123456789012:test-topic",
        "Protocol": "lambda",
        "Endpoint": {
          "Fn::GetAtt": [
            "SampleFunctionLambdaFunction",
            "Arn"
          ]
        }
      }
    }

CloudFormation の ドキュメント を見ますと、クロスリージョンサブスクリプションの際は Region というキーを必ず指定しないといけないという記述あります。

クロスリージョンサブスクリプションの場合、トピックが存在するリージョン。

リージョンが指定されていない場合、CloudFormation は発信者のリージョンをデフォルトとして使用します。

AWS::SNS::Subscription リソースの Region プロパティのみを更新する更新オペレーションを実行すると、次のいずれかの操作を行わない限り、そのオペレーションは失敗します。

Region を 発信者リージョンから NULL に更新する。

Region から 発信者リージョン NULL への更新。

ですが、先ほど記載したテンプレートにはそれがありません。だからエラーになったようです。

次は 2 を試します。

..................
Serverless: Stack update finished...

成功しますね。 先ほどと同じように生成されるテンプレートを見てみます。

"SampleFunctionSnsSubscriptionTesttopic": {
      "Type": "AWS::SNS::Subscription",
      "Properties": {
        "TopicArn": "arn:aws:sns:ap-northeast-1:123456789012:test-topic",
        "Protocol": "lambda",
        "Endpoint": {
          "Fn::GetAtt": [
            "SampleFunctionLambdaFunction",
            "Arn"
          ]
        },
        "Region": "ap-northeast-1"
      }
    }

ちゃんと Region のキーが入っていますね。

ドキュメントには記載がありませんが、マニアックな仕様を知ることができました。

もう少し実践的な場面では

チーム開発であったり、開発・検証・本番環境のように1枚の serverless.yml で複数の環境にデプロイする場面がよくあります。 そうなると、リソースを一意に特定する ARN べた書きするのはやりたくないので、たいていはパラメータ化しますよね。

早速試してみます。

functions:
  SampleFunction2:
    handler: src/handler.main
    name: ${self:provider.stage}-SampleFunction
    events:
      - sns:
          Fn::Join:
            - ":"
            - - "arn:aws:sns:ap-northeast-1"
              - Ref: AWS::AccountId
              - test-topic

Fn::Join を使ってアカウント ID を埋め込むようにしてみます。

  Serverless Error ---------------------------------------

  Missing or invalid topicName property for sns event in function "SampleFunction" The correct syntax is: sns: topic-name-or-arn OR an object with  arn and topicName OR topicName and displayName. Please check the docs for more info.

これはエラーメッセージのとおり、クロスリージョンとか関係なく Syntax エラーで落ちます。

Note: If an arn string is specified but not a topicName, the last substring starting with : will be extracted as the topicName. If an arn object is specified, topicName must be specified as a string, used only to name the underlying Cloudformation mapping resources.

ドキュメントにもあるように、 arn をオブジェクトして渡すと上手くトピック名を取得できないため、そこでエラーになるようです。

ですので、次のように指定してみます。

functions:
  SampleFunction2:
    handler: src/handler.main
    name: ${self:provider.stage}-SampleFunction
    events:
      - sns:
          arn:
            Fn::Join:
              - ":"
              - - "arn:aws:sns:ap-northeast-1"
                - Ref: AWS::AccountId
                - test-topic
          topicName: test-topic

これで実行すると、同一リージョンのトピックを指定した場合は成功します。 が、今回のように別リージョンのトピックを指定すると失敗してしまいます。

  Serverless Error ---------------------------------------

  An error occurred: SampleFunctionSnsSubscriptionTesttopic - Invalid parameter: TopicArn (Service: AmazonSNS; Status Code: 400; Error Code: InvalidParameter; Request ID: a7688ba9-30ad-5bde-b3ee-dc707d325c48; Proxy: null).

テンプレートを見ると、 Region のキーがありません。

"SampleFunctionSnsSubscriptionTesttopic": {
      "Type": "AWS::SNS::Subscription",
      "Properties": {
        "TopicArn": {
          "Fn::Join": [
            ":",
            [
              "arn:aws:sns:ap-northeast-1",
              {
                "Ref": "AWS::AccountId"
              },
              "test-topic"
            ]
          ]
        },
        "Protocol": "lambda",
        "Endpoint": {
          "Fn::GetAtt": [
            "SampleFunctionLambdaFunction",
            "Arn"
          ]
        }
      }
    }

というわけでパラメータ化する場合は ARN 全体をパラメータとし、実行時にコマンドラインで渡すようにするか、もしくは別ファイルに切り出しておく必要がありそうです。 ARN のような長めの文字列をコマンドラインで渡すのはあまりしたくないので、普通は別ファイルに切り出すことが多いかと思います。

別ファイルから読み込むときの書き方については こちらのドキュメント をご参照ください。

割とベタなやり方としてこのようなディレクトリ構成にし、開発環境に固有の値は dev.yml 、本番環境に固有の値は prod.yml に書くとします。

- serverless.yml
- config/
    - dev.yml
    - prod.yml

dev.yml は次のようになっているとします。

topicArn: arn:aws:sns:ap-northeast-1:123456789012:test-topic

そして custom というセクションを作り、 custom.config の値に環境ごとの config ファイルをオブジェクトとして読み込むようにします。

service:
  name: myService

provider:
  name: aws
  runtime: python3.7
  stage: ${opt:stage, 'dev'}
  region: ${opt:region, 'ap-southeast-1'}
  profile: default
  timeout: 30

custom:
  config: ${file(./config/${self:provider.stage}.yml)}

package:
  exclude:
    - node_modules/**
    - package-lock.json
    - package.json
    - Pipfile
    - Pipfile.lock
    - cofig/**

functions:
  SampleFunction:
    handler: src/handler.main
    name: ${self:provider.stage}-SampleFunction
    events:
      - sns:
          arn: ${self:custom.config.topicArn}

このようにすると、ちゃんとデプロイできます。 topicName は指定してもしなくても大丈夫です。