HSP3 で書いた TODO API を AWS Lambda のカスタムランタイムで動かしてみた

HSP3 で書いた TODO API を AWS Lambda のカスタムランタイムで動かしてみた

HSP3というプログラミング言語で、AWS Lambda にカスタムランタイムとして TODO API を実装・デプロイしてみました。TLS と署名は AWS CLI に委譲し、ルーティングからバリデーション、DynamoDB リクエストの組み立てまでを HSP3 で処理しています。
2026.08.14

はじめに

製造ビジネステクノロジー部のふじい(大)です。

HSP (Hot Soup Processor) というプログラミング言語をご存じでしょうか。

BASIC ライクな文法で、何も書かなくても実行すればウィンドウを表示できるので、ゲームやツールを手軽に作れるスクリプト言語です。
日本語のドキュメントや子ども向けの書籍も揃っているので、はじめて触った言語がこれ、という方もいるかもしれません。

https://hsp.tv/

今回はこの HSP で TODO アプリのバックエンド API を書いて、AWS にデプロイしてみました。
パソコンの中ではなくクラウドで動くので、インターネットから複数のユーザーが HSP で書かれたシステムを使えるようになりました。

夏休みの自由研究のつもりで「できるかな」と AI に投げてみました。デプロイの手作業以外はすべて AI 任せです。
それで実際に動いたので、少し感動しつつこの記事を書いています。

やってみて分かったことを、先に 4 つ挙げておきます。

  • CUI 版の hsp3cl は TLS も SigV4 署名も持っていないため、AWS の API を直接呼べません。この層は AWS CLI に任せました
  • レスポンスまでの 1.5 秒のうち、約 1.47 秒は AWS CLI の起動と DynamoDB の応答待ちでした。DynamoDB を呼ばない経路は 59 ms です
  • メモリを 1024 MB から下げると CPU の割当も減るため、GB 秒の課金は下がらないと見ています
  • #module 内の変数が既定で静的変数になる点と、repeat の内側から return できない点で詰まりました

成果物は以下のリポジトリに格納しています。

https://github.com/dafujii/todo-hsp3-lambda

HSP3 とは

いま使われているのはバージョン 3 系です。うしろに 3 を付けて HSP3 と呼びます。最初のバージョンが公開されたのは 1996 年、3 系になったのは 2005 年で、いまも更新が続いています。
処理系のソースコードも OpenHSP として公開されています。

https://github.com/onitama/OpenHSP

今回使うのは、Linux 向けにビルドした 3.7 の hsp3cl です。
ウィンドウを持たず、標準入出力だけで動く CUI 版になります。

作ったもの

TODO の CRUD を HTTP で叩ける API にしました。フロントエンドは作っていません。

https://github.com/dafujii/todo-hsp3-lambda

構成は下記の通りです。Lambda の中で 3 つのプログラムが動いています。

クライアント


API Gateway (HTTP API)


Lambda (コンテナイメージ)
 ├─ bootstrap … Runtime API との受け渡し
 ├─ hsp3cl    … ルーティング / 検証 / JSON の読み書き
 └─ AWS CLI   … TLS と署名を担当 ──▶ DynamoDB

HSP3 が DynamoDB を直接呼んでいるわけではありません。AWS CLI を挟むことになった経緯は後述します。

検証した環境は以下の通りです。

項目
HSP3 3.7 (OpenHSP をソースからビルド)
Lambda パッケージ コンテナイメージ
ベースイメージ public.ecr.aws/lambda/provided:al2023
アーキテクチャ x86_64
メモリ 1024 MB
AWS CLI 2.33.15 (Python 3.9.25)
API Gateway HTTP API / payload format 2.0
リージョン ap-northeast-1

ルートは 5 つ用意しました。

ルート 動作 成功時
GET /todos 一覧を取得 200
POST /todos 作成 201
GET /todos/{id} 1 件取得 200
PUT /todos/{id} 更新 200
DELETE /todos/{id} 削除 204

$default で全部受けてパスを自分で解析する方法もありますが、5 つ並べる形にしています。
こうしておくと、定義していないパスは API Gateway の段階で 404 になります。Lambda が起動しないため、その実行料金もかかりません。

ボディは {"title": "...", "completed": false} です。title は必須で 200 バイトまで、completed は省略すると false になります。
エラーは {"message":"..."} の形で返します。バリデーションに引っかかると 400、対象が無ければ 404、DynamoDB の呼び出しに失敗すると 500 です。
PUT が 2 つの属性をまとめて置き換えることや、作成時の ID 衝突を 409 にしていることなど、細かい仕様はリポジトリの README にまとめてあります。

Lambda に HSP3 用のランタイムはない

Lambda には言語ごとの実行環境が用意されていて、これをランタイムと呼びます。
Python、Node.js、Java、Ruby などが並んでいますが、当然ながら HSP3 はありません。

そこで使うのがカスタムランタイムです。一覧にない言語は、実行環境を自分で持ち込めます。
bootstrap という名前のプログラムを置いておくと、Lambda がそれをエントリポイントとして起動します。その先は自由に決められます。

特殊な使い方ではありません。
Go や Rust も、OS だけを持つこのランタイムの上に Runtime API とやりとりするクライアントを組み込む形で動いています。

今回の bootstrap はシェルスクリプトです。
やることは Runtime API との受け渡しと、HSP3 が自力では取得できない値の受け渡しの 2 つだけになります。

  1. invocation/next を叩いて次のイベントを取得し、/tmp/event.json に置く
  2. テーブル名、DynamoDB の接続先、UTC の現在時刻、新規 ID を /tmp/config.txt に書く
  3. 前回の /tmp/response.json を消してから、hsp3cl で HSP3 のプログラムを実行する
  4. HSP3 が書き出した /tmp/response.json を、そのまま Runtime API に POST する

2 が必要なのは、hsp3cl に環境変数を読む命令がないためです。時刻を取る gettime もタイムゾーンに依存するので、UTC の現在時刻もここで確定させています。
ID は /dev/urandom から 16 バイト取って 16 進で書き出しています。後述する動作検証で出てくる 32 桁の idcreatedAt の値は、ここで決まります。

3 で前回の応答を先に消しているのは、/tmp の中身が次のリクエストにも残るためです。
消さずに hsp3cl が落ちると、前回のレスポンスをそのまま返してしまいます。

なお、リクエストごとに全体を起動し直しているわけではありません。
一度起動した bootstrap がループで待ち構えていて、イベントが来るたびに hsp3cl を呼びます。

また、bootstrap の先頭には以下の 1 行が必要でした。

export HOME="${HOME:-/tmp}"

hsp3cl は初期化時に getenv("HOME") の戻り値をそのまま strlen に渡します。ところが Lambda の実行環境には HOME がありません。定義済み環境変数の一覧にも入っていません。
その結果、NULL 参照で SIGSEGV が起き、何も出力しないまま終了コード 139 で落ちます。手元で動かしているときは環境が HOME を用意してくれるため、この問題には気づけません。

HSP3 は AWS の API を直接叩けない

Lambda の中身を HSP3 で書くにあたって、最初に大きな壁がありました。
hsp3cl から AWS の API を直接叩けません。

AWS の API を呼ぶには、最低でも 2 つが必要です。

  1. TLS で暗号化する
  2. SigV4 で署名する(リクエストの内容から計算した文字列を添えて、送信者を証明する)

hsp3cl の Linux 版を調べたところ、どちらもできませんでした。

  • ネットワーク命令は生の TCP ソケットだけで、TLS に対応していません。そのうえソケットを開く命令が inet_addr() で IP アドレスを直接受け取る実装なので、名前解決もできません
  • SHA-256 や HMAC-SHA256 に相当する命令がないので、SigV4 の署名を計算できません
  • 外部ライブラリを呼ぶ FFI はあります。ただし HSP3 の整数は 32bit で 64bit ポインタを保持できないため、libcurl のようにハンドルを返す API を叩く用途には使えません

TLS を張る方法も、署名を自作して生 TCP で送る方法も、libcurl に投げる方法も塞がっています。

ここで方針を決める必要があります。SHA-256 と HMAC-SHA256 を HSP3 で実装して署名まで自作するか、その層は既存の道具に任せるか。
今回は後者にしました。

AWS CLI に通信を委譲する

AWS CLI なら TLS も署名も済ませてくれます。これを HSP3 から起動できれば解決します。

ただ、この起動が素直にいきませんでした。

外部プログラムを実行する exec は Linux 版にもあり、中では libc の system() を呼んでいます。実行自体はできます。
ただし、終了コードが取れません。exit 3 で終わるコマンドを渡しても stat は 0 のままでした。
これでは AWS CLI が成功したのか失敗したのかを判定できません。

そこで、FFI 経由で libc の system() を直接呼ぶことにしました。

#uselib "libc.so.6"
#func shell_exec "system" sptr

#uselib は本来 Windows の DLL を呼ぶための仕組みですが、Linux 版では dlopen として実装されています。
libc をそのまま開いて、system() を HSP3 の命令として生やせます。

これで HSP3 側から以下のように書けるようになりました。

shell_exec "aws dynamodb put-item ..."

system() の戻り値は POSIX の wait status なので、正常終了した場合の終了コードは stat >> 8 で取り出せます。
シグナルで落ちた場合は下位 7 ビットにシグナル番号が入り、右シフトすると 0(成功)に見えてしまうため、下位 7 ビットが非ゼロなら失敗として扱っています(WIFSIGNALED 相当の判定です)。
標準出力はファイルにリダイレクトして、noteload で読み戻しています。

できないことを自作せず、その層は別の道具に任せて自分は上の層を担当する。今回はこの割り切りで進めました。

HSP3 側の実装

Lambda の中身は以下の通りです。

	if route == "GET /todos"         : gosub *op_list   : return
	if route == "POST /todos"        : gosub *op_create : return
	if route == "GET /todos/{id}"    : gosub *op_get    : return
	if route == "PUT /todos/{id}"    : gosub *op_update : return
	if route == "DELETE /todos/{id}" : gosub *op_delete : return

イベントの routeKey を見て担当に飛ばすだけです。if で分岐して gosub で飛ぶ、HSP3 らしい書き方になりました。

これ以外に HSP3 で書いたのは、イベントの解析、バリデーション、DynamoDB へのリクエスト組み立て、レスポンスの組み立てです。

JSON を扱う機能がなかったので、そこも自作しました。json.hsp という別ファイルにしてあります。
パース結果のツリーは作らず、バイトオフセットを返す方式にしました。var 型のパラメータは参照渡しなので、JSON 全体をコピーせずに走査できます。

文字コードは、UTF-8 をバイト列のまま扱っています。JSON の構造文字はすべて ASCII なので、1 バイトずつ見ていけば足ります。
\uXXXX はサロゲートペアも含めて UTF-8 に復号しています。

DynamoDB の呼び出しは、1 リクエストにつき 1 回に収めました。後述しますが AWS CLI の起動が重いので、ここは効きます。

  • 作成: put-itemattribute_not_exists(id) を付ける
  • 更新: update-itemattribute_exists(id)ReturnValues: ALL_NEW を付ける
  • 削除: delete-itemattribute_exists(id) を付ける

条件式が失敗すると ConditionalCheckFailedException が返ります。作成時の ID 衝突は 409、更新と削除で対象がない場合は 404 に振り分けています。
これで「存在するか確認してから書く」ための読み取りが不要になります。更新後の値も同じ 1 回で受け取れます。

AWS CLI が scan を既定でページングする

一覧取得の scan だけは、素直に書いても 1 回で済みませんでした。

AWS CLI は scan を既定でページングします。DynamoDB の Scan は 1 回で最大 1 MB までしか返しません。
そのため AWS CLI が LastEvaluatedKey の尽きるまで裏で呼び直して、結果を 1 つの JSON に結合します。
親切な機能ですが、こちらは 1 回だけ呼ぶつもりでいました。

困るのは呼び出し回数のほうではなく、出力量に上限がなくなることです。

手元で 40 KB の項目を 40 件、合計 1.6 MB ほど入れて試したところ、AWS CLI は全件を 1,605,815 バイトの JSON にして返してきました。
HSP3 側は受け取ったテキストをバッファに読み込みます。そこを 1 MB で確保していたため、JSON が途中で切れてパースに失敗し、500 になりました。

routeKey=GET /todos
scan の応答から Items を読めませんでした
response: status=500

対処は 2 つ入れました。--no-paginate を付けて 1 ページに限定し、あわせて HSP3 側のバッファを 4 MB に広げています。
DynamoDB の JSON 表現は属性名と型タグのぶん元データより膨らむので、1 MB ちょうどでは足りません。

同じ 40 件で測り直すと、AWS CLI の出力は LastEvaluatedKey 付きの 1,084,009 バイトに収まり、200 で 27 件が返るようになりました。
残りは切り捨てです。続きを取る仕組みは実装していません。

委譲した層の中身は、こちらからは見えません。
速くなるかどうかだけでなく、どこまで自動でやってくれるのかも確認しておく必要があります。

AWS にデプロイする前の動作確認は手元で済ませました。
amazon/dynamodb-local を立てて、Runtime API を介さずに HSP3 のプログラムへ直接イベントを渡す統合テストを書き、45 項目を通しています。

動作検証

まず作成します。

$ curl -X POST https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/todos \
    -H 'content-type: application/json' \
    -d '{"title":"牛乳を買う"}'

{"id":"cee51ed5c3f27eaa624d761372b28bea","title":"牛乳を買う","completed":false,
 "createdAt":"2026-08-11T13:29:42Z","updatedAt":"2026-08-11T13:29:42Z"}

GET /todos で一覧を取ると、同じ項目が {"items":[...]} に包まれて返ります。

更新すると、createdAt はそのままで updatedAt だけが変わります。

$ curl -X PUT https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/todos/cee51ed5... \
    -H 'content-type: application/json' \
    -d '{"title":"牛乳と卵を買う","completed":true}'

{"id":"cee51ed5c3f27eaa624d761372b28bea","title":"牛乳と卵を買う","completed":true,
 "createdAt":"2026-08-11T13:29:42Z","updatedAt":"2026-08-11T13:30:24Z"}

createdAt は 13:29:42 のまま、updatedAt が 13:30:24 になりました。
UpdateExpression で触った 3 属性以外に影響していないことを確認できました。

削除すると 204 が返り、そのあと取得すると 404 になります。

$ curl -X DELETE https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/todos/cee51ed5...
$ curl https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/todos/cee51ed5...
{"message":"Not Found"}

日本語も問題なく往復しました。
API Gateway のイベント内でエスケープされた JSON 文字列を復号し、再エスケープして DynamoDB に渡し、また取り出す経路を通っています。

レイテンシの内訳を測ってみた

動いたのはよいのですが、レスポンスが返るまで 1.5 秒かかります。
curl を叩いて一拍置いてから返ってくる、はっきり分かる待ち時間です。

はじめは HSP3 が遅いのだろうと思っていました。
ところが CloudWatch Logs の REPORT 行を見ていて、DynamoDB を呼ばない経路だけ桁がひとつ違うことに気づきました。

title を含まないボディを送ると、バックエンドはデータベースを見に行かずに 400 を返します。その実測が以下です。

経路 Duration
コールドスタート(GET /todos) 5677 ms (Init Duration 157 ms)
DynamoDB を呼ぶ経路(8 件の平均) 1534 ms
DynamoDB を呼ばない経路(400 を返すだけ) 59 ms

59 ms の経路も、bootstrap がイベントを受け取ってから hsp3cl の実行、イベント JSON の解析、レスポンスの書き出しまでを通ったうえでの数字です。

つまり、1.5 秒のうち約 1.47 秒は、AWS CLI を起動して DynamoDB の応答を読み戻すまでの経路で使われていることになります。
HSP3 自体の実行は支配的ではありませんでした。

ただし、この 1.47 秒の中身はひとまとめです。
シェルの起動、AWS CLI(Python)の初期化とモジュール読み込み、認証情報の解決、SigV4 署名、TLS 通信、DynamoDB 側の処理、出力のファイル入出力、それに HSP3 が応答を読み戻して変換する処理まで含まれます。
差分を取っただけなので、この中の内訳までは分かりません。

手がかりになったのは、手元で CPU 制限をかけて測ったときの結果です。
認証情報がなくて通信に到達しない状態でも、AWS CLI の 1 回の実行に数秒かかっていました(この時間には認証情報を探しに行く処理も入っています)。重いのは通信ではなく起動側です。

コールドスタートの 5677 ms も同じ話でした。
DurationInit Duration は別項目で、Init も課金の対象になります(カスタムランタイムでは以前からそうでしたが、2025 年 8 月からはマネージドランタイムを含む全構成が対象です)。実際に請求される時間は REPORT 行の Billed Duration で確認できます。
Init は 157 ms しかないので、ランタイムの初期化は支配的ではありません。余分な 4 秒は初回のハンドラのほうにかかっています。

ここで少しややこしいのが、AWS CLI はリクエストごとにプロセスを起動し直している点です。
Python やモジュールの読み込み自体は毎回発生しているので、初回だけ 4 秒余分にかかる理由はそこではありません。
怪しいのは、200 MB を超えるファイル群にイメージから初めて触るコストです。ただしこの測定では特定できていません。

思い込みのまま直しにかかっていたら、HSP3 のコードを延々と眺めることになっていたはずです。
切り分けに使える経路がたまたま用意できていたのは、運が良かったところでした。

ちなみに、成果物の大きさは以下の通りです。

もの 大きさ
今回書いた HSP3 のプログラム(中間コード) 14 KB
hsp3cl の実行ファイル 278 KB
同梱した AWS CLI 200 MB 超
コンテナイメージ全体 555 MB

アプリ本体は 14 KB です。ファイルサイズで見ると、周辺が 1 万倍以上あります。

メモリを減らせば安くなるのか

Lambda のメモリは 1024 MB を割り当てていました。Max Memory Used を見ると、実際の使用量は 126 MB です。
割り当ての 1 割ちょっとしか使っていません。

使っていないのだから、256 MB まで減らせば料金が下がるはず。そう考えました。

実機で各メモリ設定を測り直すのが本筋ですが、その前に手元で見当をつけてみます。
結果は仮説と逆でした。理由は 2 つあります。

1 つめは、Lambda がメモリの割当に比例して CPU も割り当てるという仕組みです(1769 MB でおよそ 1 vCPU)。
今回の 1.47 秒のうち大きい部分は、AWS CLI が Python の起動とモジュールを読み込んでいる時間のはずです。つまり CPU が効く処理です。

手元の環境で、同じイメージに CPU 制限だけを Lambda と同じ比率でかけて測ってみます。

Lambda のメモリに換算 --cpus AWS CLI の起動時間 1024 との比
1024 MB 0.579 3469 ms 1.0 倍
512 MB 0.289 9764 ms 2.8 倍
256 MB 0.145 40798 ms 11.8 倍

半分で 2.8 倍、4 分の 1 で 11.8 倍です。線形ではなく、絞るほど不利の度合いが増していきます。

なおこれは Docker の CFS クォータによる代替計測で、Lambda の CPU 割当とは機構が違います。
手元が Apple Silicon 上の amd64 エミュレーションなので、絶対値も実機より大きく出ています。見るべきは倍率のほうです。

2 つめは料金の計算式です。Lambda の課金はメモリ量 × 実行時間の GB 秒になります。
メモリを 4 分の 1 にして実行時間が 4 倍以上になれば、積は小さくなりません。安くならないどころか高くなります。

さらに 256 MB では、コールドスタートがタイムアウトに引っかかる懸念もあります。
実機のコールドは 5.7 秒で、そのほとんどが AWS CLI の初回実行です。倍率がかかるのは CPU で決まる部分だけなので、さきほどの余分な 4 秒は据え置いて計算します。
ウォームの 1.47 秒ぶんにだけ 11.8 倍を当てはめても、コールドは 20 秒を超えます。関数の Timeout: 29 と、API Gateway (HTTP API) の統合タイムアウトの上限である 30 秒が見えてくる水準です。

AWS CLI の初期化が CPU で決まる処理であるかぎり、メモリを削れば実行時間が延びるぶん、GB 秒は減りません。
256 MB へ下げることを正当化する材料は得られなかったので、今回は 1024 MB のままにしています。実機で各メモリ設定を 20 回ずつ測って、ウォームスタートの中央値、95 パーセンタイル、コールド、GB 秒を並べれば、もっと確かなことが言えます。そこまでは今回やっていません。

本当に速くして安くするなら、削るべきはメモリではなく、リクエスト経路上の AWS CLI そのものです。
SigV4 を HSP3 で実装し、shell_exec から curl コマンドを叩ければ 1.47 秒の大半が消え、そのうえでメモリを絞れば実際に安くなります。
SHA-256 と HMAC-SHA256 を HSP3 で書く必要があるので、それはまた別の自由研究になります。

HSP3 の言語仕様で詰まった 2 箇所

いちばん時間を使ったのはここです。

書き上げたプログラムを動かしてみたところ、何を送っても空の結果しか返ってきませんでした。
エラーは出ません。プロセスも落ちません。ただ、何も見つけられないという状態です。

こうなるとあてずっぽうで書きかえたくなりますが、それでは直ったのかどうかも分からなくなります。
怪しい部分だけを取り出した小さいプログラムを書いて、条件を 1 つずつ変えて確かめました。
原因は 2 つあり、どちらも想定していないものでした。

1 つめ: モジュール内の変数が静的変数だった

HSP3 の #module 内で、関数の中で使った変数は、既定でモジュール共有の静的変数になります。

つまり関数を入れ子に呼ぶと、呼び出し先が呼び出し元の変数を書き潰します。
JSON パーサで「いまどこを読んでいるか」を保持していた変数がこれで壊れ、ずっと見当違いの位置を見ていました。

作業変数はパラメータリストで local 宣言する必要があります。

#defcfunc local jl_strend var s, int p, local i, local r, local c

後半の local i, local r, local c がその宣言です。呼び出し側は実引数を渡しません。

2 つめ: repeat の内側から return してはいけない

HSP3 の repeatloop の内側から return で抜けると、ループスタックが解放されません。

やっかいなのは、その場では落ちないことです。
何度か呼んだあとに、まったく関係なさそうな場所で error 30(Invalid parameter name)や error 9(Too many nesting)が出ます。
エラーの出た行を見ても、原因はそこにありません。

結果を変数に入れて break し、ループを出てから return する形に統一したら直りました。

このほかにも、次の点を踏んでいます。

  • global を付けずにモジュール外で宣言した #func の命令は、#module 内から呼ぶとコンパイルエラーになります。#func global にするか、モジュール内で宣言すれば呼べますが、今回はシェル実行をグローバルスコープのサブルーチンに集約しました
  • #deffuncintstr として受けたパラメータを、そのまま #funcvar 引数に渡すと実行時エラーになります
  • 文字列リテラルの \n は CR+LF に展開されます。JSON の \n は LF なので、そのままでは比較が合いません

シェルインジェクションを塞ぐ

system()/bin/sh -c に文字列を渡します。
リクエスト由来の値をコマンド文字列に連結すると、その時点でシェルインジェクションになります。

たとえば title に以下の値を入れて送られたとします。

; touch /tmp/pwned; echo $(whoami)

; はコマンドの区切りなので、素朴に組み立てているとコマンドとして実行されます。
データのつもりの文字列が命令に化ける、SQL インジェクションと同じ構造です。

対策は、値をコマンド文字列に載せないことです。
DynamoDB へ渡す値はすべて JSON ファイルに書き出して、--cli-input-json file:///tmp/ddb_req.json の形で渡しています。テーブル名もこのファイル側に入れています。

シェルを使うのは DynamoDB の呼び出しだけではありません。API Gateway はボディを base64 で送ってくることがあり、その復号も base64 コマンドに任せています。
こちらも同じで、復号したい文字列はファイルに書き出し、リダイレクトで読ませています(base64 -d < /tmp/body_b64.txt)。

その結果、コマンド文字列に入るのは、コード側で固定したサブコマンド名とオプション、それにデプロイ時に決まる接続先 URL だけになりました。
この URL は単引用符で囲んでいます。「リクエスト由来ではないから安全」ではなく、攻撃者が書き換えられない値だけを、引用したうえで置くという整理です。

実際に上の文字列を送って、コマンドが実行されないことと、値がそのまま文字列として保存されることをテストで確認しました。

ただ、このテストが見ているのは送った 1 つの文字列についてだけです。
そして「値をコマンド文字列に載せない」という方針のほうは、コードを書き換えれば簡単に崩れます。注意書きやコメントを残しても、書き換える人がそれを読むとは限りません。

そこで、system() に渡す文字列の組み立てに関わる行を許可リストと突き合わせる静的チェックを、ビルドに入れました。
コマンドを組み立てている箇所が増えたり書き換わったりすると、コンパイルより前にビルドが止まります。
許可リストを更新しないと通らないので、この経路をいじるときは必ず一度、リクエスト由来の値が混ざっていないかを人が見ることになります。書いた注意書きが守られるかどうかを運に任せずに済みます。

あわせて、Lambda の実行ロールが DynamoDB に対して持つ権限は GetItem / PutItem / UpdateItem / DeleteItem / Scan の 5 つに絞り、対象も今回のテーブルの ARN だけにしています(このほかにログ出力用の標準ポリシーが付いています)。
仮に AWS CLI へ想定外の引数が渡っても、他のテーブルには触れません。

今回作っていないもの

自由研究の範囲として、意図的に手を付けていない部分です。

  • 認証と認可: 誰でも叩ける状態で公開しています。実運用なら JWT オーソライザや IAM 認証を検討する必要があります。
    なお AWS WAF は HTTP API に直接は付けられないので、前段に CloudFront を置いてそちらに付ける形になります。その場合は execute-api のエンドポイントを直接叩かれる迂回も塞ぐ必要があります。
    暴走時の被害を抑えるため、Lambda の同時実行数を 5、API のスロットリングを毎秒 5 に制限しました。ただしこれは被害を小さくするための設定で、料金の上限ではありません
  • 一覧の続きの取得: --no-paginate を付けて Scan を 1 ページに限定しているため、1 MB を超えるぶんは返りません。応答に LastEvaluatedKey が入っていても無視して切り捨てています。
    しかも 200 を返すので、クライアントからは一覧が欠けていることが分かりません。せめて続きの有無だけでも返す必要がありました。
    件数が増える用途では、続きを取る処理か、テーブル設計の見直し(GSI と Query)が必要になります
  • 強い整合性の読み取り: GET と一覧は DynamoDB の既定どおり結果整合性のある読み取りです。書き込んだ直後に取得すると、まれに直前の変更がまだ見えないことがあります。
    ConsistentRead を有効にすれば揃いますが、読み取りのコストが上がります
  • 更新の競合対策: PUT は存在する項目をそのまま上書きするので、同じ項目への同時更新は後勝ちになります

試してみる

今回のコードはリポジトリに置いてあります。

https://github.com/dafujii/todo-hsp3-lambda

HSP3 の処理系は Dockerfile の中でソースからビルドしているので、手元に HSP3 を入れる必要はありません。
ローカルで試すだけなら Docker だけ、デプロイするならそれに AWS CLI が必要です。

AWS アカウントなしで試す場合は ./local-test.sh を実行します。
DynamoDB Local を立てて、CRUD 一巡、バリデーション、日本語の往復、シェルインジェクションが成立しないことまで、45 項目を自動で確認します。課金は発生しません。

AWS にデプロイする場合は ./deploy.sh を実行します。
AWS_REGIONAWS_ACCOUNT_ID を設定して実行すると、ECR リポジトリの作成からイメージの push、DynamoDB と Lambda と API Gateway のデプロイまで通ります。
指定したアカウントと実際の認証情報が食い違っている場合は、最初に止まるようにしてあります。

デプロイした API は認証なしで公開されます。試したあとは必ず消してください。
スタックを 2 つ削除すれば片付きます。手順はリポジトリの README にあります。
放置すると知らない相手に使われますし、無料利用枠を超えたぶんの料金もかかります。

さいごに

HSP3 で TODO アプリのバックエンドを書いて、AWS で動かせました。

ルーティング、バリデーション、JSON の読み書き、DynamoDB へのリクエストの組み立ては、HSP3 のスクリプトが担当しています。外に出したのは TLS と署名だけです。
ウィンドウを出す言語という印象でしたが、ウィンドウを持たないまま Web API として動いています。

動かすまでには、HOME が無いだけで落ちたり、モジュール内の変数が共有されて壊れたりしました。
それでも、できない層を別の道具に渡してしまえば、その上は HSP3 で書けます。

くり返しになりますが、ご自身の AWS アカウントにデプロイした場合は、試したあとに消しておいてください。認証なしで公開されたままになります。

HSP でできるのかなと思い AI に丸投げしてみましたが、このブログ記事に書かれている内容込みの TODO API を実装し AWS にデプロイできるところまで作ってくれました。
「HSP でこんなことができるのか」と「AI はここまでやってくれるのか」と二つの意味で驚かされました。

皆さんも HSP でこんなことができるんだな、と思っていただけたら嬉しいです。

この記事をシェアする

関連記事