アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

Codebuild のランタイムのアップデートでハマった

morishitaです。

先日、別のことでハマっていたときに「ビルド環境が古いせいではないか?」と思ってしまい焦ってCodebuild のランタイムを変更したら余計にハマったという話です。

当社でのCodeBuildの利用状況

本題に入る前にアクトインディでのCodeBuildの利用状況を紹介します。

主な用途は次のとおりです。

  • Dockerイメージのビルド
    • 本番サービス用イメージ
    • 開発環境用DBイメージ
  • 自動テスト(RSpec、Prontoなど)の実行

本番サービス用DockerイメージのビルドはCodePipelineに組み込まれています。
開発環境用DBイメージというのは、毎日本番DBのバックアップをリストアして作っているものです。 各エンジニア、デザイナーがローカル開発環境で使うもので、プルすればほぼ最新の本番データをローカル環境で動かせます1

自動テストについては次のエントリで紹介しています。

tech.actindi.net

tech.actindi.net

ランタイムを最新にしたら動かなくなった!

さて、本題です。

Webpacker+Workboxの設定変更をしていてローカル環境での実行ではうまく設定どおりに動作するのですが、本番適用するためDockerイメージをビルドするとうまく動作しないという事象を調べていました。

設定の仕方やコマンドの実行順序、実行タイミングなどいろいろ試してみましたがうまくいかず困っていました。
Dockerイメージのビルド中の処理なのでCodeBuildのランタイムが原因であることは冷静に考えれば少ないはずです。
でも、うまくいかない原因を見つけられず焦ってしまい実行環境を疑ってしまいました。

ビルドに使っている CodeBuild の設定を確認すると利用しているイメージがaws/codebuild/docker:17.09.0となっていました。

f:id:HeRo:20190731085509p:plain
CodeBuildの環境設定(変更前)

Dockerのバージョンが 17.09.0 とやや古い。「まさかこれが原因?」と思って変更しようとしました。
で、イメージの上書きをクリックして見ると…。

f:id:HeRo:20190731085703p:plain
仕様が変わっていたCodeBuildの環境設定

あれ、しばらく触ってなかったら設定項目が変わっている。
なにより、イメージのバージョニング体系が変わっている。

でもまあ、些細な仕様変更だろう。
最新を選択すればいいだろうと次の様に設定を変更してしまいました。

項目 設定値
ランタイム Standard
イメージ aws/codebuild/standard:2.0
イメージのバージョン aws/codebuild/standard:2.0-1.11.0

で、再度CodeBuildを実行すると、エラーで止まってしまいました。

f:id:HeRo:20190731085840p:plain
ビルド失敗
11秒で止まっているとは実質起動すらしてない様子。

しかも、設定変更前のイメージaws/codebuild/docker:17.09.0 はもう選択できません。
もとに戻すという退路も絶たれていました。

うわー、余計にハマった!
もともとサクッと終わる瞬殺タスクだと思っていたのに…。

buildspec.ymlに変更が必要だった

ログを見てみると次のエラーメッセージが出ていました。

Phase context status code: YAML_FILE_ERROR Message: This build image requires selecting at least one runtime version.

ふむふむ、ランタイムバージョンを少なくとも1つしてする必要があるらしい。

ドキュメントを確認してみると次の様に変わったようです。

以前は、Ruby、Node.js、Dockerなど使いたい言語やツールごとにイメージが用意されていたと思います。
それが、イメージが整理されたらしく、バージョンを選択してどの言語を使うのかは buildspec.yml で指定するように変わったようです。

ドキュメントには次の様に書かれています2

Ubuntu Standard イメージ 2.0 を使用する場合、buildspec ファイルの runtime-versions セクションでランタイムを指定できます。

「指定できます」と書かれていますが、先のエラーメッセージを見ると1つは「指定しなければならない」ようです。

選択できるランタイムのバージョンは次のとおりです。

プログラム言語 ランタイムのバージョン
Ruby 2.x
Python 3.x
PHP 7.x
Node.js 8.x、10.x
Java 8、11
Golang 1.x
.NET Core 2.x
Docker 18.x
Android 28.x

今回は Dockerが使えればいいので、buildspec.yml に次のように設定を追加しました。

version: 0.2

env:
  variables:
    〜 省略 〜
 phases:
  install:
    runtime-versions:
      docker: 18
  pre_build:
    〜 以下、省略 〜

あと、もう1つ。
Dockerイメージ作る場合にはCodeBuildの環境設定の特権付与にチェックを入れるのも重要です。これにチェックを入れないとDockerが動きません。

これで元通り動くようになりました。
やれやれ。

まとめ

新しいCodebuildのランタイムイメージのDockerファイルはGithubで公開されています。

それによると次のようにアクティブにメンテするのは standard 2.0 だとと書いているので、今後は standard 2.0を選択したほうがいいと思います。

The following images are actively maintained by AWS CodeBuild, and are listed in the CodeBuild console.

  • standard 2.0

standard 2.0 にアップデートする際には buildspec.yml の変更も必要なのでご注意を。

参考

最後に

アクトインディではエンジニアを募集しています。 actindi.net


  1. “ほぼ"というのは1日1回ビルドなので若干タイムラグがあるのと、メアドなどのセンシティブなデータはマスクされているという意味です。

  2. https://docs.aws.amazon.com/ja_jp/codebuild/latest/userguide/build-env-ref-available.html

AWS Chatbotを触ってみた

morishitaです。

AWSで様々なサービスを使っていて、常々不便に思っていたことがあります。

それは Slackに通知できない ということです。

もちろん、Lambda を使って Slack にメッセージを送るのは、難しくないしすぐできます。実際、Cloud Watch Alert の重要なものは SNS(Simple Notification Service) + Lambda で Slack に通知していたりします。

先日、紹介した Amplify Console はメール通知機能を備えており、ビルド、デプロイの成否をメールで通知してくれます。Amplify Consoleの様に開発プロセスの一部に組み込まれるサービスにとって通知のニーズが必要とはAWSもわかっているなぁと感心しました。
しかし、一方で「その手段がメールだけって…」と軽い失望を覚えました。

tech.actindi.net

Amplify Console のような比較的新しいサービスにおいてもそんな感じなので、AWS的にはあんまりやる気ないのかなと思っていました。

大きな手間でもないのだけど、だからこそIncoming WebhookのURL設定するだけ「デプロイ終わったよ」くらい送れる程度のことでいいからできればいいのに。

と思っていたところ、このAWSのミッシングピースを埋めるかもしれないサービスが発表されました!

aws.amazon.com

2019/07/29時点でベータ版とのことですが、すでに東京リージョンでも使えるようです 1

試してみました。

早速使ってみる

まあ、Slackになにか通知できるだけのサービス、そんなに難しくはないだろうとドキュメントを読むのもそこそこに触ってみました。

チャットサービスの選択

Chatbot Consoleにアクセスするとチャットサービスを選択ができます。

次の選択肢があります。

  • Amazon Chime
  • Slack

Amazon Chime はオンライン会議・チャットサービスとのことです。
AWSにはこんなサービスもあるのかと思いつつ、選択するのはもちろん Slack です。

f:id:HeRo:20190729083055p:plain
チャットサービスの選択

Slackアプリケーションのインストール許可

Configure client ボタンをクリックして進むと、Slack に飛ばされて、Slack アプリケーションのインストール許可を求められます。

f:id:HeRo:20190729083155p:plain
Slackアプリのインストールダイアログ

ブラウザでログインしている Slack アカウントがあればそれが表示されます。 右上のプルダウンで別のアカウントを選択することも可能です。

許可するインストールされます。 Slack側のアプリリストにも次のように追加されています。

f:id:HeRo:20190729083244p:plain
Slackアプリに追加されている

チャネルの設定

インストールOKするとChatbotの設定に戻ります。 設定は3つのパートに分かれています。

まずは、Slack Channel の設定。

f:id:HeRo:20190729084005p:plain
Slack チャネルの設定

Private を選択すると Private channel ID を直接入力します。 Public を選択すると接続した Slack にあるチャネルのリストから送信先のチャネルを選択できます。

続いて、IAMロールの設定。

f:id:HeRo:20190729084039p:plain
IAMロールの設定

既存のも選択できますが、Create an IAM role using a template を選択すれば、必要な権限を持ったロールを作ってくれます。

最後にメッセージのソースとなる SNS(Simple Notification Service)のトピックの選択。

f:id:HeRo:20190729084102p:plain
SNSトピックの設定

ChatbotSNS のサブスクライバーの1つになるので、SNS のトピックを選択します。 既存のものからの選択になるので、適当なのがなければ予め作って置く必要あります。 複数のリージョンの複数のトピックを指定することも可能です。

Configure ボタンをクリックして設定完了です。

選択した SNS トピックをコンソールを確認すると、サブスクリプションに Chatbotのエンドポイントが追加されています。

f:id:HeRo:20190729084228p:plain
SNSトピックのサブスクリプション

テストメッセージ送信

とりあえず、疎通確認としてなにか送ってみようと SNS のコンソールからメッセージを発行してみましたが、一向に Slack のチャネルにはメッセージが届かない…。

試しに、メールのサブスクライバーを追加してメッセージを発行してみると、メールにはちゃんと届きます。
しかし、Slack には何度送っても届かない。

IAMロールも Chatbot のコーンソールが作ったものを使っているので権限不足ってこともないいだろうし、何が悪いのか…。

と、ここでドキュメントに立ち返ってみると、現状 Chatbot がサポートしてるサービスが限られているようです。
Using AWS Chatbot with Other AWS Services - AWS Chatbotに書いてありました。

次のサービスがサポートされています。

  • Amazon CloudWatch
  • AWS Health
  • AWS Budgets
  • AWS Security Hub
  • Amazon GuardDuty
  • AWS CloudFormation

これら以外のサービスからメッセージを送信してもしれっと無視されるようです。

EC2に負荷をかけてみた

なるほど。ならばということでEC2インスタンスを実験用に作って負荷をかけてみることにしました。

作ったインスタンスには次のようなアラームを設定しました。

f:id:HeRo:20190729084430p:plain
Cloud Watch Alert

そして、stress コマンドで負荷をかけて見ると…。

来ました!

次のようなメッセージが Slack の設定したチャネルに届きました。

f:id:HeRo:20190729084508p:plain
Slackに送られたAlert通知

タイトル部分が該当する Cloud Watch Alert へのリンクになっていて、詳細を確認しやすくなっています。
グラフもついて、いまLambdaで自前で送っているメッセージより見やすいかも。

そして負荷をやめると次のメッセージが。

f:id:HeRo:20190729084538p:plain
Slackに送られたOK通知

あれ、こちらはちょっとグラフの内容がおかしいような。 ベータサービスだから?

まとめ

  • ChatbotSNS 経由で各種サービスの通知をSlackに送信できる
  • 特に CloudWatch AlertSlack に送信するのは簡単
  • 今は送信できるサービスが少ない

最初は SNS のどんなメッセージも送信できると思ったので、ちょっと期待通りではなかったですが、今後拡充されればもっと便利なサービスになるのではと思います。

参考

最後に

アクトインディではエンジニアを募集しています。 actindi.net


  1. AWS Chatbot自体はリージョンに属さいないグローバルサービスのようです。

Nuxt.jsのSPAをAmplify Consoleでホストする。めっちゃ簡単だった!

morishitaです。

以前、次のエントリを書きました。

tech.actindi.net

このとき作ったNuxt.jsのアプリケーションはその後、 リニューアルして2019年4月からはいこーよ!こどもBIRTHDAYとして利用しています。

https://birthday.iko-yo.net/birthday.iko-yo.net

今後は対象地域と施設を増やして拡充していく予定です。
それに伴い開発が活発になるのでステージング環境や 施設の方に予め確認いただくプレビュー環境もちゃんと準備する必要が出てきました。

上記エントリで紹介した環境を拡張しても良かったのですが、 せっかくなので東京リージョンでも使えるようになったAWS Amplify Consoleを使ってみました。

aws.amazon.com

Amplify Consoleとは

AWSが開発しているAmplifyというフレームワークがあります。
ReactやAngular、Vue.jsのアプリケーションをフロントエンドとして、 AppSync、DynamoDB、Cognitoなどのバックエンドの構築までをCLIで行えます。
フルスタックのサーバレスアプリケーションの構築から継続的なデプロイまでできてしまう超便利ツールです。

Amplifyフレームワークでは大量に設定やAppSyncのスキーマ定義のファイルが生成されます。
一部の生成ファイルは一緒に生成される.gitignoreでリポジトリに入れないように設定されます。
複数人で開発するとなるとリポジトリに入らないファイルを個別に持つのも良くないので Amplify CLIを実行する環境、生成されるファイル群を管理する仕組みを作る必要があります。
それがちょっと面倒だなぁと思っていました。

Amplify Consoleはそんな面倒を解決してくれるサービスです。
GithubなどのリポジトリにPushすると連携して、ビルド、デプロイしてくれます。
デプロイに利用するサーバ等を直接管理することのないフルマネージドのサーバレスサービスで次の機能をしてくれます。

  • 複数のブランチでそれぞれ別個にビルド、デプロイが可能
  • カスタムドメインで運用でき、サブドメインもつけ放題
  • ブランチごとにBASIC認証をかけられる
  • Amplifyフレームワークのアプリケーションならばバックエンドも構築できる
  • フロントエンドはCDNを利用して配信してくれる

プロトタイピング程度なら便利で使えるなぁという印象だったAmplifyをプロダクションでも使ってみようかなと思わせる、Amplify Consoleはそんなサービスです。

Amplify Consoleでアプリを作ってみる

いこーよ!こどもBIRTHDAYのアプリケーションの構成は次のとおりです。

  • Nuxt 2.8.1 + @nuxt/typescript
    • mode: 'spa'の静的サイト
  • Typescript 2.8.0

これをビルド・デプロイするまで流れを説明します。

アプリを設定する

最初にリポジトリと接続します。
リポジトリサービスを選択すると、OAuthの認証を促されるので従います。

f:id:HeRo:20190610110648p:plain

リポジトリとブランチが選択できるようになるので選びます。

f:id:HeRo:20190610110707p:plain

アプリ名をつけて、ビルド設定を行います。
React、Angular、Vue.jsなどは自動で認識してビルド設定を自動で作ってくれます。
ソースにGulpfileを含んでいたのでそれを使うと判定されたようです。

f:id:HeRo:20190610110725p:plain

独自にビルド処理を定義するならば、リポジトリにAmplify.ymlを作成します。

前述のGulpfileはビルド成果物のS3へのコピーなどといったデプロイ処理を行っています。それはAmplify Consoleに任せるので、次の処理だけするように変更したAmplify.ymlを用意しました。

  • yarn install
  • yarn run build
  • スラックへの通知(scripts/notify-deploy-to-slack.js

実際のAmplify.ymlは次のとおりです。

version: 0.1
frontend:
  phases:
    preBuild:
      commands:
        - yarn install
    build:
      commands:
        - yarn run build
    postBuild:
      commands:
        - node scripts/notify-deploy-to-slack.js
  artifacts:
    baseDirectory: dist/
    files:
      - '**/*'
  cache:
    paths:
      - node_modules/**/*

上記ファイルを追加して、再度アプリを作成しようとすると次のようにAmplify.ymlが認識されます。
環境変数API_URL_BROWSER1も設定します。
環境変数はアプリの作成のときでなくてもあとでいつでも追加できます。
特定のブランチでのみ値を変更することも可能です。

f:id:HeRo:20190610110750p:plain

最後に設定内容を確認します。

f:id:HeRo:20190610110808p:plain

すると処理が開始されます。

f:id:HeRo:20190610110826p:plain

しばらく待つと、処理が完了します。

f:id:HeRo:20190610110845p:plain

スクリーンキャプチャの下のリンクからデプロイしたアプリケーションにアクセスできます。
今後、対象ブランチはリポジトリにPushするたびにビルド・デプロイされます。

以上でSPAを継続的にビルドしデプロイする環境ができました。
簡単ですね。

リダイレクト設定

実はここまでの設定ではクライアント側でリクエストパスのルーティングを行うSPAの場合にはちゃんと動かないと思います。
ドメインルートからの遷移では問題ないのですが、それ以外のパスへダイレクトアクセスされると実体のファイルがないので Not Found になります。

これを解決するためにリダイレクト設定を行います。
左ペインの「書き換えて、リダイレクト」というを選択します(機械翻訳でしょうか?変な日本語ですね)。

デフォルトでは次の画像のように設定されています。

f:id:HeRo:20190610110908p:plain

編集・追加はWebフォームでできるようになっています。

f:id:HeRo:20190610110924p:plain

「テキストエディタで開く」と次のようなダイアログ中にJSONで設定が表示されます。この中で編集できます。

f:id:HeRo:20190610110940p:plain

ただ、いまいち使いづらいので、別のところで書いた設定をコピー&ペーストするほうがいいと思います。

Nuxt.jsのSPAではどう設定すればいいか?

クライアントでルーティングを行うSPAではどのパスへのアクセスもアプリケーションのエントリポイントとなるHTMLファイルを返す必要があります。
一方、CSSその他のファイルはそのまま処理してレスポンスする必要があります。

公式ドキュメントリダイレクトを使用する - AWS Amplifyに一応、SPAの場合の設定例が記述されているのですが、それではうまく動作しませんでした。

次のように設定するとうまく動作しました。

[
    {
        "source": "</^((?!.(json|css|gif|ico|jpg|js|png|txt|svg|woff|ttf)$).)*$/>",
        "target": "/",
        "status": "200",
        "condition": null
    }
]

これで、Amplify Consoleによる Nuxt SPAのビルド、デプロイ、配信が完成です。

独自ドメイン設定

さて、ここまでの操作ではデプロイしたものはAmplify Consoleが自動的に割り当てたドメインで配信されます。
開発環境として使うならいいのですが、本番環境で使うには カスタムドメインを設定して独自ドメインでの配信できるようにしなければなりません。

今回はbirthday.iko-yo.net で公開するので、それを設定します。
すでにRoute53のゾーンがあるので、それを選択します。

f:id:HeRo:20190610111054p:plain

サブドメイン名と対応するブランチを指定するとサブドメインも作り放題で増やせます。

設定後、処理が完了するまで、結構時間がかかる場合があります。ちょっと気長に待ちます。
終わるとRoute53にもCNAMEレコードが追加されています。

f:id:HeRo:20190612084039p:plain

以上で本番環境を独自ドメインを使ってAmplify Consoleで運用できます!

ブランチの追加

今回、Amplify Consoleを使ってみようと思った動機はmasterだけでなく複数のブランチで個別の環境を用意したいからでした。
それを実現するためにブランチを追加してみます。

「ブランチの接続」から別のブランチを追加すると簡単に任意のブランチを別環境にデプロイできるようになります。 f:id:HeRo:20190610111003p:plain

一度設定するとそのブランチへPushすると自動的にデプロイされます。
テストやステージングの環境を作り放題です!

ステージングはBASIC認証をかけたい

本番環境以外は関係者だけが見られるように制限したい場合も多いと思います。
Amplify Consoleならそれも簡単です。

左ペインの「アクセスコントロール」を選択するとBASIC認証の設定を行えます。
全ブランチに共通のusername/passwordを設定できますし、 ブランチごとに別々のusername/passwordを設定することもできます。
社内の関係者向け、社外の関係者向け使い分けることも簡単です。

この機能がAmplify Consoleで一番気の利いた機能だと個人的には思いました2

Amplify Consoleを探る

さて、ここまでSPAアプリケーションを設定してデプロイするまでを説明しました。とても便利なAmplify Consoleですが、どんな仕組みで動いているのでしょうか。

AWS Amplify コンソールのドキュメントには記述はないですが、コンソールに表示される値やログから推測すると次のAWSのサービスが使われているようです。

  • CodeBuild
  • S3
  • CloudFront
  • CloudFormation

NuxtのSPAを S3+CloudFrontでホストする。デプロイはCodeBuildで自動化でやったことと大体同じことがサービス化されているんだなぁと思いました。

これらのリソースが使われていると思われるものの、それぞれのサービスのWebコンソールには現れません。 一般的な管理とは別にAmplifyがプライベートに管理する環境でプロビジョニングされるようです。
なので、利用時には上記のリソースに関しては全く気にすることはありません。

配信にはCloudFormationが利用されているようなのでパフォーマンスやスケーラビリティも心配はなさそうです。

Amplify Consoleの気になるところ

Amplify Consoleはとても簡単で便利に使えるサービスです。
今の所、不満な点は殆どありません。
強いて上げるとすれば次の3点でしょうか。

  • 反映されるまでにちょっと時間がかかる設定がある
  • 標準で用意されている通知手段がメールのみ
  • 料金がちょっと割高っぽい

前者は裏で動いている仕組みが多そうなので仕方ない面もあるとは思うのですが、 カスタムドメイン設定とアクセスコントロール設定は反映に時間がかかる印象です。

AWSはもう個別のサービスに通知機能を実装しないでもいいのではと思います。
SNSのトピックにパブリッシュできればあとはよしなにできるので。
欲を言えばSlackへの通知をサポートしてほしいところです。

コストは?

さて、機能面では控えめに言って超オススメなAmplify Console、気になるコスト面ではどうでしょうか?

S3+CloudFront+CodeBuildで同様の仕組みを構築するとしてコストを比較してみました。 すべて東京リージョンでの料金です。

コスト項目 Amplify Console S3+CloudFront+CodeBuild
ビルド(USD/分) 0.01 CodeBuild: 0.01
ストレージ(USD/GB月) 0.023 S3: 0.025
ホスティング(USD) 43.9 CloudFront: 33.8

Amplify Consoleのビルド環境は4 vCPU, 7GB memoryと表示されるのでそれに相当する CodeBuildの料金と比べました。しかし、Nuxtのビルド程度なら一番小さい2 vCPU, 4GB memoryのCodeBuildで十分なのでビルドについては割高だと思います。

ストレージはAmplify Consoleのほうがややお得。しかし全体に占める割合が小さいと思われます。

ホスティングは1MB/リクエストのサイズのページを10000PV/日、30日、HTTPSで配信したときの概算料金です。
これはトラフィックが増えれば(=サイトが成長すれば)支配的なコストとなると思います。
Amplify Consoleはやはりちょっと割高ですね。

まとめ

NuxtのSPAを S3+CloudFrontでホストする。デプロイはCodeBuildで自動化で AWSの各種サービスを自前で連携させて環境を作ったのがバカバカしくなりました。
それほどAmplify Consoleは簡単に使え、便利なサービスです。

運用に関する作業コストははとても小さいので、それが配信のホスティングコストを相殺できると考えられるならオススメサービスです。

最後に

いこーよ!子供BIRTHDAYは毎月第3土曜日にその月生まれのお子様が入場無料になったりするお得クーポンを配布しているサービスです。
夏には地域を拡大してリニューアル予定なのでぜひご利用ください。

https://birthday.iko-yo.net/birthday.iko-yo.net

アクトインディではそんなパパ・ママ向けのお得サービスを一緒に開発したいエンジニアを募集しています。

actindi.net


  1. https://axios.nuxtjs.org/options#browserbaseurl

  2. CloudFront+S3でこれをやろうとするとLambdaを作って設定したりしないといけないので面倒ですよね。

Cloudformation 入門してみました

morishitaです。

これまで、なんかめんどくさそうでCloudfrmationは避けてきました。

ElasticBeanstalk や Serverlessフレームワークは裏側でCloudFormationが動くので、間接的には使ってきました。
デフォルトで用意されないリソースを追加するのにちょっとだけYAMLの定義追加する程度はやりました。
それなりに便利だと恩恵を感じてはいたのですが、直接使ったことはありませんでした。

ドキュメントの多さに、どこから手をつけていいかわからないとっつきにくさにかまけて避けていたのです。

ですが、「すぐいこ」の開発でイチからインフラを構築する機会がありついに入門してみました。

2021年03月 追記

私自身はもう CloudFormation で新規にインフラを構築することはないと思います。というのもCDKのほうが圧倒的に便利で、メンテナンスしやすいからです。

CDKについてのエントリはいくつかあるのでこちらもどうぞ御覧ください。 tech.actindi.net

CloudFormationを使ったインフラ構築の流れ

CloudFormation スタックのテンプレートをGitで管理したいので、Webコンソール上でなくローカルでテンプレートを作成します。

次の流れの繰り返しでインフラを構築しました。

  1. YAMLでCloudFormation スタックのテンプレートを作成する
  2. AWS CLIを使ってテンプレートをスタックに適用する

ちょっとづつ変更しては上記を繰り返しました。

環境の準備

まず、CLIでCloudformationスタックを操作するために AWS CLIを準備します。 AWS CLIのセットアップについては割愛します。

そして、エディタには Visual Studio Codeを使いました。 加えて次の拡張も使います。

vscode-cfn-lintを使うために、cfn-lintもインストールします。macOSだと、Brewでインストールできます。

$ brew install cfn-lint

vscode-cfn-lintによってとてもテンプレート作成が効率化されました。
テンプレートにエラーがあった場合にはスタックを更新しようとしたときに検出されロールバックされます。
が、このサイクルを繰り返すのは時間がかかって辛いです1。 設定項目の階層や名称、設定値の間違いの類ならvscode-cfn-lintでテンプレートの記述中にミスが指摘されるので、実際に適用する前に修正できます。
かなり効率が上がります。

cfn-lintの指摘を解消すれば、ほぼ記述ミスに由来するエラーはなくなります。 リソース間の矛盾のような論理的なエラーの修正に集中できます。

CloudFormationのとっつきにくさとはじめの一歩

CloudFormationのとっつきにくさの原因を私は次の様に思っています。

  1. ドキュメントが多すぎて、途方に暮れる
  2. インフラをまるっと作ろうとするとCloudFormation以前にインフラ設計の知識が必要
  3. VPCからインフラを構築していくとアプリケーションの動作を見れるまでの道のりが遠い
  4. よくわからないので既存のインフラを壊してしまわないかと怖い

1.についてですが、一旦ドキュメントを読んでよく理解してからなんて考えは捨てました。
CloudFormationはAWSの多くのサービスを扱えるのでドキュメントが膨大です。
全サービスを使うわけでもないと思うので全部読む必要なんて一切ありません。
必要な部分だけ読めばよく、この項目ってどんな意味だろうと調べるためのリファレンスだと割り切って接することにしました。

2.は既存のインフラリソースをコピーしてCloudFormationで作ってみることから始めてみました。

3.ですが、1つのWebアプリケーションを動かすためには意外と多くのリソースが必要になります。VPC、Subnet、SecurityGroup、EC2インスタンスetc。最初はそれらをまるっとCloudFormationで作れたらどんなに楽なんだろうかと想像して始めるのだろうと思います。
ですが、最初から気負って始めるとアプリケーションをデプロイしてその動きを確認できるところまでが遠くて挫折しがちです。
一部でもできるところから取り入れることにしました。

4.を克服するためには他のシステムが可動していない別リージョンや別VPCで始めて経験を積んでいけばいいのですがそうすると一部から始めるという戦術が取りにくいです。
それに練習のためであって本質的には必要のないものをCloudFormationの恩恵を感じる前に作ろうとするのはなかなかのストイックさが求められます。

これらを踏まえて、私がおすすめするCloudFormationのはじめの一歩はCloudWatch ダッシュボードを作ってみることです。

Cloudwatchのダッシュボードを作ってみる

AWSでシステムを運用しているとCloudwatch ダッシュボードでシステム状態を確認できると便利です。
同じようなAWSのリソース構成で複数のシステムを運用しているとCloudwatchのダッシュボードもやはり同じようなものを複数作っているのではないでしょうか。

CloudwatchのダッシュボードをWebコンソールで作っていくのは結構面倒なものです。
でも、CloudFormationを使えば既存のダッシュボードを複製してちょっと変更すれば楽できます。

Cloudwatch ダッシュボードをどう作ろうと運用しているサービスのインフラには何も影響しませんし、あれば役立ちます。
それに既存のダッシュボードがあればそれを複製するようなCloudFormationのテンプレートを簡単に作れます。

CloudFormationによるCloudwatch ダッシュボードの作成について以下に説明します。

テンプレートの作成

まず、既存のCloudwatch ダッシュボードの定義をコピーしてテンプレートを作ります。

サンプルとして次のようなEC2インスタンスのCPU使用率とネットワークトラフィックをグラフ化するダッシュボードがあるとします。

f:id:HeRo:20190610090632p:plain

このダッシュボードの定義を取得するには アクション>ダッシュボードの編集を選択します。

f:id:HeRo:20190610090658p:plain

次のようなダイアログが開きます。 その中にJSONで記述されたダッシュボードの定義が表示されるのでCopy Sourceボタンをクリックしてコピーします。

f:id:HeRo:20190610090715p:plain

コピーしたJSONをCloudFormationテンプレートで利用します。 そのテンプレートは次のようになります。

AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  EC2InstanceId:
    Description: 'EC2 Instance Id'
    Type: String

Resources:
  CloudWatchDashBoard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardName: EC2 Instance Metrics
      DashboardBody: !Sub |
        {
          "widgets": [
            {
              "type": "metric",
              "x": 0,
              "y": 0,
              "width": 24,
              "height": 6,
              "properties": {
                "view": "timeSeries",
                "stacked": false,
                "metrics": [
                    [ "AWS/EC2", "CPUUtilization", "InstanceId", "${EC2InstanceId}" ]
                ],
                "region": "ap-northeast-1",
                "title": "CPU Utilization"
              }
          },
          {
            "type": "metric",
            "x": 0,
            "y": 6,
            "width": 24,
            "height": 6,
            "properties": {
                "metrics": [
                    [ "AWS/EC2", "NetworkIn", "InstanceId", "${EC2InstanceId}", { "stat": "Average", "period": 60 } ],
                    [ ".", "NetworkOut", ".", ".", { "yAxis": "right", "period": 60 } ]
                ],
                "view": "timeSeries",
                "stacked": false,
                "region": "ap-northeast-1"
            }
          }
        ]
      }

Resources.CloudWatchDashBoard.Properties.DashboardBodyの値がダッシュボード定義でコピーしたJSONです。 ただし、EC2インスタンスのIDはParameters.EC2InstanceIdでパラメータ化して汎用性を高めています。 これを変えるだけで、別のEC2インスタンスについて同じダッシュボードを量産できます。
ちなみに!Subは変数を展開するためのCloudFormationの関数の省略形です。

シェルスクリプト

このテンプレートでスタックを作って更新していくのですが、AWS CLIへ渡す引数が少なくないので直接実行するのではなく、シェルスクリプトを用意したほうが便利です。

パラメータへの値のセットもシェルスクリプトからだとテンプレートの汎用性がより高まります。

上記テンプレートでCloudfrmationスタックを作成・更新するためのシェルスクリプトの例を次に示します。

#!/bin/bash

SCRIPT_DIR=$(cd $(dirname $(readlink $0 || echo $0));pwd)
CF_FILE_NAME="file://${SCRIPT_DIR}/cw-dashboard.yml"

CF_STACK_NAME='CloudwatchDashboard'
EC2_INSTANCE_ID=<YOUR EC2 INSTANCE ID>

case $1 in
  'create')
    CFN_SUB=create-stack;;
  'update')
    CFN_SUB=update-stack;;
  'changeset')
    CFN_SUB=create-change-set;; # より慎重にするにはこちら。
  * )
    echo 'Please set "create" or "update"'
    exit 1;;
esac

aws cloudformation $CFN_SUB \
--stack-name ${CF_STACK_NAME} \
--template-body ${CF_FILE_NAME} \
--parameters \
ParameterKey=EC2InstanceId,ParameterValue=${EC2_INSTANCE_ID} \
| jq .

上記シェルスクリプトは引数に応じて、AWS CLIのサブコマンドを切り替えます。 スタックが存在しないときには引数createを与えます。
スタックの更新時には引数updateを与えます。こjの場合、AWS CLIとしては aws cloudformation update-stack が実行されます。これによりスタックが更新され、変更が実リソースに反映されます。

チャンジセットがおすすめ

サービスに影響するようなCloudFormationスタックを変更する場合、無理な変更はロールバックされるとはいえ、いきなりリソース変更が適用される更新は勇気が必要です。 より慎重にするには上記シェルスクリプトに引数changesetを与えて aws cloudformation create-change-setを実行するほうが良いでしょう。 これはチェンジセットを作るサブコマンドです。 チェンジセットはスタックの変更箇所を適用せずに確認できる機能で、Dry Run のように使えます。 チェンジセットを作って変更箇所をWebコンソールでチェックしてから適用するというフローができます。

次のステップは?

簡単な例で説明しましたが、メリットを感じられたなら少々ハードルの高いことでもモチベーションを保てると思います。

既存のシステムと同様のシステムを構築する機会があるとチャンスです。 インフラ設計はすでにあるので、それらをどうCloudFormationで作っていくかに集中できます。 既存システムに加えるものは悪い影響を与えないか心配ですが、新規なら気にせず作業できます。 ぜひ、CloudFormationで構築してみましょう。 たとえ、CloudFormationに挫折してもWebコンソールでポチポチやるという逃げ道もあるのであまり気負わずに。

既存に参考になるリソースがある場合、AWS CLIでその設定内容を取得してみるといいと思います。例えばaws ec2 describe-instances などリソース情報を取得するCLIのレスポンスで示される属性はCloudFormationでのプロパティキーとほぼ一致しています。なので、どのキーにどのような値を設定すれば同じリソースが作れるのか参考になります。

このエントリの最初の方で一旦ドキュメントは置いておきましょうと述べましたが、プロパティやその設定値についてわからないことがあればドキュメントをそれらのキーワードで検索して調べてみれば良いと思います。 闇雲に膨大なドキュメントと格闘するより効率良いです。

また、次のドキュメントにはテンプレートのサンプルが示されているので、まずはコピー&ペーストで構築してもいいでしょう。

まとめ

今回、CloudFormationを使ってみて、良かったと思った点は次のとおりです。

  1. AWSのWebコンソールでポチポチ設定するより楽
  2. リソースの変更が記録されるようになった
  3. ノウハウを共有しやすい
  4. 試しに作ってみたリソースをCloudFormationスタック削除するだけで全消去できる

1.は業務で作るリソースである以上、リソースを作れば作り方や設定内容を何らか記録する必要があります。 ならば、そんなドキュメントを作るよりcloudformationテンプレートのほうがトータルで楽だと思います。 ドキュメントと実態のズレもほぼないですし2

2、3.はインフラのコード化の恩恵です。 テンプレートをコードレビューし合うようになったことで、他のメンバーが書いたテンプレートを見て学びやすくなりました。

3.は検証環境をつくるのが楽だということです。CloudFormationテンプレートを作りながら 試す場合もそうですし、機能やパフォーマンス検証のため本番と同じ構成、同じ設定のリソース群を一時的に作って すぐに捨てることができます。

最後に

アクトインディではインフラのコード化はまだ道半ばですが 一緒にやってみたいエンジニアを募集しています。


  1. 複数のリソースを作って行くときなどは途中まで作られたものがロールバックで作されます。それは結構時間がかかったりします。

  2. 適用していないテンプレートと実リソースには当然ですがズレが生じるのでご注意を。

AWS CloudWatch Logs に貯めこんだログをどうにかしようとしてハマった話

こんにちは!!こんにちは!!
インフラエンジニアのyamamotoです。

AWS CloudWatch Logs に貯めこんだログを、Kinesis Data Firehose を使って S3 に保管し、Athenaで検索しよう、と思ったらいろいろつまづいたのでまとめてみました。

きっかけ

当社の新プロジェクトで、ログをどげんかせんといかん、という話に。

ひとまず CloudWatch Logs に保存しておいて後でどうにかしようと思ったのですが、検索するにも保管するにも良くないので、S3に保管してAthenaで読めたらいいよねー、ということになりました。

しかし CloudWatch Logs のログを S3 に出そうとすると、手動での実行か、Lambdaでゴニョゴニョしないといけなさそうです。

もっとスマートに、逐次出力できないものか、と思って調べてみたところ、Kinesis Data Firehose を使ってストリーミングできるということが判明。
すごい。さすがはAWS。

ことの一部始終

Kinesis Data Firehoseのストリームを作成

まず、Kinesis Data Firehoseのストリームを作成します。
ここら辺は参考になるサイトがいっぱいあるので、そちらを見ながら設定。

CloudWatch Logsをサブスクリプション

CloudWatch Logsにはサブスクリプションという、外部出力の機能があります。
しかし、Kinesis Data Firehose に出力するにはコンソールからではできず、AWS APIで操作しなければなりません。
すごくないよAWS……

こちらのサイトを参考に登録しました。ありがとうございます。
https://christina04.hatenablog.com/entry/cloudwatch-logs-to-s3-via-firehose

ここまでは順調に進みましたが、この先でつまづきました。

S3に出力されるも……

ログがS3に出力されるようになりました。ただ、ログの内容がなんか変……

{"messageType": "DATA_MESSAGE","owner": "677754094491","logGroup": "/aws/elasticbeanstalk/hogehoge-rails","logStream": "production/rails/c6c65f81-1c88-4c02-8407-2e1d8bce69fa","subscriptionFilters": ["All"],"logEvents": [{"id": "34657843470841441528834034625987848118641882362345488384","timestamp": 1554111450640,"message": "{\"method\":\"GET\",\"path\":\"/hogehoge/products\",\"format\":\"json\",\"controller\":\"Hogehoge::ProductsController\",\"action\":\"show\",\"status\":200,\"duration\":1.41,\"view\":0.72,\"transaction_id\":\"bcfa33d20729b874d4f8\",\"params\":{},\"type\":\"RAILS\",\"host\":\"foobar.hogehoge.com\",\"remote_ip\":\"192.168.1.11\",\"ua\":\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36\",\"request_id\":\"d2c5fd8c-a207-4fe1-b030-e58439073ace\",\"session_id\":null,\"timestamp\":\"2019-05-21T08:03:50+00:00\",\"timestamp_jst\":\"2019-05-21T17:03:50+09:00\"}"}]}

見た感じ、生ログが Cloudwatch Logs のログで包まれ、複数のログも全部が1行にまとまっていました。
Athenaで読み込ませるには、1行に一つのログがある形式にしないといけないとのことなので、このログをバラす必要があります。

Kinesis Data Firehose の Transform 機能

そこで、Kinesis Data Firehose にあるTransform機能を使うことにしました。
一言で言うと、流れてきたログをLambdaで加工できる機能です。

これを使ってログをバラそう思い、流れてくるログをキャッチしてみると、なにやらbase64っぽい文字列が……
単純にbase64デコードしても、バイナリになっていて中が読めない。ううむ。

調べてみると、CloudWatch Logがサブスクリプションとして出力されるログは、gzip圧縮されてBase64エンコードされて送られてくるようです。な、なんと……

これを解凍してJSONで読み込み、生ログ部分だけ取り出すスクリプトをLambdaに用意しました。

const zlib = require('zlib');

exports.handler = (event, context, callback) => {
  const output = event.records.map((record) => {
    const buf = zlib.gunzipSync(Buffer.from(record.data, 'base64'));
    const cwlogs = buf.toString('utf-8');
    const cwlogsparsed = JSON.parse(cwlogs);
    let ret = '';

    for (let i = 0; i < cwlogsparsed.logEvents.length; i += 1) {
      ret += `${cwlogsparsed.logEvents[i].message}\n`;
    }

    return {
      recordId: record.recordId,
      result: 'Ok',
      data: Buffer.from(ret, 'utf8').toString('base64'),
    };
  });
  callback(null, { records: output });
};

肝心のログは「logEvents」配列の「message」フィールドに入っており、その他の部分はCloudWatch Logsの情報になっているようです。
「logEvents」配列を for で回して「messages」フィールドの文字列だけを取り出し、改行を付けています。

で、

{"method":"GET","path":"/hogehoge/products","format":"json","controller":"Hogehoge::ProductsController","action":"show","status":200,"duration":1.41,"view":0.72,"transaction_id":"bcfa33d20729b874d4f8","params":{},"type":"RAILS","host":"foobar.hogehoge.com","remote_ip":"192.168.1.11","ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36","request_id":"d2c5fd8c-a207-4fe1-b030-e58439073ace","session_id":null,"timestamp":"2019-05-21T08:03:50+00:00","timestamp_jst":"2019-05-21T17:03:50+09:00"}

こんな感じに生ログが一行に一つだけになりました。
この形式であればAthenaで読み込むことができます。

なお、Kinesis Data Firehoseのオプションでgzip圧縮して保存できるので、この段階では無圧縮で渡しています。

Kinesis Data Firehoseでのデータ保存

Athenaではパーティションという機能があるので、これを活用する形で Kinesis Data Firehose でも意識してデータを保存するようにしてみました。

Kinesis Data Firehoseのストリームの設定で、指定したS3のパスの下に /year=年/month=月/day=日/ のディレクトリを掘り、その中にログを出力するようにしています。
※Firehoseの仕様上、UTCで日付が振られます

S3 bucket: hogehoge
Prefix: logs/rails/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/ 
Error Prefix: logs/error/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/!{firehose:error-output-type}

この形式にしていると、Athenaでパーティションを作成するときに便利になります。

また、データはオプション設定でgzip圧縮して保存します。

Athenaの設定

Athenaでは、下記のSQLを使ってテーブルを作成しました。
RDBと違ってデータはS3にあってテーブルはいくらでも作り直せるので、いろいろ試行錯誤してみるのもいいかもしれません。

CREATE EXTERNAL TABLE IF NOT EXISTS hogehoge.rails (
  `type` string,
  `timestamp` string,
  `timestamp_jst` string,
  `method` string,
  `path` string,
  `controller` string,
  `action` string,
  `status` string,
  `duration` float,
  `view` float,
  `transaction_id` string,
  `request_id` string,
  `session_id` string,
  `params` string,
  `host` string,
  `remote_ip` string,
  `ua` string,
  `sql` string
)
PARTITIONED BY(
  year int,
  month int,
  day int
)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
WITH SERDEPROPERTIES (
  'ignore.malformed.json' = 'true'
) LOCATION 's3://hogehoge/logs/rails/'
TBLPROPERTIES ('has_encrypted_data'='false');

そして、パーティションをS3から読み込ませます。
テーブル名左の点メニューから「Load Partition」を選択します。すると、自動的に年月日ディレクトリを読み込みパーティションを作ります。

あとはAthenaでコマンドを実行してデータを取り出せばいい感じです。

ログの流れ

最終的に、下記のようなログの流れになりました。

CloudWatch Logs
 ↓ サブスクリプション
 ↓
 ↓ Logは、JSON形式でCloudWatch Logsの情報が付与されて、
 ↓ gzip圧縮されBase64で文字列化されて送られるので解凍
 ↓
 ↓ Kinesis Data Firehose にある Transform 機能(Lambda)を使い、
 ↓ CloudWatch Logsの情報をはがして純粋なログのみを1行ずつJSONで受け渡す
Kinesis Data Firehose
 ↓ 年月日ディレクトリにデータを保存
 ↓ gzip圧縮オン
 ↓
S3
 ↓
 ↓ 定義したテーブルに読み込まれる
 ↓
Athena

最後に

アクトインディでは、楽しくAWSのソリューションを活用して楽しいサービスを作るエンジニアを募集しています!

Github serviceをwebhookに変更した

morishitaです。

Github Service が使えなくなったので対応しました。

次の記事には2019年1月末に止めるよって書いてあるので、今月中になんとかすればいいかなと思っていました。
ところが、2019/01/08 から突然動かなくなったのです。

developer.github.com

最初は月末に廃止する作業でなにかミスがあったのだろう、じきに回復するだろうと思っていました。
しかしGitHub Statusを確認すると、「1/7 19:00 UTCから止め始めるよ」って書いて あるではないですか!

廃止のスケジュールが前倒されたの?こちらの勘違い?と思ったものの、どうせ今月中にはやらない行けない作業なのでこちらも予定を変更して対応しました。

なにに利用していたのか?

Github Serviceを何に使っていたのかというと、 以前、このブログでも紹介した いこレポの自動テストで利用していたのです。

tech.actindi.net

詳しくは上記の記事を見てほしいのですが、ざっくりなにをやっているかというと次の通りです。

  1. Github の PullRequest のブランチにPushする
  2. PushがAmazon SNSに通知される
  3. Amazon SNSがLambda関数をトリガーして、CodebuildによりRSpecを実行する
  4. RSpecの結果をGithubのPullRequestとSlackに通知する

このなかで、PushをAmazon SNSに通知されるという処理に利用していたのです。

移行方法の検討

移行方法を検討するにあたって、もともとの仕組みを少し調べました。

API的には、GithubServiceの設定内容も Webhook APIで取得できます。 なので、ちょっと設定をいじればできちゃうんじゃないかと思いましたが、どうやらその方法はなさそうでした。

で、Webhookに移行する方法として次の3案を考えました。

現行の処理の流れは次のとおりです。

Github Services => AWS SNS => Lambda => CodeBuild => 以下略。

これを次の3案のいずれかに移行しようと考えました。

  1. Github Webhook => AWS SNS => Lambda => CodeBuild
  2. Github Webhook => API Gateway => Lambda => AWS SNS => Lambda => CodeBuild
  3. Github Webhook => API Gateway => Lambda => CodeBuild

案1はAWS SNSにHTTPのエンドポイントがあれば、と思ったのですがどうやらトピックに直接パブリッシュできる エンドポイントはなさそうです。なので、却下。

案2はAWS SNSにパブリッシュする必要なHTTPエンドポイントをAPI Gateway+Lambdaで作ってしまうというもの。
SNS以降の処理に変更が不要なところが利点。

案3はAWS SNSの利用をやめ、CodeBuildをトリガーしているLambdaを変更して直接Webhookのリクエストを受けるようにするもの。
現行の仕組みを変更するけれど、仕組みは簡素になるという案。

いずれにせよLambdaでWebhookからのリクエストを受ける必要があるので、その部分をプロトタイピングしてどんなリクエストが来るのか調査しました。

調査した結果、AWS SNS経由で受け取っていたGithubのPullRequest情報と内容と形式が同じ1だったので、変更も軽微で済むと判断して、案3を採用しました2

Webhookへの移行

Webhookの設定

Webhookは次のように設定します。

項目 設定値
Payload URL API GateWayのURL
Content-type application/json
Secret 推測されにくい文字列を設定
SSL verification Enable SSL verification
Which events would you like to trigger this webhook? Let me select individual events. > PullRequest

説明が前後しますが、Payload URL には後述の Serverless Framework の設定を追加すればAPI Gatewayが登録されるので、そのエンドポイントURLを設定します。

プルリクに対する操作で実行したいので、送信するイベントはPullRequestだけで必要十分です。

Webhookからのリクエストの受けの変更

Webhookからのリクエストのヘッダやボディは Lambda関数にはハンドラーの引数 event オブジェクトに格納されて渡ってきます。 次のような感じです。

{
    "event": {
        "resource": "/test",
        "path": "/test",
        "httpMethod": "POST",
        "headers": {
            〜略〜
            "X-Hub-Signature": "sha1=<bodysecretでハッシュ化した値(後述)>" // 利用する
        },
        "multiValueHeaders": {
            〜略〜
        },
        "queryStringParameters": null,
        "multiValueQueryStringParameters": null,
        "pathParameters": null,
        "stageVariables": null,
        "requestContext": { 〜略〜 },
        "body": "〜後述〜",
        "isBase64Encoded": false
    }
}

で、リクエストボディは次の様な感じ。 実際にはJSON.stringify された文字列が入っているのでJSON.parseして読み込みます。

{
    "body": {
        "action": "labeled", // 利用する
        "number": <PullRequestの番号>, // 利用する
        "pull_request": { 
            "url": "https://api.github.com/repos/〜略〜",
            "id": 242573289,
            "node_id": "〜略〜",
            "html_url": "https://github.com/〜略〜", // 利用する
            "diff_url": "https://github.com/〜略〜",
            "patch_url": "https://github.com/〜略〜",
            "issue_url": "https://api.github.com/repos/〜略〜",
            "number": 883,
            "state": "open",
            "locked": false,
            "title": "<PullRequestのタイトル(略)>",     // 利用する
            "user": { <PullRequestのユーザ情報(略)> },
            "body": "<PullRequestの本文(略)>",
            "created_at": "2019-01-07T07:21:06Z",
            "updated_at": "2019-01-09T03:28:00Z",
            "closed_at": null,
            "merged_at": null,
            "merge_commit_sha": "〜略〜",
            "assignee": null,
            "assignees": [],
            "requested_reviewers": [],
            "requested_teams": [],
            "labels": [
                {
                    "id": 936758322,
                    "node_id": "〜略〜",
                    "url": "https://api.github.com/repos/〜略〜",
                    "name": "自動テスト対象", // 利用する
                    "color": "fbca04",
                    "default": false
                }
            ],
            "milestone": null,
            "commits_url": "https://api.github.com/repos/〜略〜",
            "review_comments_url": "https://api.github.com/〜略〜",
            "review_comment_url": "https://api.github.com/repos/〜略〜",
            "comments_url": "https://api.github.com/repos/〜略〜",
            "statuses_url": "https://api.github.com/repos/〜略〜",
            "head": {
                "label": "〜略〜",
                "ref": "<ブランチ名>", // 利用する
                "sha": "<コミットID>", // 利用する
                "user": { 〜略〜 },
                "repo": { 〜略〜 }
            },
            "base": { 〜略〜 },
            "_links": { 〜略〜 },
            "author_association": "CONTRIBUTOR",
            "merged": false,
            "mergeable": true,
            "rebaseable": true,
            "mergeable_state": "clean",
            "merged_by": null,
            "comments": 0,
            "review_comments": 0,
            "maintainer_can_modify": false,
            "commits": 5,
            "additions": 50,
            "deletions": 49,
            "changed_files": 1
        },
        "label": {
            "id": 936758322,
            "node_id": "〜略〜=",
            "url": "https://api.github.com/repos/〜略〜",
            "name": "自動テスト対象",
            "color": "fbca04",
            "default": false
        },
        "repository": { 〜略〜 },
        "organization": { 〜略〜 },
        "sender": { 〜略〜 }
    }
}

// 利用するとコメントした項目を処理の中で利用しています。

幸い、もともとの実装が、AWS SNSから渡されるオブジェクトからPullRequestの情報を取り出す部分を関数化していたので、event.bodyから取り出す様に変更するだけでほぼ対応できました。

API Gatewayの追加

ServerlessFrameworkを利用しているので次のようなイベント設定を追加するだけです。
簡単!

events:
    - http:
        path: pull_request
        method: post

デプロイするとエンドポイントが決まるので、そのURLをGithubのWebhookの設定の Payload URL に設定します。

不正リクエストの判別

これまで、どうしてGithub Serviceを使っていたのかというと、手軽だったということに尽きます。

いくらRSpecを実行するだけとはいえノーチェックのAPIエンドポイントをパブリックなインターネットに晒すのは避けたいなぁと思うと、簡易的にでも認証処理をしたり不正なリクエストを判別できないといけません。
Github Serviceだと、設定するだけでIAMによる認証付きでSNSのトピックにパブリッシュできたのです。

しかし、WebhookではIAMによる認証はできません。
そこで、WebhookのSecretを利用して、不正なリクエストを判別しています。

Github Webhookの設定画面で任意項目としてSecretという項目があります。

f:id:HeRo:20190115082518p:plain
Github Webhook

この Secret に適当な文字列を入力しておくとその文字列をキーとしてリクエストボディのHMAC値を計算してX-Hub-Signatureヘッダの値に入れてリクエストしてくれます。
リクエストを受けた際に、Lambda内部で再度リクエストボディのHMACを計算してX-Hub-Signatureヘッダの値と比較します。 するとリクエストの改ざんを検知できますし、Secretの値を知っているということで送信元の認証にもなります。

具体的には次のようなコードでチェックします。

/**
 * @param {String} secret Secretの値
 * @param {Object} body Webhookからのリクエストボディ
 */ 
function signRequestBody(secret, body) {
  return `sha1=${crypto.createHmac('sha1', secret).update(body, 'utf-8').digest('hex')}`;
}

/**
 * Github webhoookのリクエストが正しいことをチェックする。
 * X-Hub-Signature と リクエストホディのハッシュが一致することを確認する
 * @param {Event} event Lambdaのイベントオブジェクト
 * @return 一致するとき true。それ以外はfalse
 */
function isValidRequest(event) {
  const signature = event && event.headers && event.headers['X-Hub-Signature'];
  const secret = process.env.GITHUB_WEBHOOK_SECRET;
  const signedBody = signRequestBody(secret, event.body)
  return signature === signedBody
}

上記の isValidRequest関数を最初に実行し、true が帰ってきたときのみ処理するようにしました。

ということで、プルリクにpushすればテストが回る状況が回復しました3

参考

最後に

アクトインディではエンジニアを募集しています。


  1. SNS経由だとevent.Records[0].Sns.Messageに格納されていたものが、event.bodyに格納されていました。

  2. スッキリするのは案3だけど、予定前倒しで時間取れないし最初は既存部分に手を入れなくていい案2かなーと考えていました。

  3. 実際にはプルリクへのすべてのプッシュに対してテストを実行しても無駄な場合もあるので、「自動テスト対象」というラベルをつけている場合だけに実行するようにしています。

GAS活用事例紹介 いこーよ10周年企画を支えたアプリケーション

morishitaです。 この記事はactindi Advent Calendar 2018 - Adventarの3日目の記事です。

2018年12月1日、「いこーよ」は10周年を迎えました。

すでに終了したものも含めて10周年企画として次を実施しています。

お出かけスポット無料チケット配布以外のキャンペーンはまだまだ募集中なので奮ってご応募ください!プレゼントも用意しています!

さて、上記のうち、2つの企画でGASアプリケーションを活用しました。 このエントリではそれらについて紹介します。

tl;dr

  • 社内で使う管理ツールをGASで作りました
  • その中でS3へのファイルアップロードやVue.jsの利用を行いました
  • ちょっとした管理アプリケーションを実装するプラットフォームとしてGASの便利さを実感しました。

お出かけスポット無料チケット配布の管理画面

すでに企画自体は終了してしまっているのですが、 「お出かけスポット無料チケット配布」は全国135の施設様にご協力いただき入場料などの無料チケットを配布するというものでした。

ユーザの皆様に見ていただくWebサイトはNuxtで実装したSPAでした。 それについては次のエントリでで紹介しました。

tech.actindi.net

そこに掲載する施設様の情報を管理するための仕組みが必要だったのですが、それをGASで実装しました。

管理アプリケーションの要件は次の通り。

  • 無料チケットを提供してくださる施設様の約150件の情報を管理できること
  • 施設様の追加、情報の更新は任意のタイミングで可能なこと
  • 12月1日は施設様からの連絡があればすぐに配布を終了できること

管理すべき情報の総数が大きくないのと、できるだけ運用を手軽にするために社内で普段から活用しており、運用担当者も使い慣れているGoogle シートで管理できるようにしました。
その情報をJSONとしてS3にアップロードしてユーザのみなさんがアクセスするNuxtのSPAで参照するという仕組みにしました。

図で表すと次の様な感じです。

f:id:HeRo:20181201144529p:plain

特徴的な技術要素としては次の2つを含んでいます1

  • AWS S3へのファイルアップロード
  • カスタムメニュー

AWS S3へのファイルアップロード

GoogleシートにはAPIもあるので直接SPAから参照することも出来はします。
しかし、それほどレスポンスが速くないのと、APIの呼び出し回数の制限2に引っかかりそうだったのでJSONに変換したものをS3にアップロードしそれを参照させることにしました。

GASからS3にファイルアップロードする方法ですが、次のライブラリを使えば簡単です。

github.com

開発者の方のブログはこちら。 Amazon S3 API Binding for Google Apps Script | Engineering | Etc

この使い方を説明します。

まずはライブラリの追加します。
GASのスクリプトエディタの[リソース]-[ライブラリ]を選択すると次のダイアログが開きます。

f:id:HeRo:20181201131703p:plain

そのダイアログの「ライブラリを追加」に MB4837UymyETXyn8cv3fNXZc9ncYTrHL9 と入力して、ライブラリを追加します。 これだけです。

claspを使っている場合には application.json に次を追加してもいいかと思います。

{
  "timeZone": "Asia/Tokyo",
  "dependencies": {
    "libraries": [{
      "userSymbol": "S3",
      "libraryId": "1Qx-smYQLJ2B6ae7Pncbf_8QdFaNm0f-br4pbDg0DXsJ9mZJPdFcIEkw_",
      "version": "4"
    }]
  },
  "exceptionLogging": "STACKDRIVER"
}

後は次のようなコードでファイルをアップロードできます。 この例ではGASの実行環境の時刻serverTimeを記載したJSONファイルを指定バケットのdata/time.jsonに書き込む例です。

try {
    const s3 = S3.getInstance(AWS_ACCESS_KEY, AWS_SECRET_ACCESS_KEY);
    const uploadData = { serverTime: Utilities.formatDate(new Date(), 'GMT', "yyyy-MM-dd'T'HH:mm:ss'Z'") }
    s3.putObject(S3_BUCKET_NAME, 'data/time.json', uploadData);
} catch (error) {
    console.error('ファイルの更新に失敗しました');
}

アップロードされたファイルの中身はこんな感じです。

{"serverTime":"2018-12-02T23:00:33Z"}

簡単ですね。

簡単なのですが、MB4837UymyETXyn8cv3fNXZc9ncYTrHL9 というIDで参照できるものとGithubに上がっているコード同じものか?ということを確認するすべはおそらくありません。 (2019/08/11追記)私の無知でした。GASのスクリプトをライブラリとして一般公開するためにはそのGASプロジェクトの共有設定で公開する必要があります。Webエディタのライブラリ設定ダイアログを開くとライブラリ名がリンクになっています。そのリンクを開くとライブラリのバージョン一覧みたいなページが開きます。そのURLにGASプロジェクトのIDが含まれています。それさえわかればWebエディタが開けるのでライブラリとして公開されているソースも見ることができます。このS3ライブラリの場合、S3がソースとなります。(2019/08/11追記ここまで) 今回はS3バケット内はすべてパブリック公開しており、そのバケットへの操作だけを許可されたIAMアカウントを使っているので、仮に悪意あるライブラリがホストされており、アップロードするデータやIAMのキーが盗まれたとしても被害がその範囲に限定されているためそのまま利用しています。しかしセンシティブなデータを扱うケースでは自前でライブラリをホストし直して利用するほうがいいかもしれません。ご利用は自己責任でお願いします。

今回の「お出かけスポット無料チケット配布」では管理シート全体をJSONに変換してS3にアップロードしました。 それをNuxtのSPAから取得してコンテンツとして表示しています。

カスタムメニュー

さて、スプレッドシートの編集は任意のタイミングで行いますが、入力のたびにJSONファイルに反映しては編集途中の情報がユーザに見えてしまいます。編集後、アップロードのタイミングは運用担当者が決めたいということで、アップロードをトリガーする方法が必要となりました。

運用担当者は非エンジニアなので、「スクリプトエディタで関数を実行してください」というのは避けたいところです。
そこで選択するだけサクッとアップロード処理を実行できるように次のようなカスタムメニューを作りました。

f:id:HeRo:20181201132712p:plain

次のコードで上記のメニューを作成できます。

/**
 * スプレッドシートにメニューを追加する
 */
function addMenu(): void {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  ss.addMenu('アップロードメニュー', [
    {name: 'アップロード', functionName: 'uploadToS3'},
    {name: '最終更新時刻を確認する', functionName: 'showLastUploadTime'},
    {name: '設定を開く', functionName: 'openSettingDialog'},
  ]);
}

「アップロード」というメニューを選択するとuploadToS3という関数が実行されます。uploadToS3はスプレッドシートをJSONに変換してS3にアップロードする関数です。

ただ、addMenuという関数を作っただけではメニューは表示されません。 GASのスクリプトエディタの[編集]-[現在のプロジェクトのトリガー]を選択して、新規トリガーとして次の様なトリガーを追加します3

f:id:HeRo:20181201132322p:plain

これで、スプレッドシートを開くたびにaddMenu関数が実行されメニューが表示されるようになります。

笑顔写真の管理画面

もう1つ、笑顔写真大募集でもGASを活用した管理アプリケーションを作成しました。

笑顔写真はいこーよのスマホアプリからお子様の笑顔の写真を投稿してもらうという企画です。投稿された写真はいこーよ内の特設ページで公開されています。

管理アプリケーションの要件は次のとおりでした。

  • 投稿された写真を掲載前に確認し、掲載可否を判断できること
  • 掲載OKの写真はいこーよ内に掲載することができること
  • 一度掲載した写真も後から取り下げることができること

無邪気な子供の笑顔写真投稿に掲載可否判定の審査なんているの? と思われるかもしれませんが、投稿された写真におち○ちんがモロに写っている場合などを考慮しこの様な運用をしています4先日のTumblrのようになっても困るので。

上記の要件を満たすように作った仕組みは次のとおりです。

f:id:HeRo:20181203082924p:plain

スマホアプリから投稿された写真は、Firebaseのストレージに格納され、URLなどの情報がGoogleシートに書き込まれます。
今回作成したGASアプリケーションで掲載可否を判断し、 掲載OKとなった写真は、GASアプリ上の操作からいこーよの写真登録APIをコールします。その結果、前述の特設ページに掲載されます。 却下した写真は、それを記録します。
掲載後、やっぱり取り下げたい場合には掲載日や投稿時のメールアドレスなどから検索して取り下げることができます。

GASのWEBアプリケーションとして実装しそのUIは次の様になっています。

f:id:HeRo:20181201132906p:plain

このGASアプリケーションの特徴はVue.jsを利用していることです。

最初は仕組みを複雑にしたくなかったので、UI上のJSはバニラで実装して、辛いところだけちょこっとJQueryを使おうかなと思っていました。
しかし、いざやり始めると使い勝手よくするために思ったよりJSの量が増えてしまいました。また、普段Vueを使うことが多く、すっかりJQueryを体が受け付けなくなっていたのもありVueをGASを上で使ってみることにしました。

GAS上でのVue.js

いろいろ設定とか大変かなと思いましたが、トランスパイル不要な範囲だとGAS上でVue.jsを使うのはそれほど難しくはありません。

次の様にCDNから配信されているVueのライブラリをGASのHTMLテンプレートに追加してやるだけで使えるようになります。

<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>

ライブラリが読み込めれば、後は通常のVueアプリを作る要領で実装できます。

<div id="app">
    // アプリのUI
    /* 〜略〜 */
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
<script type="text/javascript">
    var app = new Vue({
      el: '#app',
      components: {
        'smile-photo': SmilePhoto,
        'photo-modal': PhotoModal,
      },
      data: {
        photos: JSON.parse("<?=uncheckedPhotos?>"),
      },
      computed: {
        photo_count() {
          return this.photos.length;
        }
      },
      methods: {
          /* 〜略〜 */
      }
    });
</script>

上記はGASのHtmlService.createTemplateFromFileに読み込ませるHTMLテンプレートの抜粋ですが、それを読み込むコードは次のとおりです。

function doGet(e: any): GoogleAppsScript.HTML.HtmlOutput {
  const template = HtmlService.createTemplateFromFile('index') as IExtendedTemplate;
  template.data = JSON.stringify(readSpreadsheet());
  const htmlOutput = template.evaluate();
  return htmlOutput;
}

この中で readSpreadsheetというスプレッドシートを読み出す関数をJSON文字列化してtemplate.uncheckedPhotosに代入しています。これはHTMLテンプレート内の次のコードで参照され、HTMLの一部5として展開されます。

data: {
    photos: JSON.parse("<?=data?>"),
},

こうしてスプレッドシートの内容をVueアプリケーションが保持するデータとして渡すことができます。

単一ファイルコンポーネントっぽいものの実装

さて、これでインタラクティブなUIが作りやすくなりましたが、実装量が増えてくると分割したくなります。 Vueではコンポーネントに分割していきますが、GAS上でもそれは変わりません。 VueではUIとロジックを1ファイルに書ける単一ファイルコンポーネントが便利ですが、今回はトランスパイル等しないので使えません。

でも、単一ファイルコンポーネントもどきなら実装できます。

笑顔写真を表示する次の部分をコンポーネントとして切り出しました。

f:id:HeRo:20181203083544p:plain

そのコードが次のとおりです6

<script type="text/template" id="smile-photo-tmplate">
  <div class="card" style="width: 245px;">
    <div class="card-image">
      <img :src="photo.photo_url" class="img-responsive img-fit-cover" :title="photo.uuid" :class="{done: isAccepted||isRejected}" @click="$emit('show-modal', photo)">
    </div>
    <div class="card-header">
      <div class="card-title">
        <span class="label label-success" v-if="photo.status === 'accepted'">掲載中</span>
        <span class="label label-error" v-else-if="photo.status === 'rejected'">掲載却下</span>
        <span class="label label-warning" v-else-if="photo.status === 'canceled'">掲載取下</span>
        <span class="label" v-else>未判断</span>
      </div>
      <div class="card-subtitle text-gray">
        <span v-if="photo.status === 'accepted'">掲載日:{{photo.published_at}}</span>
        <span v-else-if="photo.status === 'rejected'">却下日:{{photo.updated_at}}</span>
        <span v-else-if="photo.status === 'canceled'">取下日:{{photo.updated_at}}</span>
      </div>
    </div>
    <div class="card-body">
      <div class="container">
        <div class="columns">
          <div class="column col-6">
            <button class="btn btn-primary" @click="accept" :class="[{loading: isProcessing}, {'btn-success': isAccepted}]" :disabled="isProcessing||isAccepted">掲載する</button>
          </div>
          <div class="column col-6">
            <button class="btn" @click="reject" :class="[{loading: isProcessing}, {'btn-error': isRejected}]" :disabled="isProcessing||isRejected">掲載しない</button>
          </div>
        </div>
      </div>
    </div>
  </div>
</script>
<script type="text/javascript">
  var STATUS = {
    ACCEPTED: 'accepted',
    REJECTED: 'rejected',
    CANCELED: 'canceled',
  }
  var SmilePhoto = {
    template: '#smile-photo-tmplate',
    props: ['photo'],
    data: function() {
      return {
        isProcessing: false,
        modal: false
      }
    },
    computed: {
      isAccepted() {
        return this.photo.status === STATUS.ACCEPTED;
      },
      isRejected() {
        return this.photo.status === STATUS.REJECTED || this.photo.status === STATUS.CANCELED;
      }
    },
    methods: {
      accept(){
        this.isProcessing = true;
        google.script.run
          .withSuccessHandler(this._onSuccess.bind(this))
          .withFailureHandler(this._onFailure.bind(this))
          .accept(this.photo.uuid)
      },
      reject(){
        this.isProcessing = true;
        google.script.run
          .withSuccessHandler(this._onSuccess.bind(this))
          .withFailureHandler(this._onFailure.bind(this))
          .reject(this.photo.uuid)
      },
      _onSuccess(updatedPhoto) {
        Object.assign(this.photo, updatedPhoto);
        this.isProcessing = false;
        console.log({accept: 'success',uuid: this.photo.uuid, iko_yo_id: this.photo.iko_yo_id})
      },
      _onFailure() {
        this.isProcessing = false;
        console.log({accept: 'failure',uuid: this.photo.uuid})
      }
    }
  };
</script>

UIテンプレートは <script type="text/template">の中に書き込み、Vueコンポーネントから参照しています。
これを components/smile-photo.html というファイルに保存して、使いたいGASのHTMLテンプレート内で次の様に読み込みます。

<?!= HtmlService.createHtmlOutputFromFile('components/smile-photo').getContent(); ?>

HtmlService.createHtmlOutputFromFileはGASのユーティリティー関数で指定したファイルを読み込んでHTMLとして挿入してくれます。

後は、コンポーネントとしてVueアプリにSmilePhotoを登録すれば使えます。

まとめ

キャンペーンなど一時的な機能の追加が必要で、しかもそのためのデータの登録、更新が必要であれば、何らかの管理機能が必要になります。 でも、どうせ使い捨て機能だしプロダクト本体に実装するのは避けたい。

という場合の1つのソリューションとして、GoogleシートとGASを組み合わせて実現した実例をご紹介しました。

エンドユーザからのアクセスがあるような箇所で適用するにはデータをS3等にアップロードし、それをアプリケーションから利用することで速度やAPIの制限を超えることができました。

大量のデータを扱う必要のあるケースでは処理が遅くて無理かもしれません。
しかし、今回の笑顔写真大募集の場合であれば、約3000枚の写真を表示し処理してみましたが、問題なく使えました。

管理画面の実装だと操作できるユーザの認証も必要となりますが、Googleアカウントで共有する形なので、自前で実装する必要がありません。

そしてサーバレスで運用できます。

とても便利です。

最後に

アクトインディでは、長くメンテナンスするプロダクトでは運用性やメンテナンス性を検討しながら開発を進めます。が、今回のような使い捨てな場合にはこれまでのやり方にとらわれず場合、場合に最適なやり方で開発を進めます。

そんな環境で一緒に開発したいエンジニアを募集しています。

actindi.net


  1. アーキテクチャとして Command :スプレッドシートの更新Query:JSONファイルへのアクセスとするCQRSを採用しているとこっそり主張します。

  2. https://developers.google.com/sheets/api/limits

  3. UIデザインがガラッと変わっており驚きました。最近GAS周りの変更が盛んですね。

  4. 実際、何件かは掲載をお断りしました。微笑ましい写真だったのですが。

  5. 正確にはHTMLの中のJSの一部です。

  6. HTML部分にはSpectre.css CSS Frameworkを利用しています。

NuxtのSPAを S3+CloudFrontでホストする。デプロイはCodeBuildで自動化


≪2019/06/20追記≫
この記事を公開したのは2018-10-19です。
2019-06-20時点でより簡単さ、運用の容易さを求めるならAWS Amplify Consoleもおすすめです。

tech.actindi.net


morishitaです。

Nuxt で実装した SPA を S3 + CloudFront で配信する機会があったのでそれを書きます。

NuxtのSPA自体については、標準的な作りでTypescript、PugSassを使ってますよってことぐらいしか書くことがないのですっ飛ばして、 S3とCloudFrontでどのようにSPAをホストしたのか、そしてCodeBuildを使ったデプロイの自動化について書きます。

概要は次のとおりです。

  • NuxtのSPAをホストするための S3とCloudFrontを設定する
  • CodeBuildを使ってS3にデプロイする
    • GIthubリポジトリのmasterにpushしたら動く
    • Gulpを利用して、古いファイルの削除やCloudFrontのキャッシュ無効化も実施。

f:id:HeRo:20181018063222j:plain

ちなみに、主なライブラリ等のバージョンは次のとおりです。

以下、nuxt.actindi.net というドメインで配信するとして説明します。
nuxt.actindi.netは例であって実際には存在しません。念のため。

S3 の設定

バケット nuxt.actindi.netを作成します。 バケット内のディレクトリ構成は次のとおりです。

nuxt.actindi.net   # S3バケット
  ├ app/                # Nuxt SPAをデプロイするディレクトリ
  └ data/               # Nuxt で読み込むJSONファイルを置くディレクトリ

app ディレクトリには nuxt build の生成物をデプロイします。
data ディレクトリには JSON ファイルを置きます。このファイルを Nuxt SPA が動的に読み込んで表示します。 このJSON ファイルは別のアプリケーションが任意のタイミングで更新します1

このバケットには公開してよいリソースのみ配置するので Static website hostingを有効にして次の項目も設定します。

設定項目 設定値
インデックスドキュメント index.html
エラードキュメント /app/index.html(※後述)

次のバケットポリシーも設定しておきます。
これでこのバケットに置いたファイルへのパブリックアクセスを設定しています。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::nuxt.actindi.net/*"]
    }
  ]
}

そして、data ディレクトリのJSONファイルはXHRで取得するのでCORSの設定もしておきます。

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>*</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
    <MaxAgeSeconds>3000</MaxAgeSeconds>
    <AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>

CloudFront の設定

HTTPS にするために CloudFront を利用します。

AWSコンソールのタブごとに設定内容を説明します。

General

目的が HTTPS 化なので、SSL Certificate を設定します。
ここでは nuxt.actindi.net で配信したいので Alternate Domain Namesnuxt.actindi.net を設定します。
SSL Certificate には ACM で作った証明書を設定します。

Origin

キャッシュの有効期限を分けたいのでOrigin は 2 つ登録します。

1 つ目は次の設定。

オリジン(1)
Origin Domain Name S3 バケットの公開ドメイン
Origin Path / (表示上は空)

もう 1 つは次の設定。

オリジン(2)
Origin Domain Name S3 バケットの公開ドメイン
Origin Path /app

Behaviors

Behaviors は上記で作ったオリジンそれぞれに作ります。

まずは、オリジン(1)/に対する設定。

設定項目 設定値
Path Pattern /data/*
Origin オリジン(1)/
Whitelist Headers Origin (CORSのため)
Object Caching Customizeを選択し、TTLはすべて0にする

dataディレクトリ以下のファイルの更新を即時反映するため、キャッシュさせないようにTTLをすべて0にするのがポイントです。

続いて、オリジン(2)/appに対する設定。

設定項目 設定値
Path Pattern Default(*)
Origin オリジン(2)/app
Object Caching Use Origin Cache Headers

こちらはSPAを構成するファイル用なのでキャッシュさせます。
後述しますがデプロイ時に古いキャッシュを無効化するので、デプロイしても反映されないということは起こりません。

Error Pages

次の様に404エラーが発生した場合、index.htmlを返すようにします。重要です。

設定項目 設定値
HTTP Error Code 404: Not Found
Customize Error Response Yes
Response Page Path /index.html
HTTP Response Code 200: OK

なぜこの設定が必要かというと、NuxtではPageshoge.vueのような固定ルーティングのページを作るとnuxt buildしたときに実体として/hoge/index.htmlファイルが生成されます。

しかし、_id.vueのような動的ルーティングするページを作った場合、それに対応するファイルは生成されません2

その状態で /some_dir/10の様なパスにアクセスするとCloudFrontはS3にファイル/some_dir/10を探しに行きます。開発者の意図としては/some_dir/_id.vueで処理したいのですが、対応するファイルが無いので404エラーとなっていまいます。
Nuxtではindex.htmlでリクエストを受けないと何も処理できないのでS3上に存在しないパスへのリクエストにはとにかく index.htmlを返すようにします。そのため上記のError Pages設定を行います。(その上でNuxtの動的ルーティング上でも存在しなければNuxt上で404ページを表示します)。

Lambda Edgeで処理する方法もあるのですが、少々大げさな気がしますしLambdaのコストがかかるのでこうしました。

S3の設定でもエラードキュメントを設定しましたが、あの設定は念のためなので上記の様にError Pagesを設定すればS3のエラードキュメントの設定はたぶん不要です。

Nuxtの設定

デプロイする先が用意できたので、デプロイするものを作るための設定です。

ポイントとなるのはnuxt.config.jsの次の箇所です。

let generateDir = {};
if (process.env.DEPLOY_ENV === 'S3'){
  generateDir = {
    generate: { dir: "dist/app" },
  }
}

module.exports = {
  mode: 'spa',
  ...generateDir,
  // 〜中略〜
}

何をやっているのかというと、DEPLOY_ENVという環境変数にS3という値が設定されていれば、dist/appディレクトリにビルド生成物を出力するという設定です(デフォルトでは distディレクトリに出力されます)。
これにより、appディレクトリを丸ごとS3にアップすれば良くなります。

また、modeを 'spa'に設定しています。

CodeBuildでのデプロイ

デプロイ先とデプロイするものが揃ったので、デプロイ処理を作ります。
デプロイにはCodeBuildを使います。

CodeBuildのプロジェクトは次のように設定して作成します。

設定項目 設定値
ソースプロバイダ GithubのNuxtのSPAのリポジトリを設定
Webhook 有効にしてブランチフィルタに ^master$を設定
環境イメージ aws/codebuild/nodejs:10.1.0
インスタンスタイプ 3 GB メモリ、2 vCPU(最低スペックで十分)
Buildspec buildspec.yml

Webhookを有効にすると、GithubのリポジトリにWebhookを作ってくれます。 ただ、この時作られるWebhookには、Pull requestsの操作時もトリガーするように設定されてしまいます。
不要で邪魔なのでGithubのWebhook設定画面で Pull requestsのチェックを外して、Pushesのみにします。
ブランチフィルタを設定しているので masterにpushまたはRPをmasterにマージしたタイミングでCodeBuildが実行されるようになります。
アーティファクトは出力しません。

環境変数には次の4つの値を設定します。

環境変数名 設定値
AWS_BUCKET_NAME nuxt.actindi.net
AWS_CLOUDFRONT 作成したCloudFrontのディストリビューションID
API_URL_BROWSER https://nuxt.actindi.net/data
DEPLOY_ENV S3

上2つは あとで説明するGulpで参照するものです。
API_URL_BROWSERはNuxtに組み込まれているaxios-moduleの設定で、axiosのリクエストパスのプリフィックスを上書きするものです3

DEPLOY_ENVは前述したビルド生成物の出力先を変更するフラグですね。

で、CodeBuildプロジェクトで実行する処理を記述した buildspec.ymlは次のとおりです。

version: 0.2

phases:
  install:
    commands:
      - apt-get update -qq && apt-get install -y apt-transport-https
      - curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
      - echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
      - sudo apt-get update && sudo apt-get install yarn
  pre_build:
    commands:
      - echo 'Start pre_build phase'
      - yarn
  build:
    commands:
      - echo 'Start build phase'
      - nuxt build
  post_build:
    commands:
      - echo 'Start post_build phase'
      - ./node_modules/.bin/gulp deploy

特に難しいことはしていません。

install フェーズ

残念ながら、CodeBuild標準で用意されているNode.js環境にはyarnがインストールされていないので インストールしています。

pre_buildフェーズ

yarnを使ってモジュールをインストールしているだけです。

buildフェーズ

nuxt buildを実行してアプリケーションをSPAとしてビルドしています。

post_build フェーズ

生成物をS3にアップロードしているのですが、そのためにGulpを使っています。

Gulpの設定

CodeBuildからS3にビルド生成物をアップロードするために次の gulpfile.js を使いました。

var gulp = require('gulp');
var awspublish = require('gulp-awspublish');
var cloudfront = require('gulp-cloudfront-invalidate-aws-publish');
var parallelize = require('concurrent-transform');

// https://docs.aws.amazon.com/cli/latest/userguide/cli-environment.html

var config = {

  // Required
  params: { Bucket: process.env.AWS_BUCKET_NAME },

  // Optional
  deleteOldVersions: true,
  originPath: '/app',       // ポイント①
  distribution: process.env.AWS_CLOUDFRONT, // Cloudfront distribution ID
  region: process.env.AWS_DEFAULT_REGION,
  headers: { /*'Cache-Control': 'max-age=315360000, no-transform, public',*/ },

  // Sensible Defaults - gitignore these Files and Dirs
  distDir: 'dist',              // ポイント②
  indexRootPath: true,
  cacheFileName: '.awspublish',
  concurrentUploads: 10,
  wait: true,  // wait for Cloudfront invalidation to complete (about 30-60 seconds)
}

gulp.task('deploy', function() {
  // create a new publisher using S3 options
  // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property
  var publisher = awspublish.create(config, config);

  var g = gulp.src('./' + config.distDir + '/**');
    // publisher will add Content-Length, Content-Type and headers specified above
    // If not specified it will set x-amz-acl to public-read by default
  g = g.pipe(parallelize(publisher.publish(config.headers), config.concurrentUploads))

  // Invalidate CDN
  if (config.distribution) {
    console.log('Configured with Cloudfront distribution');
    g = g.pipe(cloudfront(config));
  } else {
    console.log('No Cloudfront distribution configured - skipping CDN invalidation');
  }

  // Delete removed files
  // ポイント③
  if (config.deleteOldVersions) g = g.pipe(publisher.sync(config.originPath));  
  // create a cache file to speed up consecutive uploads
  g = g.pipe(publisher.cache());
  // print upload updates to console
  g = g.pipe(awspublish.reporter());
  return g;
});

このgulpfile.js は実はNuxtの公式ドキュメントの次のページにあるサンプルほぼそのままです。

https://nuxtjs.org/faq/deployment-aws-s3-cloudfrontnuxtjs.org

このgulpfile.jsは次の2つのライブラリを使ってS3へのアップロードとCloudFrontのキャッシュ無効化を行っています。

  • gulp-awspublish:S3にファイル/ディレクトリをアップロードしてくれる。古いファイルの削除も可能で大変便利。
  • gulp-cloudfront-invalidate-aws-publish:更新されて古くなったファイルのCloudFrontキャッシュを無効にしてくれます。大変便利。

ただ、Nuxtの公式ドキュメントの例そのままでは困るところがあり、いくつか手を入れています。

順にポイントを説明します。

ポイント①

deleteOldVersions: trueにすることで、S3上の古いファイルを削除してくれます。 しかし、バケット全体を対象にするので、そのままだと /dataディレクトリまで削除されてしまいます。 それは困るので originPath: '/app' で影響範囲を /appディレクトリ配下のみに限定しています。

ポイント②

distDir: 'dist'ディレクトリ以下をまるごとS3にアップしてくれます。 nuxt buildの生成物は /dist/appディレクトリに出力されるので、S3には/appディレクトリごとアップされます。

ポイント③

publisher.sync()は、古くなったS3上のファイルを削除してくれる関数ですが、 もともとのコードではpublisher.sync()の様に引数無しで使われています。 ポイント①で設定したoriginPathを渡すことにより、/appディレクトリとその中身のみ削除の対象とするようになります。

ポイント④

ポイント④はコードから削除した部分です。 Nuxtの公式ドキュメントにも書いてありますが、もともとのコードはAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYを環境変数で渡すように書かれており、 次のコードが記載されています。

var config = {
  // 〜略〜
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  // 〜略〜
}

CodeBuildを実行するロールに次の権限を与えておけば、これらの値をわざわざ設定しなくても S3とCloudFrontへの操作が可能になります。なので不要となり削除しました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObjectAcl",
                "s3:GetObject",
                "s3:AbortMultipartUpload",
                "s3:ListBucket",
                "s3:DeleteObject",
                "s3:PutObjectAcl",
                "s3:ListMultipartUploadParts"
            ],
            "Resource": [
                "arn:aws:s3:::nuxt.actindi.net",
                "arn:aws:s3:::nuxt.actindi.net/*"
            ]
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "cloudfront:ListInvalidations",
                "cloudfront:GetInvalidation",
                "cloudfront:CreateInvalidation"
            ],
            "Resource": "*"
        }
    ]
}

他のCIの仕組みを使うと AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY という秘密情報を渡さないといけなくなり その管理どうしようと考えないといけなくなるのですが、CodeBuildではそれが不要です。便利ですね。

これですべての設定の説明は終わりです。
冒頭の図を再掲しますが、次のような流れが実現できます。

f:id:HeRo:20181018063222j:plain

まとめ

  • Nuxtで実装したSAPをS3+CloudFrontでホストしました。
  • 動的ルーティングを持つSPAも運用可能です。
  • CodeBuildで自動デプロイも楽々。

最後に

アクトインディは今のところRailsメインの会社ですが、Railsだけではありません。
あれこれ一緒に試してみたいエンジニアを募集しています。

actindi.net


  1. APIを作っても良かったのですが、今回の案件ではデータ量も多くないし、更新頻度も多くないので簡易に済ませました。

  2. nuxt generate でも_id.vueは無視され何も生成されません。

  3. https://github.com/nuxt-community/axios-module/blob/dev/docs/options.md#browserbaseurl

VirtualAlexaによるテストドリブンなAlexaスキル開発

morishitaです。

「いこーよのおでかけナビ」の開発において、最も役立ったライブラリVirtualAlexaについて紹介したいと思います。

いこーよのおでかけナビ

いこーよのおでかけナビ

  • 発売日: 2019/08/27
  • メディア: アプリ

開発環境

このエントリでは次の環境を前提とします。

  • Node.js v8.10.0
  • Typescript 2.9以上 (3.0.0以上でも問題ないです)

Alexaスキル実装の辛み

Alexaスキルの開発コンソールにはAlexaシミュレータというツールが含まれていて、実機に近いE2Eテストが可能となっています。 Alexaシミュレータを利用したテスト/デバッグの開発では次のサイクルを繰り返すことになります。

f:id:HeRo:20180902154051p:plain

便利は便利なのですが、繰り返すと次が辛くなってきます。

  • Lambdaのビルド+デプロイに時間がかかる
  • 実行後の確認が面倒
    • どこでエラーが発生したのかわかりにくい
    • Clowd Watch Logsでデバッグログを探すのが…
  • UIの操作も面倒
  • デバッガも使えないし…

ということで、ローカルで動かしてテストしたいなーと思い始めます。

最初に考えるのは次のようにテストしやすいLambdaのモジュール分割を行い、 LambdaやAlexaに依存しないビジネスロジックだけでもローカルでじっくりテストしようということです。

f:id:HeRo:20180831093054p:plain

ただ、このやり方だと、テストの範囲が部分的になりますし、ビジネスロジック以外のところにバグがあれば 前述のAlexaシミュレータでのサイクルに戻ります。

やはり、ローカルでリクエストハンドラを実行したいと思い始めます。 Lambdaをローカルで動作させる方法はあるのですが、多いのがAPI GatewayのLambdaをローカルでテスト する方法で、実際にはローカルで動くAPI Gatewayの模擬環境にHTTPリクエストを送って動かすというものです。

そのために、API Gatewayを設定するのもなー、やったとしてもAlexaが送ってくるJSONを組み立てるのは 自前でやらないといけないしなー、面倒だなーと思っていた私を救ってくれたのは VirtualAlexaでした。

VirtualAlexa ってなに?

f:id:HeRo:20180831083018p:plain

VirtualAlexaBespoken社によって開発されている AlexaスキルのバックエンドとなるLambda関数をローカルで動かしてテストするためのライブラリです。 これを使えば難なくローカルで実行しテストできます。

次のような特徴があります。

  • ユーザの発話やインテントを指定してリクエストハンドラを動かすことができる。
    • つまり、Alexaから送られてくるJSONを作ってくれる
  • リクエストをLambdaに渡す前に書き換えられるフィルタが便利
    • 直前の対話の状態に依存したテストも書きやすい
  • DynamoDBやAddressAPIに依存したテストも可能
    • モックが含まれている
  • Display Interfaceもサポート
    • Echo Spot向けのレスポンスもテストできる

インストール

ここからはテスティングフレームワークJestとの組み合わせでVirtualAlexaを利用する方法を説明します。

必要なモジュールを次のコマンドでモジュールのインストールを行います。

$ npm install -D typescript jest ts-jest virtual-alexa @types/jest @types/node ask-cli

Typescriptの設定は次の通り。

{
  "compilerOptions": {
    "sourceMap": true,
    "target": "es6",
    "lib": [
      "esnext"
    ],
    "moduleResolution": "node"
  },
  "exclude": [
    "node_modules"
  ]
}

そして、Jestの設定は次の通り。Typescript のトランスパイルはts-jestで行います。

{
  "globals": {
    "ts-jest": { "tsConfigFile": "./tsconfig.json" }
  },
  "transform": {
    "^.+\\.tsx?$": "ts-jest"
  },
  "testEnvironment": "node",
  "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
  "moduleFileExtensions": [
    "ts",
    "tsx",
    "js",
    "jsx",
    "json"
  ]
}

全体のファイル構成はこんな感じ。

.
├── /__tests__          # テストコードのディレクトリ
├── index.ts            # lambdaのハンドラーを実装している
├── /interaction_models # 対話モデルを保存する。後述。
├── jest.json 
├── /node_modules
├── package-lock.json
├── package.json
├── /src                # ソース
└── tsconfig.json

テスト対象のサンプルスキル

テスト対象として次のような簡単なやり取りを行うスキルを考えます。

f:id:HeRo:20180901233854p:plain
スキルの会話

FeelingIntent

「今日の気分は絶好調」というフレーズは次の様に定義した FeelingIntentで受けます。

f:id:HeRo:20180901234107p:plain
FeelintIntent

このインテントで使っている feeling スロットはカスタムスロットで次の様に定義しています。 ポイントはIDを設定しているところ。このIDはリクエストハンドラで取得できます。

f:id:HeRo:20180901234713p:plain

テスト対象コード

上記のスキルのバックエンド側のコードを説明します。

まずは、このスキルのエンドポイントとなるLambdaのハンドラー関数index.ts。 後述するFeelingIntentHandlerと、標準インテントのリクエストハンドラを設定しています。

import { HandlerInput, SkillBuilders } from "ask-sdk";
import { Handler } from "aws-lambda";

import CancelAndStopIntentHandler from "./src/handlers/CancelAndStopIntentHandler";
import ErrorHandler from "./src/handlers/ErrorHandler";
import FeelingIntentHandler from "./src/handlers/FeelingIntentHandler";
import HelpIntentHandler from "./src/handlers/HelpIntentHandler";
import LaunchRequestHandler from "./src/handlers/LaunchRequestHandler";

export const handler: Handler = SkillBuilders.standard()
    .addRequestHandlers(
      LaunchRequestHandler,
      FeelingIntentHandler,
      HelpIntentHandler,
      CancelAndStopIntentHandler,
    )
    .addErrorHandlers(ErrorHandler)
    // .withTableName(process.env.DYNAMODB_TABLE) // uncomment if you use dynamodb
    // .withAutoCreateTable(true)
    .lambda();

そしてテスト対象のFeelingIntentHandlerのコードは次の通りです。 注目ポイントはカスタムスロットのIDで処理を分岐させているところです。

import { HandlerInput, RequestHandler } from "ask-sdk";
import { IntentRequest, Response, Slot } from "ask-sdk-model";
import * as Speech from "ssml-builder";

const FeelingIntentHandler: RequestHandler = {
  canHandle(handlerInput: HandlerInput): boolean {
    return handlerInput.requestEnvelope.request.type === "IntentRequest"
        && handlerInput.requestEnvelope.request.intent.name === "FeelingIntent";
  },
  handle(handlerInput: HandlerInput): Response {
    const request = handlerInput.requestEnvelope.request as IntentRequest;
    const slot = request.intent.slots && request.intent.slots.feeling;
    let speech: string;
    const reprompt: string = "今日の気分を教えてください";
    if (slot.value) {
      const feelingId = slot.resolutions.resolutionsPerAuthority[0].values[0] &&
        slot.resolutions.resolutionsPerAuthority[0].values[0].value.id;
      switch (feelingId) {
        case "0":
          speech = "まあ、元気だして。くよくよせずに行きましょう。";
          break;
        case "5":
          speech = "いつもどおりで行きましょう。";
          break;
        case "8":
          speech = "そんなときは思い切って行動しましょう。";
          break;
        case "10":
          speech = "素晴らしい!張り切っていきましょう!";
          break;
        default:
          speech = reprompt;
          break;
      }
    } else {
      speech = "そんな日もありますよね";
    }

    return handlerInput.responseBuilder
      .speak(speech)
      .reprompt(reprompt)
      .getResponse();
  },
};

export default FeelingIntentHandler;

Jest + VirtualAlexa によるテストコード

そして、本題のテストコードは次の通り。

import {SkillResponse, VirtualAlexa} from "virtual-alexa";
import { handler } from "../../index";

// ポイント(1) VirtualAlexaのインスタンス生成
const alexa = VirtualAlexa.Builder()
  .handler(handler) // Lambdaハンドラを指定
  .interactionModelFile("./interaction_models/model_ja-JP.json") // 対話モデルを指定
  .create();

describe("FeelingIntent", () => {
  it ("よくわかんない", async () => {
    // ポイント(2) スロットのないサンプル発話のテスト
    const response = await alexa.utter("よくわかんない") as SkillResponse;
    expect(response.prompt()).toContain("そんな日もありますよね");
  });

  it ("気分は絶好調", async () => {
    // ポイント(3) スロットを含むサンプル発話のテスト
    const response = await alexa.intend("FeelingIntent", { feeling: "絶好調"}) as SkillResponse;
    expect(response.prompt()).toContain("素晴らしい!");
  });

  it ("気分は最悪", async () => {
    // ポイント(4) スロットのシノニムによるテスト
    const response = await alexa.intend("FeelingIntent", { feeling: "最悪"}) as SkillResponse;
    expect(response.prompt()).toContain("元気だして。");
  });

  it ("気分はイマイチ", async () => {
    // ポイント(5) カスタムスロットにない値のテスト
    const response = await alexa.intend("FeelingIntent", { feeling: "イマイチ"}) as SkillResponse;
    expect(response.prompt()).toContain("今日の気分を教えてください");
  });
});

ポイント(1)VirtualAlexaのインスタンス生成

インスタンス生成時に、テスト対象のスキルのLambda関数と対話モデルのJSONを読み込ませています。 この対話モデルのJSONは ask-cliの api get-modelコマンドで取得できるJSONをそのままファイルに保存したものです。

ポイント(2)スロットのないサンプル発話のテスト

まずはスロットを含まないフレーズのテスト。この場合は VirtualAlexa#utterメソッドでテストできます。 response にはAlexaに送るレスポンスのJSONがそのまま入ってくるので、 そこからresponse.prompt()outputSpeechなどを取り出して内容をチェックすることでテストできます。

ポイント(3)スロットを含むサンプル発話のテスト

次に、スロットを含む発話のテスト。流石にフレーズそのままを解釈してくれはしないので、 VirtualAlexa#intendメソッドにインテント名とスロットの値を設定してテストします。 responseからoutputSpeechを取り出して期待するセリフが入っているかを確認します。 ここで注目してほしいのは、テスト対象の FeelingIntentHandlerでは "絶好調"という スロットの値を取り出すのではなく、スロットの値に設定されたIDを取り出して処理を分岐させていたというところ。

一方、テストではIDは渡さず、スロットの値として"絶好調"という文字列を渡しています。 インスタンス生成時に対話モデルを読み込んでいるので、VirtualAlexaがスロットとIDの対応がわかっておりテストが可能になっています。

ポイント(4)スロットのシノニムによるテスト

スロットの値としてシノニムの文字列を与えた場合もちゃんとテストできます。 これも、VirtualAlexaが対話モデルを元にリクエストを生成してくれているからテストできるのだとわかります。

ポイント(5)カスタムスロットにない値のテスト

カスタムスロットに定義していない言葉をスロット値として渡すと、当然ながらIDとの対応が取れずもう一度聞き直す流れとなりますが、それもテストできます。

その他

filter

1つ前のやり取りを受けて、返答を変えるなど、ステートフルな実装が必要になるケースがあります。 例えば、セッション属性の値に応じて応答を変更するには、VirtualALexa#intentなどリクエストを発生させるメソッドを実行する前にセッション属性の値を設定したくなります。 そのようなテストも filterメソッドで設定すれば可能となります。

virtual-alexaのインスタンスを生成した後、filterメソッドにリクエストを書き換えるコールバック関数を渡します。 コールバック関数にはrequestEnvelopeに相当するオブジェクトが渡されるので、 その中でセッション属性を設定すれば、セッション属性に応じたハンドラの動作をテストできます。

次の様に、beforeEachなどでfilterメソッドを呼びます。

describe("SomeHandler", () => {
  let alexa;

  beforeEach(() => {
    const alexa = VirtualAlexa.Builder()
      .locale("ja-JP")
      .handler(handler)
      .interactionModelFile(modelJson)
      .applicationID(process.env.APP_ID)
      .create();
    alexa.filter((requestEnvelope) => {  // <= filter
      requestEnvelope.session.attributes = { someAttributes: "value" }  
    });
  });
  it ("doSomething", async () => {
    const response = await alexa.intend("SomeHandler") as SkillResponse;
    // response をアサーションするコード
  });
});

一旦、filterを設定すると、そのvirtual-alexaインスタンスでintendutterなどリクエストを発生させるメソッドを呼ぶ度に filterに渡したコールバック関数が実行されるので注意が必要です。

DynamoDBのモック

VirtualAlexaにはDynamoDBのモックも含まれています。

VirtualAlexaのインスタンスを生成した後、alexa.dynamoDB().mock()を実行することでモックされるようになります。

const alexa = VirtualAlexa.Builder()
  .handler(handler)
  .interactionModelFile(modelJson)
  .create();
alexa.dynamoDB().mock(); // <= DynamoDBのモック化

多分、不具合だと思うのだけど、次のように環境変数にAWSのリージョンを設定しておかないと接続時にエラーとなりました。

process.env.AWS_REGION = 'ap-northeast-1';

いこナビではDBのテーブル名など環境変数で渡したい値と一緒に設定するコードをファイルに纏めて、 setupFilesjest.jsonに追加してテストの初期化時に設定しています。

モック生成後は普通にDynamoDBにアクセスすればモックにつながるので、 初期データを BeforeEachで作ればDBの状態に応じた挙動のテストも可能です。

Displayインタフェース関連のテスト

VirtualAlexaはDisplayインタフェース特有の実装のテストにも対応しています。 次の様にすると、リクエストにDisplayインタフェースサポートが追加されます。

const alexa = VirtualAlexa.Builder()
      .locale("ja-JP")
      .handler(handler)
      .interactionModelFile(modelJson)
      .applicationID(process.env.APP_ID)
      .create();
alexa.context().device().displaySupported(true); // <= Dipslayインターフェースサポートを追加

後は、intendutterでリクエストを実行すれば、Dipslayインタフェースサポートされている場合のテストを行えます。

また、ListTemplateやアクションリンクを実装すると、Display.ElementSelectedリクエストを受ける必要がありますが、そのテストもサポートしています。

次のようにselectElementを実行するとDisplay.ElementSelectedリクエストがハンドラに送られます。 引数にはtokenの値を渡します。

const response = await alexa.selectElement('token') as SkillResponse;

デバッガも使える

VSCodeのデバッグ機能からテストを実行すれば、ブレークポイントで止めてステップ実行も可能です! バグを追う際の強い味方になってくれます。

f:id:HeRo:20180905234652p:plain

実機テストも重要

VirtualAlexaを使えば、Alexaスキルをテストドリブンに開発しやすく、捗ります。

しかし、VirtualAlexa でテストできるのは次の2点に過ぎません。

  • 対話モデルが仕様どおり定義されているか否か
  • その対話モデルに対応するLambda関数がプログラムとして意図した入力に対し意図した出力をするかどうか。

これらの前段にあるAlexaがユーザの発話を意図通り聞き取り、適切なインテントにルーティングするかはテストできません。

現状では、思ったように聞き取ってくれなかったり、別のインテントにリクエストを送ってきたりすることは往々にして起こります。 Alexaシミュレータでもある程度テストは可能なのですが、マイクの特性まではシミュレートできないのでリリース前には 実機テストしておくのが安心です。

また、Alexaがしゃべるセリフの区切りやイントネーションに違和感を感じることもあります。 実機での動作確認を通してそれらを検出し次の対応が必要になります。

  • サンプル発話を増やす
  • Alexaが聞き取りやすい発話にユーザを誘導する
  • Alexaに喋らせる言い回しを変更する。

まとめ

  • virtual-alexa を使えば、Alexaスキル用のLambda関数をローカルで簡単に実行できます。
  • Jestと組み合わせて使えば自動テストが実装できます。
  • 対話モデルも含めたテストが可能です。
  • DynamoDBを使うスキルもモックでテストが可能です。
  • 実機テストも重要

最後に

アクトインディではテストしてこそプロダクト、 テストドリブン開発大好き!というエンジニアを募集しています。

Serverlessの変数にSSMパラメータストアを使って秘密情報を分離する

morishitaです。

アクトインディでは AWS Lambda を利用しています。

Alexaスキル「[asin:B07G7W8SNL:title]」はプロダクトそのものが Lambda 関数ですし、 いこレポでは CI に Lambda を活用しています。

それらは Serverless Framework を使ってビルドしたりデプロイしています。 SSM のパラメータストアと組み合わせて使ってみたのでそれについて書きます。

秘密情報とコードを分離したい

Lambda 関数内で利用する各種 API のキーや認証情報はコードに書き込んでリポジトリに入れたくはありません。 そういう値は Lambda には環境変数で渡すようにしています。

Serverless Framework では、Lambda の環境変数を設定できます。 その機能を使ってデプロイとともに環境変数も設定しています。 しかし、Serverless Framework の設定ファイル serverless.ymlにその値を書き込んでしまうと、 今度は、serverless.ymlをリポジトリに入れにくくなってしまいます。

serverless.yml内で使える変数の機能は外部ファイルを読み込むことができるのでリポジトリに入れたくない値は設定ファイルにして、 その設定ファイルは .gitignoreに加えたりしていました。

ただ、プロダクトには通常複数の人が関わるので、リポジトリに入れないファイルを別途やり取りする必要があるなど共有しづらく管理が面倒です。

serverless.yml の変数の機能とは?

前述したように Serverless Framework の設定ファイルであるserverless.ymlでは変数が利用できます。

変数は次の値を参照できます。

  • serverlessコマンドの実行環境の環境変数
  • serverlessコマンドのオプション
  • 他のファイル(YML や JSON,JavaScript ファイル)
  • S3
  • SSM パラメータストア

変数によりデプロイ先を変更したり、Lambda に渡す環境変数の値をプロダクションと開発環境で別々にするのに使えとても便利です。

パラメータストアって?

SSM とは AWS Systems Managerのことです。 SSM は「インフラストラクチャを可視化し、制御するためのサービスです」と謳っており、AWS 上のリソースのみならずオンプレのサーバもエージェントをインストールすれば管理できてしまうというサービスです。 その中に秘密データと設定データをコードから分離するためのサービスとして位置づけられているのが、パラメータストアです。 パラメータストアを利用すれば、例えばデータベースの接続先のようなプレーンテキストデータや パスワードのような秘密データなどをコードから分離して管理できます。

パラメータストアに秘密情報を預けてしまい、アプリケーションからはそれを参照するようにすると 秘密情報をコードから分離し、その共有の煩わしさからも解放されそうです。

パラメータストアの作成

では、実際にやってみましょう。

SSM パラメータストアは SSM の一機能ではありますが、独立しておりインフラ全体を SSM で管理していなくても単体で使えるサービスです。

AWS コンソールのパラメータ作成画面は次のようになっています。

f:id:HeRo:20180905054804p:plain

名前には/が使えるので例のようにプロダクト名やステージ名を含めるようなルールを設計して運用するのが良いでしょう。

タイプで安全な文字列を選択すると KMS キーを利用して暗号化されます。 後述しますが、暗号化してもあんまりセキュリティ強度は変わらないと思うのでどちらでもいいと思います。 安全な文字列を利用場合の KMS のキーは影響範囲を各プロダクト内に閉じ込めるためにプロダクト毎に分けておくのが良いでしょう1。 今回は安全な文字列を選択するとして説明します。

serverless.ymlから参照

パラメータストアから値を読むためのssm:GetParametersや、暗号化した値を復号するkms:Decryptなどの権限を serverless.ymlprovider.iamRoleStatementsに追加しなければならないと思いきや必要ありません。 というのもデプロイ時に取得、復号されるようで、デプロイに利用する IAM2が権限を持っていれば良いようです。

serverless.yml で参照するには次のSOME_API_KEYの値の様に記述します。

functions:
  handler:
    handler: index.handler
    environment:
      SOME_API_KEY: ${ssm:/productName/${self:provider.stage}/paramName~true}

SSM パラメータストアで暗号化している場合には末尾に~trueを付けておきます。 ${self:provider.stage}という変数を更に内包させていますが、こうしておくとステージごとに参照するパラメータを切り替えられます。 ステージングとプロダクションでAPIキー等が異なる場合、こうして切り替えられます。

Lambda 関数に設定する環境変数の値として利用する場合はちょっと注意が必要です。 というのも、暗号化した値がデプロイ時に復号されるからです3。つまり、Lambda のコンソールを見れば復号された値が見えてしまうのです。 SSM パラメータストアで暗号化しても強度的にあまり変わらないと書いたのはこういう理由です。 Lambda のコンソールでも見えるとまずいというセキュリティポリシーの場合には別の手を考える必要があるでしょう4

AWS のコンソールで Lambda を参照できるのは権限のある人間だけで、 その人間は Lambda が使う API 等のクレデンシャル情報を見てもよいというのなら、問題ないでしょう。

まとめ

  • Serverless Framework の変数管理にパラメータストアは便利
  • Lambda の環境変数の設定に利用する場合には注意が必要
  • リポジトリに入れない秘密情報をどこに置く問題を解決できる

最後に

アクトインディではエンジニアを募集しています。


  1. KMS キーの管理は IAM の「暗号化キー」というメニューの中にあります。グロバールサービスである IAM の中にありながら「暗号化キー」はリージョンに依存しています。うっかりしているとハマるので注意。

  2. Serverless は AdministratorAccess を持つアカウントで使うようにドキュメントには書かれています。結構強力なので権限を絞りたければ、Narrowing the Serverless IAM Deployment Policy · Issue #1439 · serverless/serverlessを参考に調整する必要があります。

  3. 変数を使ってデプロイ先まで変更できるので、デプロイ時に復号するのは当然といえば当然でしょう。

  4. KMS を利用して暗号化した値を環境変数に設定し、その値をLambdaのコード内で復号する必要があります。serverless-kms-secretsを使うと便利かな。