サーバーレスなバックアップシステムを AWS SAM を用いてシュッと構築する

こんにちは。昨晩のお夕飯は鮭のカレー風味ムニエル定食だったインフラ部 SRE グループの @mozamimy です。

今回は、SRE グループでの取り組みのひとつであるマルチクラウドバックアップを題材にして AWS SAM、CodePipeline (CodeBuild および CodeDeploy を含む) を用いたサーバーレスアプリケーションの構築、ビルドおよびデプロイについて書いていきたいと思います。また、1月に Lambda で Golang が利用可能になった こともあり、CodePipeline の進捗を Slack に投稿する Lambda function を Golang で作ってみたので、そちらもあわせて解説したいと思います。

今回取り扱うトピック

  • マルチクラウドバックアップの重要性
  • サーバーレスアプリケーションについて
  • AWS SAM (Serverless Application Model)
  • s3-multicloud-backup: Lambda を中心に実装するイベント駆動の S3 -> GCS へのバックアップシステム
  • codepipeline-notify: Golang で実装された CodePipeline の進捗を Slack に投稿するアプリケーション

マルチクラウドバックアップ #とは

クックパッドでは、オンプレミスから AWS に移行してから長きにわたって AWS を中心に利用しています。その一部として、レシピ画像やつくれぽ画像の配信のための tofu というシステムが稼働しており1、tofu のバックエンドストレージとして Amazon S3 (以降 S3 と略記) を利用しています。S3 バケットに格納された画像データは、それぞれが大切なユーザーの皆様の画像であり、データの可用性が非常に重視されます。心のこもったひとつひとつの画像を大切に守って配信すべきときに漏れなく配信できるようにする、それは我々 SRE の使命です。

クックパッドにとって、ユーザーの皆様からお預かりしたデータはサービスの要です。S3 は非常に高い可用性を持っているため、たとえば S3 の障害などからデータを守るためには十分と言えるかもしれません。しかし、サービスの要であるからには、たとえば AWS アカウントへのアクセスができなくなるような自体にも備える必要があるとチームでは考えています。そこで我々は Google Cloud Storage (以下 GCS と略記) をバックアップ先として採用し、GCS にバックアップデータを預けることにしました。

今回は、AWS の S3 から Google の GCS のように、クラウドサービスをまたいだバックアップのことをマルチクラウドバックアップと呼ぶことにします。そして、それを実現するためのイベント駆動 Lambda function を中心としたサーバーレスアプリケーションを、AWS SAM のもとで構築するためのノウハウを解説していきます。

サーバーレスアプリケーション

サーバーレスアプリケーションとは、サーバのプロビジョニングなしに2利用できるマネージドサービスを駆使して、何らかのサービスを提供するアプリケーションのことを指します。たとえば AWS では以下のようなコンポーネントを利用することができます。

  • Lambda: コンピューティング
  • API Gateway: リバースプロキシ
  • SNS (Simple Notification Service): pub/sub によるメッセージング
  • SQS (Simple Queue Service): キューによるメッセージング
  • S3: オブジェクトストレージ
  • DynamoDB: NoSQL データベース
  • CloudWatch: ロギングおよびモニタリング

これらのコンポーネントは AWS によって維持管理されており、高い可用性とスケーラビリティを持つとされています。たとえば、コンピューティングという意味では Lambda は EC2 に代わるものであり、Lambda を利用することで EC2 のように OS そのものの面倒を見るといった運用作業から解放され、実現したい機能に集中することができます。また、Lambda function が実行されている時間だけ課金するというモデルなので、EC2 のようにインスタンスが起動している限り課金され続けるコンポーネントよりも経済的に運用しやすいというメリットもあります3

ただし、これらの個別のコンポーネントを組み合わせて動作させるという性質上、アプリケーション全体として俯瞰したときにピタゴラ装置になることは避けられない上、デプロイ作業が非常に面倒なものになります。そのため、これらのコンポーネントを定義し、スマートにデプロイできる「何か」が必要になります。そして、その「何か」の一つとして AWS SAM (Serverless Application Model) が公式に提唱されており、実際に利用できる状態になっています。

AWS SAM (Serverless Application Model)

AWS SAM (以降 SAM と略記) とは、ひとことで言うならば「CloudFormation をベースとしたサーバーレスアプリケーションのリソースを作成、管理できる仕組み (モデル)」といったところです。詳しくは以下の公式リポジトリに置かれているドキュメントに目を通してみてください。

awslabs/serverless-application-model: AWS Serverless Application Model (AWS SAM) prescribes rules for expressing Serverless applications on AWS.

SAM テンプレート自体は CloudFormation テンプレートの特殊化に過ぎず、最終的にベーシックな CloudFormation テンプレートに展開されます。つまり、SAM を使ってサーバーレスアプリケーションをデプロイする場合、最終的に CloudFormation stack が生えて、Lambda function を中心とした各リソースが作られることになります。

ちなみに Lambda のデプロイツールの有名所として Apex といったツールも検討しましたが、公式のエコシステムに賭けるほうが将来性があるということや、Apex は Lambda function のデプロイのみに特化しているといった点が気になり、SAM を中心にしていこうという流れになっています。

aws-sam-local でいい感じにサーバーレスアプリケーションを開発する

SAM を利用することのもう一つのメリットとして、ローカルで開発するときにとても便利な aws-sam-local というツールを利用することができる、ということがあげられます。まだベータ版の位置付けとなっていますが、十分使えるという印象です。API Gateway をシミュレーションすることもでき、SAM に乗っかるならば必携のツールといえます。以降の解説でも aws-sam-local を利用するので、チュートリアルとして試してみたい場合は README を読んでインストールしておいてください。

awslabs/aws-sam-local: AWS SAM Local 🐿 is a CLI tool for local development and testing of Serverless applications

2018-02-07 現在、npm を用いたインストールが Recommended となっていますが、個人的には https://github.com/awslabs/aws-sam-local#build-from-source にあるように、go get でインストールすることをおすすめします。まだベータ版ということもあってか、エラーメッセージがやや不親切であったり、不具合と思われる挙動に出くわしたときにちょっと直して動かしてみることが簡単だからです。今回説明するマルチクラウドバックアップシステムを構築する際にも、SAM テンプレートの CodeUri に相対パスが含まれていると期待通りに動作しないという問題を発見し、それを修正するための PR を出しました。

Fix a glitch that a tamplate CodeUri has like ../ relative path by mozamimy · Pull Request #279 · awslabs/aws-sam-local

余談ですが、SAM のリスのキャラクターは SRE グループの中ではとっとこ SAM 太郎と呼ばれてたいへん愛されて(?)おります。New – AWS SAM Local (Beta) – Build and Test Serverless Applications Locally | AWS News Blog のブログ記事でも、

At it's core, SAM is a powerful open source specification built on AWS CloudFormation that makes it easy to keep your serverless infrastructure as code –and they have the cutest mascot.

とあり、公式にも (?) SAM 太郎推しであることがうかがえます。

f:id:mozamimy:20180202132607p:plain

s3-multicloud-backup: Lambda を中心に実装するイベント駆動の S3 -> GCS へのバックアップシステム

アーキテクチャ

以下に、今回構成する s3-multicloud-backup のアーキテクチャを図示します。

ここでは、ソースとなる S3 バケットの名前を source-s3-bucket とし、バックアップ先の GCS バケットの名前を dest-bucket とします。source-s3-bucket にオブジェクトがアップロードされると、以下の順に処理が進み、最終的に dest-bucket にオブジェクトがバックアップされます。

  1. オブジェクトがアップロードされたという通知が source-s3-bucket-notification SNS topic に飛ぶ。
  2. source-s3-bucket-notification SNS topic を subscribe する s3-multicloud-backup function が起動する。
  3. s3-multicloud-backup function は、SNS 経由で届いた通知を開封し、対象のオブジェクトを source-s3-bucket から取り出して dest-bucket にアップロードする。

ただし、s3-multicloud-backup function によるオブジェクトのダウンロードとアップロードは、何らかの理由で失敗することがあり、堅牢なバックアップシステムを構築するためには失敗した事実を適切にハンドリングする必要があります。ここで起こりうる失敗の原因としては、以下の 3 点が上げられます。

  • S3 もしくは GCS に障害が起きている。
  • function にバグにより例外が起こって処理が正常に完了しなかった。
  • Lambda の実行環境でメモリ不足やタイムアウトが起きた。

このうち、上から 2 つについては障害が回復してから手動でリトライしたりバグを修正するなどの対応が必要となりますが、これらは避けることができません。とはいえ S3 や GCS の可用性を考えると滅多に起こることはないでしょう。

ここで特に考慮すべきは 3 つめのパターンで、オブジェクトのファイルサイズと Lambda の実行環境として設定するメモリ量によっては、それなりに頻繁に起こりえます。だからといって大きめのメモリサイズを確保するとコストに直接響くため、なるべく確保するメモリ量は小さくしたいところです。そこで、ここでは DLQ を用いた多段呼び出しというパターンを用いることにします。

Lambda では非同期呼び出しの場合、適当な時間を置いて 2 回まで自動的に再試行され、それでもダメなら DLQ として指定した SNS topic もしくは SQS queue にメッセージが送られます4。この機能を利用し、1 段目の s3-multicloud-backup function の実行が失敗した場合、s3-multicloud-backup-retry SNS topic に通知を送り、2 段目に控える s3-multicloud-backup-retry function を起動するようにします。もちろん、2 段目の function には、1 段目よりも大きめのメモリサイズを確保するように設定します。

このような構成にすることで、設計としてはやや複雑になりますが、より低コストでバックアップシステムを運用することができます。

最終的に s3-multicloud-backup-retry による実行も失敗した場合、最終的に DLQ として指定されている s3-multicloud-backup-dlq SQS queue に情報が格納されます。CloudWatch Alarm を用いてこの SQS queue の NumberOfMessageSent を監視しておき、DLQ にメッセージが届いたら PagerDuty を経由して SRE が状況判断をして対応する、という流れになっています。

ソースコードと SAM テンプレート

アーキテクチャを理解したところで、ここから function のソースコードと SAM テンプレートについて説明します。ソースコードは https://github.com/mozamimy/s3-multicloud-backup に push してあるので、必要に応じてご活用ください。

ディレクトリツリーは以下のようになっています。

.
├── README.md
├── deploy
│   ├── buildspec
│   │   ├── production.yml
│   │   └── staging.yml
│   └── template
│       ├── production.yml
│       └── staging.yml
├── sample-event-dlq.json
├── sample-event-s3-via-sns.json
└── src
    ├── index.js
    └── package.json

ここでは、ビルドやデプロイに関連するファイルを deploy ディレクトリ以下にまとめ、処理本体が記述された index.js およびパッケージ情報である package.json を src ディレクトリ以下にまとめています。また、aws-sam-local を用いて手元で function を動かすために、サンプルのイベントを含んだ 2 つの JSON ファイルが置かれています。

index.js

'use strict';

const fs = require('fs');
const uuid = require('uuid');
const aws = require('aws-sdk');
const kms = new aws.KMS();
const s3 = new aws.S3();

const GCS_BUCKET = process.env.GCS_BUCKET;
const PROJECT_ID = process.env.PROJECT_ID;
const ENCRYPTED_GC_CREDENTIAL = process.env.GC_CREDENTIAL;
const SKIP_KEY_REGEX = process.env.SKIP_KEY_REGEX;
const RETRY_SNS_TOPIC_ARN = process.env.RETRY_SNS_TOPIC_ARN;
const GC_CREDENTIAL_FILE_PATH = '/tmp/gcp_cred.json';
const TMP_FILE_PATH = `/tmp/${uuid.v4()}`;

function replicateObject(s3ObjectKey, s3Bucket) {
  const kmsParamas = {
    CiphertextBlob: new Buffer(ENCRYPTED_GC_CREDENTIAL, 'base64'),
  };

  const s3Params = {
    Bucket: s3Bucket,
    Key: s3ObjectKey,
  };

  return Promise.all([
    kms.decrypt(kmsParamas).promise().then((data) => {
      const decrypted = data.Plaintext.toString('ascii');
      fs.writeFileSync(GC_CREDENTIAL_FILE_PATH, decrypted);
    }),
    s3.getObject(s3Params).promise(),
  ]).then((results) => {
    const s3Object = results[1];
    fs.writeFileSync(TMP_FILE_PATH, s3Object.Body);

    const storage = require('@google-cloud/storage')({
      projectId: PROJECT_ID,
      keyFilename: GC_CREDENTIAL_FILE_PATH,
    });

    const gcsBucket = storage.bucket(GCS_BUCKET);
    const gcsParams = {
      destination: s3ObjectKey,
      validation: 'crc32c',
      resumable: false,
    };

    return gcsBucket.upload(TMP_FILE_PATH, gcsParams);
  });
}

function deleteTempFile() {
  if (fs.existsSync(TMP_FILE_PATH)) {
    fs.unlinkSync(TMP_FILE_PATH);
  }
}

exports.handler = (event, context) => {
  const message = (event.Records[0].Sns.TopicArn == RETRY_SNS_TOPIC_ARN)
    ? JSON.parse(JSON.parse(event.Records[0].Sns.Message).Records[0].Sns.Message)
    : JSON.parse(event.Records[0].Sns.Message);

  const s3Bucket = message.Records[0].s3.bucket.name;
  const s3ObjectKey = message.Records[0].s3.object.key;

  if (!(SKIP_KEY_REGEX == null) && s3ObjectKey.match(SKIP_KEY_REGEX)) {
    const logMessage = `skipped: Bucket: ${s3Bucket}, Key: ${s3ObjectKey}`;
    console.log(logMessage);
    context.succeed(logMessage);
  } else {
    replicateObject(s3ObjectKey, s3Bucket).then((success) => {
      deleteTempFile();
      const logMessage = `replicated: Bucket: ${s3Bucket}, Key: ${s3ObjectKey}`;
      console.log(logMessage);
      context.succeed(logMessage);
    }).catch((err) => {
      deleteTempFile();
      const logMessage = `ERROR!: Bucket: ${s3Bucket}, Key: ${s3ObjectKey}`;
      console.log(logMessage);
      console.log(err);
      context.fail(logMessage);
    });
  }
};

Lambda が呼び出されたとき、exports.handler に格納された関数が実行されます。この関数では、SNS topic から受け取ったイベントを開封し、バックアップ元のバケットとオブジェクトのキーを取り出し、GCS バケットにアップロードします。SKIP_KEY_REGEX 環境変数に設定された正規表現にマッチするキーに対してはバックアップを行いません。また、184 行目で 1 段目の実行なのか 2 段目の実行なのかを判定して、適切にイベントの開封処理を変えています。

replicateObject() がバックアップ処理の本体で、GCS にアクセスするための暗号化されたクレデンシャル (JSON) を環境変数 ENCRYPTED_GC_CREDENTIAL から読み出し、KMS API を呼ぶことで復号します。そして、S3 バケットから対象のオブジェクトをダウンロードし、復号したクレデンシャルを用いて GCS バケットにアップロードします。クレデンシャルの暗号化や、この function につけるべき IAM role については後述します。

package.json

package.json に関しては特筆すべきところはないでしょう。

{
  "name": "s3-multicloud-backup",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@google-cloud/storage": ">=0.52.0",
    "aws-sdk": ">=2.44.0",
    "uuid": ">=3.0.1",
    "fast-crc32c": ">=1.0.4"
  }
}

ちなみに、fast-crc32c を依存に含めることにより、@google-cloud/storage による CRC チェックが高速になるという利点があります。また、後述しますが @google-cloud/storage の依存にネイティブライブラリが含まれるため、Lambda 環境で実行するためには Linux 上で npm install を実行する必要があります。

buildspec

package.json の項でも説明しましたが、依存ライブラリにネイティブバイナリのビルドが必要になるため、ローカル環境で npm install したときに作成される node_modules をそのまま Lambda 環境に置いてもきちんと動作する保証はありません。ここではその問題を解決するために CodeBuild を利用し生成された生成物を CodeDeploy でデプロイする、という構成にし、一連の処理を CodePipeline にまとめることにします。

以下はそのための buildspec の例です。

version: 0.2
phases:
  install:
    commands:
      - 'cd src && npm install'
  build:
    commands:
      - 'aws cloudformation package --template-file ../deploy/template/production.yml --s3-bucket sam-artifact.ap-northeast-1 --output-template-file template.package.yml'
artifacts:
  files:
    - 'src/template.package.yml'

AWS CLI の cloudformation package サブコマンドを使うことで、--template-file に指定したテンプレートを読み込んで src ディレクトリ以下を zip ファイルにまとめ、--s3-bucket に指定した S3 バケットに生成物を自動的にアップロードしてくれます。また、テンプレートに含まれる CodeUri を生成物用の S3 バケットに書き換えたテンプレートを --output-template-file に指定したファイルに出力します。--s3-bucket に指定するバケットは、各自の環境の合わせて変更してください。

SAM テンプレート

以下の YAML ファイルがデプロイの要となる SAM テンプレート です。上述したアーキテクチャ図と見比べて、どこがどの項目にあたるかをチェックするとよいでしょう。SAM の詳細な記法は以下の公式ドキュメントを参照してください。

serverless-application-model/2016-10-31.md at master · awslabs/serverless-application-model

また、SAM のベースは CloudFormation なので、CloudFormation に慣れておくとより理解が深まるでしょう。CloudFormation については以下のドキュメントが参考になります。

What is AWS CloudFormation? - AWS CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: 'A serverless application that replicate all objects in S3 bucket to Google Cloud Storage.'

Resources:
  # リトライ通知用 topic
  RetrySNSTopic:
    Type: 'AWS::SNS::Topic'
    Properties:
      DisplayName: 's3mc-stg'
      TopicName: 's3-multicloud-backup-retry'
  # 2 段目でも処理が失敗したとき用の queue
  DLQ:
    Type: 'AWS::SQS::Queue'
    Properties:
      QueueName: 's3-multicloud-backup-dlq'
      MessageRetentionPeriod: 1209600 # 14 days

  # 1 段目の function
  S3MulticloudBackupFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: 'index.handler'
      Runtime: 'nodejs6.10'
      CodeUri: '../../src'
      FunctionName: 's3-multicloud-backup'
      # 事前に作成しておいた IAM role をここに指定する
      Role: 'arn:aws:iam::(Account ID):role/LambdaS3MulticloudBackup'
      MemorySize: 256
      Timeout: 30
      Events:
        ObjectUploaded:
          Type: 'SNS'
          Properties:
            # S3 バケットからきた通知を受け渡すための
            # source-s3-bucket-notification topic の ARN を指定する
            Topic: 'arn:aws:sns:ap-northeast-1:(Account ID):source-s3-bucket-notification'
      DeadLetterQueue:
        Type: 'SNS'
        # `!Ref` を利用して論理名 `RetrySNSTopic` から ARN を取り出して設定する
        TargetArn: !Ref RetrySNSTopic
      # Lambda を起動した環境における環境変数を設定できる
      Environment:
        Variables:
          # マッチするキーのオブジェクトはバックアップしない
          SKIP_KEY_REGEX: '^(_sandbox|test)'
          # バックアップ先の GCS バケット名を指定
          # 環境に合わせて書き換える
          GCS_BUCKET: 's3-multicloud-backup'
          # バックアップ先のプロジェクトを指定
          # 環境に合わせて書き換える
          PROJECT_ID: 'foo-project'
          # リトライ用の SNS topic の ARN を指定
          # `!Ref` は環境変数を設定するときにも使える
          RETRY_SNS_TOPIC_ARN: !Ref RetrySNSTopic
          GC_CREDENTIAL: '暗号化したクレデンシャルをここに(後述)'

  # 2 段目の function
  S3MulticloudBackupRetryFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: 'index.handler'
      Runtime: 'nodejs6.10'
      CodeUri: '../../src'
      FunctionName: 's3-multicloud-backup-retry'
      # 事前に作成しておいた IAM role をここに指定する
      Role: 'arn:aws:iam::(Account ID):role/LambdaS3MulticloudBackup'
      # MemorySize および Timeout は 1 段目より大きめにする
      MemorySize: 2048
      Timeout: 90
      Events:
        BackupFailed:
          Type: 'SNS'
          Properties:
            # 1 段目の DLQ に指定したリトライ用 SNS トピックの ARN
            Topic: !Ref RetrySNSTopic
      DeadLetterQueue:
        Type: 'SQS'
        # 2 段目でも失敗した場合は SQS queue に格納するので、`!GetAtt` を用いて論理名から ARN を取り出して設定
        # SNS の場合は `!Ref` で ARN が返るが、SQS の場合は `!GetAtt` であることに注意
        TargetArn: !GetAtt DLQ.Arn
      Environment:
        Variables:
          SKIP_KEY_REGEX: '^(_sandbox|test)'
          GCS_BUCKET: 's3-multicloud-backup'
          PROJECT_ID: 'foo-project'
          RETRY_SNS_TOPIC_ARN: !Ref RetrySNSTopic
          GC_CREDENTIAL: '暗号化したクレデンシャルをここに(後述)'

SAM では、基本的に Resources に各種リソースを記述していくことになります。Resources のキーは各リソースの論理名で、別の場所で !Ref!GetAtt でリソースの ARN や名前を取得することが可能です。

ここでは、リトライを通知する s3-multicloud-backup-retry SNS topic および DLQ である s3-multicloud-backup-dlq SQS queue のみを SAM テンプレートに定義します。Lambda function にアタッチする IAM role や、S3 バケットからの通知を受ける source-s3-bucket-notification SNS topic は SAM テンプレートの中に定義しません。

これは、source-s3-bucket-notification は s3-multicloud-backup 以外の用途でも利用されるかもしれないということと、クックパッドでは IAM を codenize ツールである codenize-tools/miam: Miam is a tool to manage IAM. It defines the state of IAM using DSL, and updates IAM according to DSL. を用いて GitHub flow に則ってレビューできるように中央集権的に管理しているからです。読者の皆さんの環境次第では、これらの設定も SAM テンプレートに含めてしまってもよいでしょう。

RetrySNSTopic および DLQ は、基本的にデフォルトのパラメータを使うようにすれば十分でしょう。ただし、DLQ では MessageRetentionPeriod1209600 に明示的に指定することにより、メッセージが最長の 14 日間キューに保持されるようにします。

S3MulticloudBackupFunction および S3MulticloudBackupRetryFunction は Lambda function で、それぞれ 1 段目、2 段目の Lambda function になります。MemorySizeTimeout の値が違うことに注目してください。設定する値の詳細については、YAML 中のコメントを確認してください。

クレデンシャルを暗号化する

GCS バケットへのアクセスに利用するクレデンシャルをハードコードするわけにはいかないため、何らかの手段を用いて暗号化して環境変数にセットする必要があります。ここでは、AWS のキーマネジメントサービスである KMS (Key Management Service) を利用してクレデンシャルを暗号化および復号することにします。KMS の詳細については以下のドキュメントを参照してください。

AWS Key Management Service Documentation

まず、s3-multicloud-backup 専用の鍵を作成して、エイリアスおよび ARN をメモしておきます。そして、以下のような Ruby スクリプトを使ってクレデンシャル JSON ファイルの内容を暗号化します。暗号化の際には、作成した鍵を使える適当なアクセスキーを AWS_ACCESS_KEY_ID および AWS_SECRET_ACCESS_KEY 環境変数にセットしてください。

https://github.com/mozamimy/toolbox/tree/master/ruby/easykms

#!/usr/bin/env ruby

require 'aws-sdk-kms'

key_id = ARGV[0]
kms_resp = Aws::KMS::Client.new.encrypt({
  key_id: key_id,
  plaintext: STDIN.read,
})

print Base64.strict_encode64(kms_resp.ciphertext_blob)
$ cat credential.json | ruby kms_encoder.rb [鍵の ARN]

以上の作業で暗号化され Base64 エンコードされた出力をテンプレート中の GC_CREDENTIAL に書き込んでください。

Lambda function に付与する role

以下のような権限を持つ role を事前に作り、作った role の ARN を SAM テンプレートの Role に指定するとよいでしょう。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "kms:Decrypt"
      ],
      "Resource": [
        "暗号化用の KMS 鍵の ARN をここに"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "sns:Publish"
      ],
      "Resource": "s3-multicloud-backup-retry SNS topic の ARN をここに"
    },
    {
      "Effect": "Allow",
      "Action": [
        "sqs:SendMessage"
      ],
      "Resource": "s3-multicloud-backup-dlq SQS queue の ARN をここに"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:Get*"
      ],
      "Resource": "バックアップ元の S3 バケットの ARN をここに/*"
    }
  ]
}

Trust relationship には以下のように指定して Lambda function に付与する role であることを示します。

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

aws-sam-local を使って開発する

SAM テンプレートを書いておけば、aws-sam-local を使ってローカルで Lambda function を実行することができます。その際、以下の点に注意してください。

  • 事前に Lambda 環境に近い状態で依存モジュールをビルドする
  • aws-sam-local コマンドを実行する際に、対象の S3 バケットを読み書きするためのクレデンシャルを AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY で与える

依存モジュールをビルドするには、aws-sam-local も内部で利用している lambci/lambda Docker image を利用すると便利です。以下のようなコマンドで npm install しましょう。

$ docker run --rm -v "$PWD/src/":/var/task lambci/lambda:build-nodejs6.10 npm install

すると、Lambda 環境に近い状態でビルドされた node_modules が src/ ディレクトリに作られ、以下のようなコマンドで function を動かすことができます。

$ aws-sam-local local invoke S3MulticloudBackupFunction -e sample-event-s3-via-sns.json --template=deploy/template/staging.yml

このように、手元から動かしてみることも考慮して、テンプレートは staging (もしくは development) 用と production に分けておくとよいでしょう。

buildspec も同様で、開発用の AWS アカウントと本番用の AWS アカウントが分かれている場合など、CodeBuild によるビルドによる生成物をアップロードするための S3 バケットが異なる場合があるので、staging 用と production 用に設定が分かれているほうが何かと便利だと思います。

aws-sam-local の詳しい使い方を知りたい場合は GitHub ページの README を参照するとよいでしょう。

CodePipeline を使ってビルドとデプロイを行う

ここからは、CodePipeline を使って CodeCommit、CodeBuild および CodeDeploy を組み合わせて、SAM テンプレートで定義したアプリケーションのビルドおよびデプロイについて説明します。

それぞれのサービスの詳細については、以下のドキュメントを参照してください。

デプロイできるようになるまでの大まかな流れとしては、以下のようになります。

  • 各種サービスロールを用意する
    • 各サービスで利用する S3 バケットを 3 つ用意する
      • CodePipeline 用
      • CodeBuild 用
      • CloudFormation 用
  • CodeCommit にリポジトリを用意する
  • CodeBuild にプロジェクトを追加する
  • CodePipeline にパイプラインを作成する
    • ここで CodeCommit、CodeBuild プロジェクト、CodeDeploy を組み合わせる

各種サービスロールを用意する

まだ Code シリーズや CloudFormation を利用していない場合サービスロールがない状態なので、事前に作成する必要があります。CodePipeline などを利用する IAM ユーザが持ってない権限であっても、サービスロールに強い権限があると間接的に行使できることになるため、以下の例を参考になるべく権限を絞るほうがよいでしょう。

CodePipeline 用のサービスロール

以下に CodePipeline が利用するためのサービスロール CodePipelineServiceRole の一例を示します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "cloudformation:CreateChangeSet",
        "cloudformation:CreateStack",
        "cloudformation:DeleteChangeSet",
        "cloudformation:DeleteStack",
        "cloudformation:DescribeChangeSet",
        "cloudformation:DescribeStacks",
        "cloudformation:ExecuteChangeSet",
        "cloudformation:SetStackPolicy",
        "cloudformation:UpdateStack",
        "cloudformation:ValidateTemplate"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "codebuild:BatchGetBuilds",
        "codebuild:StartBuild"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "codecommit:CancelUploadArchive",
        "codecommit:GetBranch",
        "codecommit:GetCommit",
        "codecommit:GetUploadArchiveStatus",
        "codecommit:UploadArchive"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "codedeploy:CreateDeployment",
        "codedeploy:GetApplicationRevision",
        "codedeploy:GetDeployment",
        "codedeploy:GetDeploymentConfig",
        "codedeploy:RegisterApplicationRevision"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "iam:PassRole"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "lambda:InvokeFunction",
        "lambda:ListFunctions"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "opsworks:CreateDeployment",
        "opsworks:DescribeApps",
        "opsworks:DescribeCommands",
        "opsworks:DescribeDeployments",
        "opsworks:DescribeInstances",
        "opsworks:DescribeStacks",
        "opsworks:UpdateApp",
        "opsworks:UpdateStack"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketVersioning",
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:PutObject"
      ],
      "Resource": [
        "[ここに CodePipeline が利用するための S3 バケットの ARN を入れる]",
        "[ここに CodePipeline が利用するための S3 バケットの ARN を入れる]/*",
      ]
    }
  ]
}
CodeBuild 用のサービスロール

以下に CodeBuild が利用するためのサービスロール CodeBuildServiceRole の一例を示します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "codecommit:GitPull"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecr:BatchCheckLayerAvailability",
        "ecr:BatchGetImage",
        "ecr:GetAuthorizationToken",
        "ecr:GetDownloadUrlForLayer"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:ap-northeast-1:(Account ID):log-group:/aws/codebuild/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:PutObject"
      ],
      "Resource": [
        "[ここに CodePipeline が利用するための S3 バケットの ARN を入れる]/*",
        "[ここに CodeBuild が利用するための S3 バケットの ARN を入れる]/*",
        "[ここに CodeBuild によってビルドされた生成物を置くための S3 バケット (CloudFormation 用) の ARN を入れる]/*"
      ]
    }
  ]
}
CloudFormation 用のサービスロール

以下に CloudFormation が利用するためのサービスロール CloudFormationServiceRole の一例を示します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "apigateway:*"
      ],
      "Resource": [
        "arn:aws:apigateway:ap-northeast-1::*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "cloudformation:CreateChangeSet"
      ],
      "Resource": [
        "arn:aws:cloudformation:ap-northeast-1:aws:transform/Serverless-2016-10-31"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "events:*"
      ],
      "Resource": [
        "arn:aws:events:ap-northeast-1:(Account ID):rule/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "iam:PassRole"
      ],
      "Resource": [
        "*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "lambda:*"
      ],
      "Resource": [
        "arn:aws:lambda:ap-northeast-1:(Account ID):function*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketVersioning",
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:PutObject"
      ],
      "Resource": [
        "[ここに CodeBuild によってビルドされた生成物を置くための S3 バケット (CloudFormation 用) の ARN を入れる]/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "sns:ListSubscriptionsByTopic",
        "sns:Subscribe",
        "sns:Unsubscribe"
      ],
      "Resource": [
        "arn:aws:sns:ap-northeast-1:(Account ID):*"
      ]
    }
  ]
}

CodeCommit にリポジトリを用意する

CodeBuild では、コードのソースとして S3 バケットや CodeCommit、GitHub などをサポートしており、最近以下のブログポストにあるように、GitHub Enterprise 連携も登場しました。

Announcing AWS CodeBuild Support for GitHub Enterprise as a Source Type and Shallow Cloning | AWS DevOps Blog

ここでは、もっともお手軽な CodeCommit を利用することにし、CodeCommit に s3-multicloud-backup という名前でリポジトリを作成し、master ブランチをビルド・デプロイすることとします。

CodePipeline を構築する

SAM テンプレートで定義されたアプリケーションをデプロイするための最小のパイプラインは、以下のようなものになります。1 段目で CodeCommit からソースを取得し、2 段目で CodeBuild を用いて依存ライブラリのビルドおよび aws cloudformation package を行い、3 段目で stack の change set を作成し、4 段目で stack の change set を適用します。

f:id:mozamimy:20180202132555p:plain

各ステップは以下のように設定します。

1 段目: CodeCommit

f:id:mozamimy:20180202132537p:plain

Source provider に AWS CodeCommit を指定し、Repository name と Branch name をいい感じに埋めてください。

2 段目: CodeBuild

まず、以下のような設定で CodeBuild のプロジェクト s3-multicloud-backup を作成しましょう。Node.js 用の image を利用し、Buildspec name に buildspec ファイルの位置を指定し、Service role に事前に用意しておいた CodeBuild 用のサービスロールを指定すれば OK です。

f:id:mozamimy:20180202132523p:plain

パイプラインには以下のように設定します。

f:id:mozamimy:20180202132532p:plain

3 段目: CodeDeploy と CloudFormation による stack の change set の作成

以下のスクリーンショットのように、Deployment provider に AWS CloudFormation を指定し、Action mode を Create or replace a change set を指定し、Role name には事前に用意しておいた CloudFormation 用のサービスロールを指定します。

f:id:mozamimy:20180202132548p:plain

4 段目: CodeDeploy と CloudFormation による stack の change set の適用

4 段目では、3 段目で作成した実行計画ともいえる change set を実行できるように、以下のスクリーンショットのように設定します。Action mode に Execute a change set を指定するのがミソです。

f:id:mozamimy:20180202132543p:plain

以上で CodePipeline の設定は完了です。Release change ボタンを押せばパイプラインの各ステップが実行され、最終的に SAM テンプレートに定義された各リソースが作成され、アプリケーションが利用可能な状態になるでしょう。

アラートの発行とモニタリング

クックパッドではアラート用の SNS topic が存在し、その topic にアラートの内容を送るとアラート用のメールアドレスにメールが飛び、最終的に PagerDuty で incident が発行されるという仕組みになっています。以下の例では DLQ 用の SQS の NumberOfMessageSent を CloudWatch Alarm で監視し、メッセージが発行されたことを検知するとアラート用の SNS topic に通知が飛ぶようになっています。アラートに関しては読者の皆さんの環境によって違うと思うので、それに合わせて設定してください。

f:id:mozamimy:20180207085432p:plain

CloudWatch では任意のメトリクスを組み合わせてダッシュボードを作ることができるので、以下のようなダッシュボードを作っておくと便利でしょう。

f:id:mozamimy:20180207085424p:plain

たまに数回エラーが発生していますが、ほとんどは Lambda の非同期呼び出しの自動リトライで成功していることがわかります。また、点になっていてやや見づらいですが、02/02 に一度だけ s3-multicloud-backup-retry function が実行されていることがわかります。

codepipeline-notify: Golang で実装された CodePipeline の進捗を Slack に投稿するアプリケーション

ここまで、SAM テンプレートで定義された s3-multicloud-backup というサーバーレスアプリケーションを CodePipeline を用いてデプロイする方法について述べてきました。

ここからは、1月に Lambda で Golang が利用可能になった ということもあり、Golang で実装された Lambda function の一例として、CodePipeline の進捗を Slack に投稿するアプリケーションを SAM テンプレートで定義してデプロイする方法について書いていきます。従来 Lambda で公式にサポートされていた静的型付け言語は Java および C# のみであったことを考えると、手軽に書ける静的型付け言語ということで Golang は選択肢の一つとして有力なものとなるでしょう。

ソースコードと SAM テンプレート

ここからは Golang で実装された function のソースコードと SAM テンプレートについて説明します。ソースコードは https://github.com/mozamimy/codepipeline-notify に push してあるので、必要に応じてご活用ください。

ディレクトリツリーは以下のようになります。

.
├── Makefile
├── README.md
├── deploy
│   ├── buildspec
│   │   ├── production.yml
│   │   └── staging.yml
│   └── template
│       ├── production.yml
│       └── staging.yml
├── handler
│   └── handler.go
├── main.go
└── sample-event.json

main.go

package main

import (
    "github.com/mozamimy/codepipeline-notify/handler"
    "github.com/aws/aws-lambda-go/lambda"
)

func main() {
    lambda.Start(handler.HandleRequest)
}

ハンドラは handler.go に記述することにし、main では lambda.Start() にハンドラを渡して実行を待ち受ける形になります。

handler.go

package handler

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "net/url"
    "os"
)

type CodePipelineEventDetail struct {
    Pipeline    string      `json:"pipeline"`
    State       string      `json:"state"`
    ExecutionID string      `json:"execution-id"`
    Version     json.Number `json:"version"`
}

type CodePipelineEvent struct {
    Detail CodePipelineEventDetail `json:"detail"`
}

type SlackPayload struct {
    Text        string            `json:"text"`
    Username    string            `json:"username"`
    Icon_emoji  string            `json:"icon_emoji"`
    Icon_url    string            `json:"icon_url"`
    Channel     string            `json:"channel"`
    Attachments []SlackAttachment `json:"attachments"`
}

type SlackAttachment struct {
    Color  string       `json:"color"`
    Fields []SlackField `json:"fields"`
}

type SlackField struct {
    Title string `json:"title"`
    Value string `json:"value"`
    Short bool   `json:"short"`
}

func HandleRequest(codePipelineEvent CodePipelineEvent) {
    field := SlackField{
        Value: fmt.Sprintf("The state of pipeline `%s` is changed to `%s` (execution_id: %s)", codePipelineEvent.Detail.Pipeline, codePipelineEvent.Detail.State, codePipelineEvent.Detail.ExecutionID),
        Short: false,
    }
    colorMap := map[string]string{
        "CANCELED":   "warning",
        "FAILED":     "danger",
        "RESUMED":    "warning",
        "STARTED":    "good",
        "SUCCEEDED":  "good",
        "SUPERSEDED": "warning",
    }
    attachment := SlackAttachment{
        Color:  colorMap[codePipelineEvent.Detail.State],
        Fields: []SlackField{field},
    }
    params, _ := json.Marshal(SlackPayload{
        Username:    "CodePipeline",
        Icon_emoji:  os.Getenv("SLACK_EMOJI_ICON"),
        Channel:     os.Getenv("SLACK_CHANNEL"),
        Attachments: []SlackAttachment{attachment},
    })

    resp, _ := http.PostForm(
        os.Getenv("SLACK_WEBHOOK_URL"),
        url.Values{
            "payload": {string(params)},
        },
    )

    body, _ := ioutil.ReadAll(resp.Body)
    defer resp.Body.Close()

    fmt.Printf(string(body))
}

CodePipelineEvent 構造体がミソで、ハンドラとなる関数 HandleRequest() の引数に構造体を与えることで、自動的に JSON をパースして仮引数に渡してくれます。CloudWatch Event から受け取れるサンプルイベント (sample-event.json) を以下に示します。

{
  "version": "0",
  "id": "CWE-event-id",
  "detail-type": "CodePipeline Pipeline Execution State Change",
  "source": "aws.codepipeline",
  "account": "123456789012",
  "time": "2017-04-22T03:31:47Z",
  "region": "us-east-1",
  "resources": [
    "arn:aws:codepipeline:us-east-1:123456789012:pipeline:myPipeline"
  ],
  "detail": {
    "pipeline": "myPipeline",
    "version": 1,
    "state": "CANCELED",
    "execution-id": "01234567-0123-0123-0123-012345678901"
  }
}

この仕組みは便利な半面、JSON と静的型付け言語の相性の悪さが浮き彫りになるところでもあり、デバッグがやや面倒になりがちな部分なので注意してください。ハンドラの仕様については以下のドキュメントや実装そのものが参考になります。ここでは、引数を取る場合でもっともシンプルなパターン func (TIn) で実装しています。

buildspec

buildspec は以下のように書くとよいでしょう。install フェーズでソースコードを GOPATH 以下に配置し、pre_build フェーズで go get し、build フェーズで go build して得たバイナリを zip にまとめ、AWS CLI の cloudformation package サブコマンドを利用してパッケージングします。このステップにより、--template-file に指定したテンプレートを読み込んで src ディレクトリ以下を zip ファイルにまとめ、--s3-bucket に指定した S3 バケットに生成物を自動的にアップロードしてくれます。s3-multicloud-backup と同様、--s3-bucket に指定するバケットは、各自の環境の合わせて変更してください。

version: 0.2
env:
  variables:
    PACKAGE: 'github.com/mozamimy/codepipeline-notify'
phases:
  install:
    commands:
      - 'mkdir -p "/go/src/$(dirname ${PACKAGE})"'
      - 'ln -s "${CODEBUILD_SRC_DIR}" "/go/src/${PACKAGE}"'
      - 'env'
  pre_build:
    commands:
      - 'cd "/go/src/${PACKAGE}"'
      - 'go get ./...'
  build:
    commands:
      - 'go build -o main'
      - 'zip main.zip main'
      - 'aws cloudformation package --template-file deploy/template/production.yml --s3-bucket sam-artifact.ap-northeast-1 --output-template-file template.package.yml'
artifacts:
  files:
    - 'template.package.yml'

SAM テンプレート

以下に SAM テンプレートの例を示します。今回はシンプルに Lambda function が 1 コしかないため、テンプレートの内容もシンプルです。SLACK_ から始まる各環境変数は、お手持ちの環境に合わせて適宜書き換えてください。

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: 'A serverless application to notify whether it succeeded or not.'

Resources:
  CodePipelineNotify:
    Type: 'AWS::Serverless::Function'
    Properties:
      CodeUri: '../../main.zip'
      Handler: 'main'
      Runtime: 'go1.x'
      FunctionName: 'codepipeline-notify'
      MemorySize: 128
      Timeout: 8
      Events:
        CodeCommitStateChanged:
          Type: 'CloudWatchEvent'
          Properties:
            Pattern:
              source:
                - 'aws.codepipeline'
              detail-type:
                - 'CodePipeline Pipeline Execution State Change'
      Environment:
        Variables:
          SLACK_WEBHOOK_URL: 'Slack の incoming webhook URL をここに入れる'
          SLACK_CHANNEL: '#serverless'
          SLACK_EMOJI_ICON: ':samtaro1:'

aws-sam-local を用いてローカルで function を動かす

今回は以下のような簡単な Makefile を用意したので、make コマンドを使ってビルドし aws-sam-local コマンドを使ってローカルで function を動かすことができます。s3-multicloud-backup の場合とは違い、Golang はクロスコンパイルが容易なため、手持ちの環境が Linux でない場合も Linux コンテナを使わずに GOOS=linux としてビルドすれば十分でしょう。

main: main.go handler/handler.go
    go build -o main
main.zip: main
    zip main.zip main
clean:
    rm -f main main.zip
$ GOOS=linux make main.zip
$ aws-sam-local local invoke CodePipelineNotify -e sample-event.json --template=deploy/template/production.yml
$ make clean

また、s3-multicloud-backup の場合と同様、CodePipeline を使ってビルドおよびデプロイのための仕組みを構築することができます。

まとめ

今回は、マルチクラウドバックアップを題材にして AWS SAM、CodePipeline を用いたサーバーレスアプリケーションの構築、ビルドおよびデプロイについて、チュートリアル的に解説しました。また、Golang で実装した CodePipeline の進捗を Slack に投稿するためのアプリケーションについても解説しました。

SAM を用いてサーバーレスアプリケーションの構成をテキストファイルに記述することで、いわゆる Infrastructure as Code の恩恵を受けることができ、加えて CodePipeline をはじめとする AWS の開発者ツールを利用することにより、ビルド及びデプロイの自動化が簡単になります。

今回は扱いませんでしたが、SAM と aws-sam-local の組み合わせによって API Gateway を用いた Web アプリケーションの開発やデプロイが容易になるといったメリットもあります。また、今回はテストコードを含めていませんが、buildspec にテストを起動するためのコマンドを含めれば、ビルドのパイプラインにテストを組み込むことも可能です。

他にも、codepipeline-notify の改良として、DynamoDB にパイプライン名と Slack チャンネルの対応を持たせておき、Slack channel の出し分けといったようなこともできるでしょう。また、AWS SDK や AWS CLI を用いて Slack などのチャットボットが CodePipeline を起動するようにすれば、サーバーレスアプリケーションに Chatops を持ち込むこともできます。

本記事がこれから Lambda を本格利用していこうとしている方や、すでに Lambda を利用しているが SAM などの管理手法を導入していない方の助けになれば幸いです。


  1. 料理を楽しくする画像配信システム

  2. 実際には DynamoDB のようにオートスケールを設定できるものの、プロビジョニングの概念が存在するコンポーネントはありますが…

  3. これはいかなる場合も当てはまるというわけではありません。たとえば、普段はほとんど利用されないが、定期的に実行がバーストするようなワークロードだと Lambda 型のほうが有利になります。

  4. Understanding Retry Behavior - AWS Lambda