シェルスクリプト一発で、AWS の IAM, API Gateway, Lambda へサンプル web アプリをデプロイする

CLI でやってみようシリーズですが、大物として、下記をスクリプト一発叩けば設定されるようにしてみました。

  • IAM Role の作成
  • Lambda Function の作成
  • REST API の作成
  • API のインテグレーションのリクエストテンプレートの設定

AWS の Web コンソールからやっていても良いのですが、そうすると、

  • 何となくマウスでゴニョゴニョとイジっているうちに動きはするが、結果として、何をやっていたのかがよく理解できていない
  • AWS コンソールはよく UI がよく変わるので、きっとそのうち教科書どおりに操作できなくなる
  • コマンド化しないと、デプロイの自動化ができない
  • 再構築もできない(&めんどい)
  • コードに残しておかないと忘れちゃう

といった問題がありますので。

ですので、AWS CLI を順次実行して、一気通貫で IAM, API Gateway, Lambda Function までを設定するスクリプトを書いてみました。

下記などを参考にしました。あいかわらずクラスメソッドさんは取っ掛かりが早い:

とはいえ、思った以上に大変でした。やや複雑なことをやっているのは分かるのですが、AWS のドキュメントは、やや不親切な感も? まあいいか、やりたいことはできたし。

実行すると出来あがるもの

HTTP から API Gateway 経由で Lambda Function を呼び出し、HTTP クライアントの情報をブラウザ側へ返す web アプリです。スクリーショットを撮るまでもないのですが、こんな感じですね。

スクリーンショット 2016-05-25 18.24.44.png

サンプルとして簡単ではあるのですが、

  • API Gateway + Lambda における、基本的な機能はひと通り使っている
  • Lambda Function は HTTP 専用というわけではないので、HTTP クライアントの情報を扱うのは API Gateway の方である(クライアント情報等を、バックエンドへマップしてやる必要がある)
  • ターミナル上での ID のコピペ等をしなくて良い

といった点をおさえているので、発展性もある良いサンプルになったのではないかと自画自賛。

Note: 今さら気づいたんですが、これだけならば Lambda 無しの API Gateway だけで出来るんちゃう? 発展性は無くなるけれど。

実行

スクリプトは右記の Gist にあります。 ∥ Create sample IAM Role, API Gateway REST API and Lambda Function.

JSON を多用しますので jq コマンドは動くようにしておいてください。

AWCLI の設定を、使用リージョンも含めてしておいてください。バージョンとしては、Mac OS 版 1.10.26 と、Linux 版 1.10.33 で動くことを確認しました。

Note: 古い awscli だと、API Gateway の Integration 操作で、Passthrough Behavior を扱う機能が未実装なためにコケる模様。新しいめのを使ってください。

Note: Wed May 25 2016 現在、どうも東京リージョンの API Gateway の web コンソールの日本語化が変なことになっています(あちこちで日本語文字列が、HTML エンコードを 2 回しちゃったように &# づいてしまっていて、読めない)。とりあえず私はオレゴン (us-west-2) を使いました。各種、単価が安いんですよね、オレゴン。

それ以外では、特に妙なものは使っていないと思います。openssl くらい? エラーが出たら足してやってください。

オプションなしで実行すれば、ひととおり作成&デプロイして、アクセス用の URL を表示します。オブジェクトの存否を確認するサブプロセスからの標準エラー出力が混じっていますが、気にしないでください。

$ ./deploy

A client error (NoSuchEntity) occurred when calling the GetRole operation: The role with name myLambdaRole cannot be found.
# Role myLambdaRole created.
# Attached arn:aws:iam::aws:policy/AWSLambdaFullAccess
# Attached arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
  adding: index.js (deflated 44%)

A client error (ResourceNotFoundException) occurred when calling the GetFunction operation: Function not found: arn:aws:lambda:us-west-2:732......782:function:getClientInfo
# Function getClientInfo created.
# REST API created.
# REST API ID is zp0ap4j0o3 .
# API Resource created.
# API Resource ID is nzdgmw .

A client error (NotFoundException) occurred when calling the GetMethod operation: Invalid Method identifier specified
# Method created.

A client error (NotFoundException) occurred when calling the GetIntegration operation: No integration defined for method
# Integration put.

A client error (NotFoundException) occurred when calling the GetMethodResponse operation: Invalid Response status code specified
# Method response put.

A client error (NotFoundException) occurred when calling the GetIntegrationResponse operation: Invalid Response status code specified
# Integration response put.

A client error (ResourceNotFoundException) occurred when calling the GetPolicy operation: The resource you requested does not exist.
# Test invocation command: aws apigateway test-invoke-method --rest-api-id zp0....0o3 --resource-id nzdgmw --http-method GET --path-with-query-string ''
# URL: https://zp0....0o3.execute-api.us-west-2.amazonaws.com/prod/client_info

Test invocation command: ~ は API Gateway からのテスト実行のコマンド、URL: ~ がデプロイされた先の URL です。

すでに各オブジェクトが作成済みであればスキップしますので、スクリプトは何度実行しても平気なように書かれているはずです。二度目の実行結果:

$ ./deploy
# Role myLambdaRole already exists.
# Policy arn:aws:iam::aws:policy/AWSLambdaFullAccess already attached.
# Policy arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole already attached.
  adding: index.js (deflated 44%)
# Code is alredy uploaded.
# REST API already exists.
# REST API ID is zp0ap4j0o3 .
# API Resource already exists.
# API Resource ID is nzdgmw .
# Method already exists.
# Integration already exists.
# Method response already exists.
# Integration response already exists.
# Test invocation command: aws apigateway test-invoke-method --rest-api-id zp0....0o3 --resource-id nzdgmw --http-method GET --path-with-query-string ''
# URL: https://zp0....0o3.execute-api.us-west-2.amazonaws.com/prod/client_info

-d オプションをつけて実行すれば、作成したオブジェクトは軒並み消します。

$ ./deploy -d

スクリプト解説

頭から始まり、下まで行って終わるだけのスクリプトですので、順次実行しながら出力を確認していただくのが一番分かりやすいとは思いますが、一応、備忘録も兼ねて解説します。

全体

set -o nounset -o errexit -o pipefail

jq を使うために、Bash で set -o nounset -o errexit -o pipefail をしています。くわしくは、右記の過去記事も参照してください。 ∥ jq(1) の力を借りて Bash スクリプト内で JSON を操作する - Qiita

Note: source したりコピペで逐次実行する際に set -o errexit が入っているとやたら死にやすいターミナルになってしまいますので、その際には set -o nounset -o pipefail くらいにしておいてください。

次に、各リソースの名前です。

role_name=myLambdaRole
rest_api_name="Get Client Info"
stage_name="prod"
resource_path="/client_info"
function_name="getClientInfo"

それぞれ、下記です。命名の際の大文字小文字の混ぜ方(case style)については、AWS の web コンソールで「create」しようとした際に表示されるサンプルに倣っています。

  • Lambda の実行ロール(lowerCamelCase で命名)
  • API Gateway での REST API 名(Title Case)
  • API Gateway でのステージ名(snake_case)
  • API Gateway での URI リソースのパス(snake_case)
  • Lambda Function 名(lowerCamelCase)

IAM ロールの作成と、Managed Policies の attach

Lambda Function を実行するためのロールを作成します。

CLI での IAM 作成については、過去記事で書きましたので、そちらも参考にしてください。 ∥ AWS CLI で IAM Role を作りつつ、「Assume」や「Principal」について考える - Qiita

  role=$(aws iam create-role \
    --role-name "$role_name" \
    --path "/" \
    --assume-role-policy-document "$assume_role_policy_document" | \
    jq -c '.' )
  echo "# Role $role_name created."
  ## Takes time to be available for the other services?
  sleep 5

5 秒待っていますね。IAM ロールを作ってから間髪を入れずに Lambda Function を作成しようとすると、たまに失敗するようなので、5 秒間のウェイトを入れています。もし失敗するようでしたら、スクリプトごと、再度実行してみてください。

Lambda Function の作成

zip を作成し、アップロードしています。同じ zip がすでにアップロードされていれば、再度アップロードしないようにしています。右記の記事もあわせてご参照ください。 ∥ 内容物が同一なのにハッシュ値の異なる ZIP ファイルが出来ないようにするには(あるいは、AWS Lambda へ同一コードを update することを防ぐには) - Qiita

REST API の作成

API Gateway 上に、REST API を作成して行きます。今回最大のハマりどころ。

Note: ところで、最初に API Gateway のチュートリアルを読む際には、いきなり Lambda へ接続しようとするよりも、HTTP Proxy への接続の方が、リクエストとレスポンスでのパラメータやボディのマッピングの意味合いが分かりやすいと思います。その発想の延長として、Lambda バックエンドへの接続があるんで。

REST API のリソースはパスで表わされるので、ルートリソース (/) から辿るツリー状の構造をとります。ですので、今回のように逐次コマンドで構成すると、記述が煩雑になります。今回はルート直下に GET リソースを一つ作るだけ(/client_info)ですので大したことはありませんが、複雑なモノを作り始めたら、やはり Swagger やフレームワーク等が要るのでしょうね。

さて、REST API を作 (create-rest-api) ったら、それ以降は、各リソースを足すたびに、下記のような手順を経ることになります。

  1. REST API のルート以下へリソースを足す (create-resource)
  2. リソースへ CRUD メソッドを足す (put-method)
  3. メソッドごとに、put-integration やら update-integration で、integrated な backend へ繋ぐ
  4. 戻り値ごとに put-integration-response で統合レスポンスを作成する
  5. 戻り値ごとに put-method-response でメソッドレスポンスを作成する
  6. メソッドごとに、Lambda Function へ add-permission

そして、API をデプロイすると (create-deployment)、晴れて外部からアクセスできるようになります。

Web コンソールの設定画面でも、右記の解説でも、概念的にはリクエスト側には、外と内とで 2 つのパートがあるような考え方になっているのですが、API 的には一つになっています。リソース+メソッドをキーとして、それに対応する「integration」がある(put-integration など)。これが、だいたい「メソッドリクエスト」と「統合リクエスト」に相当します。 ∥ メソッド設定の用語と定義 - Amazon API Gateway

  integration=$(aws apigateway put-integration \
    --rest-api-id "$rest_api_id" \
    --resource-id "$api_resource_id" \
    --http-method GET \
    --integration-http-method POST \
    --passthrough-behavior "WHEN_NO_TEMPLATES" \
    --type AWS \
    --uri "$function_uri" )
  echo "# Integration put."

--type AWS と、--uri に指定する API Gateway → Lambda の呼び出し ARN でもって、API Gateway と Lambda を「統合」している。

ここで --integration-http-method POST とあるように、Lambda への「統合リクエスト」での内部的な呼び出しも HTTP で、メソッド的には POST らしいです。どっちでもいいけど。ここをうっかり GET にしていて、延々と Internal Server Error が出続けて泣きそうになった。注意しよう。

対してレスポンス側(統合レスポンスとメソッドレスポンス)は、リソース+メソッド+リターンコードがキーとなるので、API も別立てです。入りは一本でも、出はリターンコードごとに必要ですので。

  method_response=$(aws apigateway put-method-response \
    --rest-api-id $rest_api_id \
    --resource-id $api_resource_id \
    --http-method GET \
    --status-code 200 \
    --response-models '{"application/json": "Empty"}' | jq -c '.' )
  echo "# Method response put."

統合における「リクエスト・テンプレート」は、クライアント側からのリクエストを反映して Lambda 側へのイベントリクエストへ反映させる内容のテンプレートを指定します。このテンプレートから実データが作られて、Lambda のイベント情報として渡されますんで。

次に、update-integration でリクエストボディのテンプレートを渡すのですが、渡し方が面倒ですね。AWS の API でよく出てくるのですが、サーバ側の内部ではドキュメント型の(あるいはナビゲーショナルな)データベースになっているようで、それを更新するためのデータ操作コマンドを JSON 形式で送り込むようなイメージです。リクエスト・テンプレートの内容をエスケープした文字列を JSON に埋め込んでデータ操作コマンドを作り、それを update-integration でもって発行します。詳しくは update-integration のヘルプを参照してください(ヘルプに詳しく書かれているとは言っていない)。

request_template=$(cat <<'EOF'
#set($input_root = $input.path('$'))
{
  "stage": "$context.stage",
  "request_id": "$context.requestId",
  "api_id": "$context.apiId",
  ...
  "user_arn": "$context.identity.userArn",
  "input": $input_root,
  "params": "$input.params('foo')"
}
EOF
)
patch_operations=$(jq -c '.' <<EOF
[
  {
    "op": "add",
    "path": "/requestTemplates/application~1json",
    "value": $(jq -n --arg request_template "$request_template" \
     '$request_template' )
  }
]
EOF
)
integration=$(aws apigateway update-integration \
  --rest-api-id "$rest_api_id" \
  --resource-id "$api_resource_id" \
  --http-method GET \
  --patch-operations "$patch_operations" | jq -c '.' )

ここで VTL テンプレートは「JSON を生成するテンプレート」であって JSON ではないので、jq(1) は通せません(通せば当然、エラーになる)。

リクエストボディ(&レスポンスボディ)のテンプレートのリファレンスは、右記。ここで構築したオブジェクトが、Node.js 内では event オブジェクトになります。 ∥ API Gateway API Request and Response Payload-Mapping Template Reference - Amazon API Gateway

ついでに、テンプレート関連のリファレンスを並べておく:

ここに出てくる、デリミタの ~1 は何なんだろう? MIME Type 内のスラッシュ (/) をエスケープしているようなのだが。まあいいか。

おわりに

今回は理解のために CLI で API Gateway を作成しましたが、こんなの実務じゃ都度やっておれんですね。次は Swagger か。