AWS
CloudFormation
lambda
serverless
ServerlessFramework

serverlessフレームワークで複数スタックに分割して、リスクを軽減する方法

serverless.yml (AWSの場合)の分割方法について紹介します。ファイルを分けるだけでなく、デプロイ単位自体を分ける感じの手法です。cf: という指定で他スタックの値を参照します。

一言で

serverlessプロジェクトを複数スタックに分割する方法があります。サンプルが以下のURLにあるので見て下さい。

https://github.com/sctv-hamanaka/ServerlessSamples/tree/master/split-stack

なぜ分けるのか

運用時のリスク軽減

serverlessフレームワークで、ある程度の大きさのプロジェクトを作るようになると、気になってくることがあります。

  • slsコマンド実行時にステージを間違えるなどして、もしこのプロジェクトを削除してしまったらDynamoDBのデータが削除され、大きな被害が出るのではないだろうか?(バックアップがあっても、復旧は面倒だし)
  • CloudFormationでは、設定や名前を変更してデプロイしたときに、持っていたデータが消えてしまうものがある。そのようなリスクを軽減できないだろうか?

このようなことを考えたとき、1つの対策として考えられるのは、以下のようなことです。

データを失うような危険性のあるリソースと、危険性の低いリソースを分けて扱うと良いのではないだろうか。分けておけば、プログラム部分の修正や削除などは比較的気軽に行い、データ部分の定義修正時には、より慎重に行うといったことができるのではないか?

その他の効用

  • CloudFrontなど、デプロイにやたら時間がかかるリソースがあり、不要な場合はデプロイしないようにしたい、といった場合もスタック分割が利用できます。
  • CloudFormationでは、1スタックあたりリソース数200個までという制限があります。分割することで、これを回避することができます。

別の手段

以下のような方法もあるとは思います。試していないので、ここでは一言紹介にとどめます。

デメリット

  • 複数に分かれることで、デプロイの回数自体が増加する
  • どちらのスタックに入れるか、という点において複雑性が増す。別スタックへの移動などは幾分面倒さがあるでしょう。

スタック分割の際に考慮すべきこと

  • リソースの寿命(ライフサイクル)

    • それぞれのリソースには寿命があります。寿命の似たものを同じスタックに入れるのが良いと思います。
    • キャッシュを除くDBなどデータ部は、レコードを残す必要があるので寿命が長いと考えます。
    • キャッシュ機能は、内容が消えても問題ないケースが多いので寿命は短いと考えます。
    • プログラムは、構成変更で消すことも良くあるので、寿命は短いと考えます。
  • リソースの依存関係

    • 何が何に依存しているか、を把握しておきましょう。Lambda関数がDynamoDBテーブルに依存している、というようなものです。
    • 相互依存や循環依存がないようにスタックを分割しましょう。
  • デプロイにかかる時間

    • デプロイに非常に長い時間がかかると、デプロイが嫌になるでしょう。たとえばCloudFrontのデプロイは15分以上かかることがあります。このようなリソースは別スタックで定義したほうが良いでしょう。

スタック分割の構成

DBなどデータ部と、Lambdaなどのプログラム部に分けるのはわかりやすいスタック分割の構成です。

ファイル構成

  • data-stack/
    • serverless.yml # DynamoDBなどの設定を行う。
  • lambda-stack/
    • serverless.yml # API Gateway, Lambdaの設定を行う。
    • handler.js

連携ポイント

スタックを分割したとき、とあるスタックから別のスタックの情報を参照する必要がでてきます。この手法を見ていきましょう。
この情報の参照ですが、各スタックのOutputs値を利用します。Outputsは実際にはCloudFormationの機能で、デプロイされたCloudFormationスタックは出力値を持つことができます。serverlessの機能を利用すると、serverless.yml内で別スタックのOutputを参照できます!

参考:
出力 - AWS CloudFormation - AWS Documentation

resources:
  Outputs:
    HOGE:
      Value: "この値がHOGEという名前で外部から参照できます"

Outputsの値は、 sls deploy -v または、デプロイ済みであれば sls info -v を実行すると表示されます。

serverless.ymlでの記法

serverlessフレームワークでは、serverless.yml内で、以下の記法で他のスタックの出力値を埋め込むことができます。

${cf:スタック名.変数名}

スタック名が hogestack, 変数名が DB_NAME だとすると、

${cf:hogestack.DB_NAME}

サンプルでは、スタック名も変数にしており、

dynamoDBHogeTableName: ${cf:${self:custom.dataCfName}.DynamoDBHogeTableName}


のようになります。

参考: https://serverless.com/framework/docs/providers/aws/guide/variables#reference-cloudformation-outputs

依存されるスタックを先にデプロイする

注意すべき点としては、既にデプロイされたスタックの出力値しか参照できないということです。このため、被依存側のスタックを先にデプロイしておきます。

たとえば、

  • データスタック (DynamoDB)
  • プログラムスタック (Lambda/API Gateway)
  • キャッシュスタック (CloudFront)

のようになっている場合、順番は

データスタック → プログラムスタック → キャッシュスタック

のようになります。

実例

こちらにソースをpushしました。

https://github.com/sctv-hamanaka/ServerlessSamples/tree/master/split-stack

DynamoDBスタック

service: data-stack

provider:
  name: aws
  runtime: nodejs6.10
  stage: ${opt:stage, 'dev'} # ここは全スタック同じにするように。

custom:
  # カスタム文字列
  hogeTableName: ${self:service}-hoge-${self:provider.stage}

resources:
  Resources:
    # DynamoDBのテーブル定義
    DynamoDbHogeTable:
      Type: 'AWS::DynamoDB::Table'
      Properties:
        TableName: ${self:custom.hogeTableName}
        AttributeDefinitions:
          -
            AttributeName: id
            AttributeType: S
        KeySchema:
          -
            AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

  Outputs:
    # 出力値(別スタックから参照可能)
    DynamoDBHogeTableName:
      Value: ${self:custom.hogeTableName}

Lambda/API Gatewayスタック

provider:
  name: aws
  runtime: nodejs6.10
  stage: ${opt:stage, 'dev'}

  iamRoleStatements:
    # DynamoDBのアクセス権追加
    - Effect: Allow
      Action:
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource:
        - "arn:aws:dynamodb:${opt:region, self:provider.region}:*:table/${self:custom.dynamoDBHogeTableName}"
  environment:
    DYNAMODB_HOGE_TABLE_NAME: ${self:custom.dynamoDBHogeTableName}

custom:
  # データスタックのservice名
  dataStackName: data-stack
  # データスタックのCloudFormation名
  dataCfName: ${self:custom.dataStackName}-${self:provider.stage}

  # ********* データスタックの出力値を参照する *********
  dynamoDBHogeTableName: ${cf:${self:custom.dataCfName}.DynamoDBHogeTableName}

functions:
  hello:
    handler: handler.hello
    events:
      - http:
          path: hello
          method: get

デプロイ方法

cd data-stack
sls deploy -v
cd ../lambda-stack
sls deploy -v

アクセス方法

deploy -v 実行時に、以下のようなログが出ると思います。

ServiceEndpoint: https://12345abcde.execute-api.us-east-1.amazonaws.com/dev

これにAPIのpathに指定したhelloを足して、以下のコマンド実行してみてください。

curl https://12345abcde.execute-api.us-east-1.amazonaws.com/dev/hello
{"message":"saved count:1"}

DynamoDBからカウントを取り出して、 +1 して保存しつつレスポンスで返すようにしています。
(DynamoDBはupdateItemで一回でカウントアップもできますが、まあサンプルですので気にしないように)

まとめ

スタック分割の手法と理由でした。
Export/Importの仕組みなども気になるところなので試したいと思っています。

以上です。