AWS Lambda と Python で作る動的な HTML ページ

  • 12
    いいね
  • 0
    コメント

やりたいこと

AWS の Lamdba を使って動的なHTMLを生成するページを作りたい
Lambda を使うと自前でサーバーを用意しなくても好きな Python のスクリプトを実行できるので、これを使って動的な HTML を生成するページを作ってみようと思います。

Lamdbaでページを作る系の話はすでに結構あるのですが、この前発表された API Gateway の Lambda プロキシ統合 とか
Lambdaのデプロイツール apex あたりを組み合わせるともうちょっといい感じにできるのではというのが今回の目的です。
あとスクリプトから HTML を吐くからには、テンプレートエンジンは欠かせないので、実際それらを組み合わせて使えるのか検証兼ねて遊んでみました。

作るもの

どんなページを作ってみるかですが、今回は神社を建立することにしました
Python の有名なテンプレートエンジンに jinja2 というものがあるのですが、それにあやかってサーバレスな神社を作ることにしました。
(といってもただの Webページです。念のため)

動的なページのサンプルとして作りたいので、何番目の参拝者かを示すアクセスカウンターを設置してみます。
まあ現在時刻を返すとかでも動的な例としてはいいのでしょうが、Javascript でできることをわざわざサーバーでやるのも味気ないですし、動的な処理が必要な殆どはデータベースでゴニョゴニョする用途だと思うので、実践的な要素としてアクセスカウンターを選んでみました。
アクセスカウンター自体が化石みたいな扱いになってることは目をつむりましょう。

構成図

image
https://cloudcraft.co/view/67eed248-a460-46dc-91dd-fdb5a70f529f?key=D4aRY_6kyWIfbEKtYk78TQ

今回作ってみるサーバーの構成はこんな感じです。
Lambdaは直接ブラウザから叩けないので前に API Gateway をかませてアクセスできるようにしています。
また背後には DynamoDBを設置してアクセス数を保存するようにしています。
DynamoDBもインスタンスを建てずに使える Key-Value データベースです。
シンプルですがこれでも立派なサーバレスアキテクチャです。

準備

Apex

今回は Apex というツールを使ってLambdaにデプロイすることにしました。
Lambda で外部のライブラリを使う場合、それらを ZIP で固めて配置する必要があるのですが、この Apex というツールを使うとコマンドラインからデプロイや設置したスクリプトの実行が簡単に出来るのでとても便利です。

ここでは詳しくは書きませんが、

  1. Apex をインストール
  2. AWSのキーを環境変数に設置
  3. apex init でプロジェクト初期化・雛形作成
  4. 雛形を参考に関数を実装する
  5. apex deploy

こんな感じでサクッとデプロイ出来ます。

DynamoDB

アクセス集計用のテーブルを先に用意しておきます。
DynamoDBの場合、スキーマレスなので使うカラムをすべて定義する必要はないのですが、走査のためのプライマリキーは予め決めておく必要があります。

今回は以下のようなテーブルを作りました。
image

counter_id という名前のプライマリパーティションキーを用意しています。
使い方としては、 0 から 9 のcounter_idを持つ10個のカウンターを用意しておき、それらをランダムに指定してインクリメントする感じですね。カウンターを複数用意することで、1箇所に書き込みが集中せず、一定の性能を保つことができます。

DynamoDBにはトランザクションがないので同時に同じテーブルに書き込むと整合性が取れなくなってしまうと思ってましたが、 update_item を使って式としてインクリメントすることでアトミックなカウンターを実現できるみたいです。(さっき知りました)

読み込むときは全カウンターを取得して、それらの合算を返すことにします。「強い整合性」を使わない場合、書き込んだ結果がすぐ反映されないみたいですが、今回は厳密性は求めてないのでリーズナブルな弱い整合性を使ってます。

image
何度か書き込んでみた結果。

実装

早速作っていきます。

Lambda で Jinja2 を使う

Jinja2のパッケージと使いたいテンプレートを丸ごとApexの関数フォルダに突っ込むことで、Lambdaでも問題なくテンプレートエンジンを使うことが出来ました。

フォルダ構成.txt
├── functions
│   └── jinja
│       ├── jinja2/ など
│       ├── main.py
│       ├── requirements.txt
│       └── templates
│           ├── index.html
│           └── layout.html
├── project.json

Apex のプロジェクトルートから見たフォルダ構成が上記です。
functions というフォルダの中に jinja というフォルダがありますが、これが今回定義した Lamda 関数のルートフォルダになります。 この階層に複数のフォルダを作ることで別々の Lambda 関数をまとめて扱うことが可能になります。

jinja2 を使うためには、 jinja のフォルダの中で pip install -t . jinja2 を実行して jinja2 のパッケージを丸ごと設置しておく必要があります。 また使う予定のテンプレートファイルもフォルダを作って入れておきます。

main.py
# coding: utf-8

from jinja2 import Environment, FileSystemLoader

env = Environment(loader=FileSystemLoader(path.join(path.dirname(__file__), 'templates'), encoding='utf8'))

template = env.get_template('index.html')
html = template.render()

呼び出すときはこんな形。
これで templates/index.html のテンプレートを出力できます。

DynamoDB にアクセスする

Lambda 関数から DynamoDB にアクセスするには boto3 という公式ライブラリを使うのが簡単です。 Apex のパッケージに入れなくても使えますし(バージョンを固定したいという理由で明示的に入れるケースも)、 Lamdba に適切な Role を指定しておけば、コード内で認証をする必要なく使えます。

main.py
import boto3

dynamodb = boto3.resource('dynamodb')
count_table  = dynamodb.Table('lamdba-jinja')
counts = count_table.scan(Limit=10)

API Gateway の Lambda プロキシ統合

API Gateway を使うと、 HTTP から Lambda を実行してその結果を受け取れるようになります。ただ従来の API Gateway だと、どんなパスにどんなリクエストが来て、どんなレスポンスを返すのかをマッピングしてあげる必要がありました。
これが割と面倒くさい 任意のリクエストに対応できなかったり、1つのスクリプトをパスに応じて使いまわすみたいなケースに対応できなかったのですが、最近になってプロキシ統合という素晴らしい仕組みが導入されました。
これを使うと関数側にリクエストやパスの情報が渡ってくるので API Gateway + Lambdaの可能性が一気に広がってきます。早速今回試してみました。

image

API Gateway でルートに対してメソッドとリソースをこんな形で作成し、これを今回作る Lambda と紐つけてあげます。

swagger.yaml
---
swagger: "2.0"
basePath: "/jinja"
schemes:
- "https"
paths:
paths:
  /:
    x-amazon-apigateway-any-method:
      produces:
      - "application/json"
      responses:
        200:
          description: "200 response"
          schema:
            $ref: "#/definitions/Empty"
  /{proxy+}:
    x-amazon-apigateway-any-method:
      produces:
      - "application/json"
      parameters:
      - name: "proxy"
        in: "path"
        required: true
        type: "string"
      responses: {}
definitions:
  Empty:
    type: "object"
    title: "Empty Schema"

Swagger で書くとこんな感じ。

リソースを /{proxy+} としてあげることで、任意のパスのリクエストを同じ Lambda でさばくことが可能です。
/{proxy+} だけだとルートのアクセス拾えなかったので、ルートにも別途メソッドを定義してます。

API Gateway 用のレスポンス

API Gateway側の設定は簡単に終わったのですが、 Lambda 側で問題が。
HTML を返す Lamda を作って、それを API Gateway に繋げればよしなにページを返してくれるのかと思ってましたがちょっと違いました。
ただHTMLを返すだけだと API Gateway 側で 502 が返ってきてしまいます。
最初は形式が JSON じゃないからだと思って ContentType や API Gateway の設定を弄っていたのですが、原因は別のところにありました。

Proxy 統合を使う場合、スクリプトは 特定の形式でレスポンスを返す必要があるみたいです。この形式に従っていないと問答無用で 502 が返ってくるというオチでした。

決まった形式というのはこちら。

main.py
def handle(event, context):
    html = ...
    return {
        "statusCode": 200,
        "headers": {"Content-Type": "text/html"},
        "body": html
    }

statusCode, headers, body の3つを定義した辞書を Lambda で呼んだ関数から返してあげる必要があります。 API Gatewayはこれらをみて HTTP のレスポンスを作ってくれます。

また、 API Gateway にリクエストしたときの情報はすべて handle 関数の event (第一引数)に渡されます。今回のサンプルではどんな値が渡されるかを pprint で表示してますのでよかったら参考にしてみて下さい。

完成

https://teu24wc5u9.execute-api.ap-northeast-1.amazonaws.com/jinja/
無事神社が完成しました。
もうちょっと神社っぽいページにしたかったのですが時間なくて力尽きました。
技術的に伝えたい事は伝えきれたのでよしとします。

表示されている参拝者数がアクセスするたび増えていっているのが分かるでしょうか。
1台もインスタンスを作らずに動的な Web ページを作ることが出来ました。

アクセスするパスは

/jinja/
/jinja/hoge
/jinja/fuga
/jinja/piyopiyo

みたいに何でも受け付けてくれます。
ページ下部にあるリクエスト情報の path もちゃんと取れてるはずです。
これを使えば独自にルーティング処理を入れて異なるページを見せるのも簡単ですね。

API Gatewayの制約で /jinja/ の部分はバージョン文字列なので外せません。

ルートでアクセスするようにするためには、 API Gateway の前に CloudFront などを置いてあげるのが良さそうです。
API Gateway だけだと静的ファイルもホストできませんしね。

終わりに

結構見切り発車で Lambda での動的ページ構築やってみましたが無事いい感じに作れて良かったです。
API Gateway という名前からして API 向けのものを Webページとして使うのはどうなんだろうという不安はありましたが、最終的には本格的な Web サービスでも Lambda + API Gateway でいけるのではという力強さを感じました。
Lambda の唯一のつらみは Python2 しか使えないことですね…。 早く 3 対応お願いします:pray:

https://github.com/pistatium/lambda_jinja_sample
今回のソースはこちらです。

おまけ

弊社の QiitaOrganization をこの前作ってもらいました。
AdventCalender 以外にも頑張って書いていきたいです。