AWS Lambda で遊ぼう(第2話: AWS SAMといっしょ)

AWS SAM で Lambda を作って API としてデプロイしてみる

前回の続き。

tercel-tech.hatenablog.com

前回は、マネジメントコンソールから Lambda をひとつずつぽちぽち作る体験をした。

今回はいよいよ AWS SAM (Serverless Application Model) の力を借りて、コマンドラインベースで Lambda を試食してみよう。

aws.amazon.com

お品書き

演習環境について

今回は、開発環境の構築を諸々チートできる Cloud9 というツールを使うことにする。

ご存知ない方のためにざっくり説明すると、これは Web ブラウザで利用できる開発エディタである(下図参照)。

f:id:tercel_s:20210430193922p:plain

Git やら Python やら Docker やら AWS SAM CLI やら、とにかく開発に必要なもろもろが初めから入っており、今回に関していえば Cloud9 ひとつ立ち上げるだけで開発環境がワンタッチで調達できてしまう優れものだ。

実を言うと、手元の M1 Mac にもローカル開発環境があるといえばあるのだが、そのへんの構築方法を一から書こうとするとそれだけで丸ごとひとつの記事分の分量になりかねないし、少々退屈な話になってしまうので、最初からいろいろ面倒を省ける道具に頼る方がよい。

【練習①】プロジェクトの作成とローカル動作確認

SAM CLI による新規プロジェクトの作成

Cloud9 の右下(下図の赤枠部分)のペインがターミナルである。 前回とは打って変わって、コマンドラインインタフェースを通してプロジェクトの作成・テスト・デプロイを行うことになるため、是非とも仲良くなっておきたいツールである。

f:id:tercel_s:20210430221324p:plain

ここに以下のコマンドを打ち込んで Enter キーを叩いてみよう。

$ sam init

f:id:tercel_s:20210430195218p:plain

Which template source would you like to use? と訊かれるので、1 (AWS Quick Start Templates) と入力し、Enter を押す。

f:id:tercel_s:20210430195057p:plain

さらに What package type would you like to use? と訊かれるので、1 (Zip) と入力し、Enter を押す。

f:id:tercel_s:20210430195454p:plain

Which runtime would you like to use? と訊かれるので、2 (Python 3.8) と入力し、Enter を押す。

f:id:tercel_s:20210430195743p:plain

続いて、Project name を訊かれる。 これは、何も入力しないとデフォルトで sam-app という名前が付けられる。

特に拘りもないので、このまま何も入力せず Enter を押す。

f:id:tercel_s:20210430200031p:plain

AWS quick start application template を選ぶように促されるので、ここでは一番ベーシックな 1 (Hello World Example) を入力し、Enter を押す。

f:id:tercel_s:20210430200421p:plain

これでプロジェクトの作成が完了した。 ./sam-app/README.md を読めと書いてあるので、左側のペイン*1から当該ファイルをダブルクリックで開いてみよう。

プロジェクトのフォルダ構成や、SAM CLI の基本的なコマンドなどが解説されている。

f:id:tercel_s:20210430200856p:plain

ここからは、特に断りがない限り、カレントディレクトリが sam-app であるものとする。

Lambda 関数の確認

Lambda 関数の中身を確認するため、./hello_world/app.py を開こう。

f:id:tercel_s:20210430202217p:plain

紙面の都合でコメントを除去しているが、以下のようなサンプルの Lambda 関数が実装されている。

前回マネジメントコンソールで見たコードとほぼ同じものであるため、特に抵抗感はない。

import json

def lambda_handler(event, context):
    return {
        "statusCode": 200,
        "body": json.dumps({
            "message": "hello world",
        }),
    }
ローカルでの動作確認

この Lambda 関数を、ローカルで動作確認してみよう。

まずはお手軽編として、以下のコマンドを実行してみよう。 オプションなどの意味は、いまは深く考えなくてよい。

$ sam local invoke HelloWorldFunction --event events/event.json

うまくいくと、実行ログと戻り値が表示されるはずだ。

f:id:tercel_s:20210430203532p:plain

ローカルでの REST API としてのテスト

続いて、これを REST API としてローカルで動かしてみよう。 Cloud9 のターミナルに、以下のコマンドを入力する。

$ sam local start-api

すると、http://127.0.0.1:3000/helloGET メソッドでアクセスできるようになる。 Ctrl+c で解除するまで待ち受け状態が続くので注意。

f:id:tercel_s:20210430204816p:plain

このターミナルは start-api によって占領されてしまったので、もうひとつ別のターミナルを起動しよう。+ボタンを押すとポップアップメニューが表示されるので、「New Terminal」をクリックする。

f:id:tercel_s:20210430205654p:plain

新しいターミナルに、curl コマンドを入力する。

$ curl http://127.0.0.1:3000/hello

うまくいくと、Lambda 関数が実行され、HTTP の Response Body の内容がターミナルに表示される。

f:id:tercel_s:20210430210109p:plain

【練習②】パラメータの受け渡し

入力パラメータと events/event.json

Lambda 関数を実行する際、外部から何らかのパラメータを受け取りたいとしよう。

たとえば、整数値 xy という 2つの変数を受け取り、その値を合計して返す Lambda 関数を考えてみる。

前回書いたとおり、Lambda の実行に必要なパラメータは第一引数にまとめられるので、もし呼び出し側でパラメータをセットしていれば、event.get('x') もしくはブラケットを利用した event['x'] で取得できる。

import json

def lambda_handler(event, context):
    x = event.get('x', 0)
    y = event.get('y', 0)
    
    return {
        "statusCode": 200,
        "body": json.dumps({
            "result": str(x + y)
        }),
    }

さて、正しく動くかどうかを試すため、実際に xy にパラメータを受け渡した上でローカル実行してみたい。

ここで登場するのが、events/event.jsonである。

f:id:tercel_s:20210430212737p:plain

ここには、実際に AWS 上で実行される Lambda に引き渡される想定のダミーデータが前もって定義されている。

ローカルでテストをする際に xy の値を設定したい場合は、下図の赤枠のように xy それぞれを JSON に追加すればよい。

f:id:tercel_s:20210430213025p:plain

あとは、先ほどと同じように sam local invoke コマンドを打つだけだ。

$ sam local invoke HelloWorldFunction --event events/event.json

戻り値には、確かに xy の合計が格納されていることが確認できる。

f:id:tercel_s:20210430213428p:plain

REST API のクエリパラメータ

続いて、この Lambda が REST API として実行されたときのクエリパラメータの取得方法を見てみよう。

ちなみにクエリパラメータとは、URL 末尾にありがちな、「?」以降のキーバリューのペアである(伝われ)。

例: https:​//tercel-tech​.com?foo=bar&hoge=piyo

これは、queryStringParameters経由で取得できる。

import json

def lambda_handler(event, context):
    params = event.get('queryStringParameters')
    x = int(params.get('x', 0))
    y = int(params.get('y', 0))
    
    return {
        "statusCode": 200,
        "body": json.dumps({
            "result": str(x + y)
        }),
    }

そして、ターミナルに以下のコマンドを入力し ──

$ sam local start-api

先ほどと同じ要領で新たなターミナルを起動して、curl を叩く。

$ curl http://127.0.0.1:3000/hello?x=100\&y=20

すると、クエリパラメータに引き渡した値がきちんと Lambda の中で足し算され、戻り値にセットされたことが分かる。

f:id:tercel_s:20210430220107p:plain

【練習③】AWS へのデプロイ

ここまでの成果を、AWS にデプロイしてみよう。

AWS SAM CLI によるデプロイ

ターミナルに、以下のコマンドを打ち込む。

$ sam deploy --guided

f:id:tercel_s:20210430222635p:plain

ここから、いくつかの質問を受けるが、ほぼほぼデフォルト(何も入力せず Enter)でよい。

f:id:tercel_s:20210430222806p:plain

ただし、HelloWorldFunction may not have authorization defined, Is this okay? という問いに対してだけは、この質問文の意味をよく理解した上で y を応答する

f:id:tercel_s:20210430222942p:plain

念の為に書き添えておくと、ここで「y」を応答するということは、URL さえ知っていれば全世界どこからでもアクセス可能な API としてデプロイされてしまう悪魔の契りを交わしたことになるので本当に自己責任でお願いします

個人の私有アカウントではなく、会社のアカウントを使って勝手にこういう実験をした場合、(内部統制に厳しい会社の場合は)けんせきされたり始末書を書かされたりするので十分注意されたし。

残りの項目に対してはデフォルト応答でよい。

f:id:tercel_s:20210430223729p:plain

すると、しばらくデプロイ処理が走る。 2〜3分待って、Successfully と表示されたらデプロイ成功である。 さすがに見えてはいけない文字列がいくつかあったのでモザイク処理を施した。

f:id:tercel_s:20210430224207p:plain

ちなみに、AWS マネジメントコンソールでデプロイされた Lambda 関数を見ると、ご丁寧に API Gateway までセットでデプロイされていることが分かる。

f:id:tercel_s:20210501000836p:plain

なぜそうなるかというと、template.yaml がそのような構成になっているからなのだが、今回は時間の都合でこのファイルの中身には触れないため、一旦「そういうふうにできている」と思うことにして先へ進もう。

API Gateway との導通確認

デプロイに成功すると、HelloWorldApi のエンドポイント URL がターミナルに表示されるので、curl を叩いてみる。

f:id:tercel_s:20210430230120p:plain

うまくいくと、下図のように、クエリパラメータに指定した xy の合計が表示される。

f:id:tercel_s:20210430230725p:plain

【練習④】 CORS 対応

ここまでの練習で、Lambda を REST API として AWS にデプロイするステップまで完了した。

しかしながら、今の状態の APIJavaScript から(XHRで)呼ぼうとするとエラーになってしまう。 実験してみよう。

テスト用 HTML の作成

即席で以下のような HTML を用意した*2

index.html

<html>
<head>
  <script src="https://code.jquery.com/jquery-3.6.0.min.js"
          integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
          crossorigin="anonymous"></script>
  <script>
    (function() {
      $.get(`https://████████.execute-api.ap-northeast-1.amazonaws.com/Prod/hello?x=100&y=20`)
      .then(res => alert(JSON.stringify(res)));
    })();
  </script>
</head>
<body>
</body>
</html>

これをローカルに保存して、適当なブラウザで開いてみよう。 本来は、API の戻り値がアラートに表示されるはずだが、何も起こらない。

開発者ツールを開くと、どうやら CORS エラーが発生しているようだ。

f:id:tercel_s:20210430232603p:plain

この悲劇の原因は、同一生成げんポリシーとよばれるブラウザ側のセキュリティ機能が働いたためである。

超ざっくり話すと、本来、自分自身のサイトと異なるオリジンにあるリソースへの XHR はそもそも許されていないのだが、CORS (Cross-Origin Resource Sharing) という仕組みを使うことによってこの制約を回避できる。

CORS の有効化

CORS を有効化するには Lambda 関数の戻り値を修正して、レスポンスヘッダに Access-Control-Allow-Origin を追加すればよい。

import json

def lambda_handler(event, context):
    params = event.get('queryStringParameters')
    x = int(params.get('x', 0))
    y = int(params.get('y', 0))
    
    return {
        "statusCode": 200,
        "headers": {
            "Access-Control-Allow-Origin": "*"
        },
        "body": json.dumps({
            "result": str(x + y)
        }),
    }

なお、上記のコードはあらゆるオリジンからのアクセスを許可するコードになっている。 実際に商用サイトとして運用する場合は、"*" になっている箇所を、アクセスを許可したい特定のオリジンに書き換えた方がよい。

再度、sam deploy でデプロイしてみよう。

f:id:tercel_s:20210430234400p:plain

XHR での API 呼び出し動作確認

再び、先ほどの即席 HTML をブラウザで開いてみよう。 今度は期待どおり、API の戻り値がアラートに表示されるはずである。

f:id:tercel_s:20210430234521p:plain

開発者ツールを見ても、きちんと正常応答が返ってきていることが分かる。

f:id:tercel_s:20210430234820p:plain

まとめとか

今日は Cloud9 と AWS SAM CLI を使用して、コマンドライン操作で Lambda プロジェクトを作成し、API として AWS にデプロイした。

また、クエリパラメータの受け渡しや、CORS 対応なども行なった。

前回のようにあちこちのページをったり来たりはせず、ほぼ Cloud9 だけで操作が完結したのも大きなポイントである。

本文中では詳しい説明を避けたが、プロジェクトはテキストファイル(Pythonyamljsonなど)の集合体であり、git などでのバージョン管理が容易であるなど、前回、課題として挙げた内容のいくつかは AWS SAM によってクリアされる。

話の流れの都合上、AWS SAM において最も重要な template.yaml にほぼ触れず、Lambda 関数本体と events.json を多少修正しただけに過ぎない。

しかしこれだけでも前回の開発体験と何がどう変わるのかを体感できたように思う。

なっっが。

実は、テーマの設定から記事の完成まで軽く5時間以上かかっているのだ……。

なんでそんなに時間かかってるのw

ねー。

今回、細かく話し出すと長くなりそうなところは、かなり思い切って削ったのだけど、どうしてもストーリーが長くなってしまった……。

API をデプロイするときに、一箇所だけデフォルトじゃない応答を入力したところ?

それと、最後の Access-Control-Allow-Origin ヘッダとかね。

だいぶ手を抜いた。

時々思うんだけど、そんな杜撰なガバガバ設定でいいもんなのです?

あんまり良くない。

オンプレの閉じた環境であれば、ちょっとした動作確認のつもりでゆるい設定のままデプロイすることはざらにあるよね。

そうだね。

しかし、今回はいきなり全世界に向けて API をデプロイしてしまっている。

個人のお勉強以外では避けた方がいいと思う。

ふむ。

で、次回は?

次回は、いよいよ template.yaml の中身をいじってみようと思う。

DynamoDB をひとつこさえて、そいつを読み書きする Lambda を SAM で書いてみようかと。

まーた長くなりそうw

ねー。

本当は GW に他にもやりたいことがいくつもあるのに、これだけで連休が終わっちゃいそう。

*1:ファイルが階層的に表示されている領域。正式名称は Environment。

*2:伏字になっている箇所は API のエンドポイントである。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.