S3に何らかのファイルが置かれたとき、そのことをアプリケーションに通知したいとき、S3イベント通知を使うと便利なのだが、いくつかハマりどころがあったので、備忘しておく。
S3イベント通知は、特定のバケットにおいて何らかの操作がなされたとき、そのことを連携しているサービスに通知してくれる。コンソールをポチポチするだけでイベントオブジェクトをLambdaが処理してくれるようになるので大変便利だ。
このイベントオブジェクトの構造は以下のようになっている。*1
{ "Records":[ { "eventVersion":"2.2", "eventSource":"aws:s3", "awsRegion":"us-west-2", "eventTime":"The time, in ISO-8601 format, for example, 1970-01-01T00:00:00.000Z, when Amazon S3 finished processing the request", "eventName":"event-type", "userIdentity":{ "principalId":"Amazon-customer-ID-of-the-user-who-caused-the-event" }, "requestParameters":{ "sourceIPAddress":"ip-address-where-request-came-from" }, "responseElements":{ "x-amz-request-id":"Amazon S3 generated request ID", "x-amz-id-2":"Amazon S3 host that processed the request" }, "s3":{ "s3SchemaVersion":"1.0", "configurationId":"ID found in the bucket notification configuration", "bucket":{ "name":"bucket-name", "ownerIdentity":{ "principalId":"Amazon-customer-ID-of-the-bucket-owner" }, "arn":"bucket-ARN" }, "object":{ "key":"object-key", "size":"object-size in bytes", "eTag":"object eTag", "versionId":"object version if bucket is versioning-enabled, otherwise null", "sequencer": "a string representation of a hexadecimal value used to determine event sequence, only used with PUTs and DELETEs" } }, "glacierEventData": { "restoreEventData": { "lifecycleRestorationExpiryTime": "The time, in ISO-8601 format, for example, 1970-01-01T00:00:00.000Z, of Restore Expiry", "lifecycleRestoreStorageClass": "Source storage class for restore" } } } ] }
なので、追加されたファイルに何らかの処理をしたいのならば、Records[].s3.object.key
をもとにバケットにアクセスし、あれこれ触ればよい。
どこにハマったか
LambdaのJavaScriptランタイムに手軽なHTTP通信用のライブラリは組み込まれていない
コンソールでポチポチしてちょっとしたHTTPリクエストを送るものを探索的に実装したかったので、JavaScriptでLambdaを書いていた。こういう感じなので、組み込まれていないライブラリに依存したコードは書きたくない。こんなとき、Lambdaではどうしたらいいのだろう?
最新のNodeJSにはfetch APIが登場しているが、LambdaのNodeJSランタイムのバージョンは20で、ギリギリ安定化されていないので、使えない。11月には22のランタイムが登場するようなので、ギリギリ間に合わなかったようだ。
そんなわけで、httpsというモジュールを使う必要がある。
使い心地については、リンク先にある以下のサンプルコードがわかりやすいと思う。
const https = require('node:https'); const options = { hostname: 'encrypted.google.com', port: 443, path: '/', method: 'GET', }; const req = https.request(options, (res) => { console.log('statusCode:', res.statusCode); console.log('headers:', res.headers); res.on('data', (d) => { process.stdout.write(d); }); }); req.on('error', (e) => { console.error(e); }); req.end();
見ての通り大分低レベルなAPIで、手軽なスクリプトを書く目的からするとtoo muchな感じがある。自分は、最後にreq.end()を呼ばないと通信が終了しないことに気付かず、Lambdaを何度かタイムアウトさせてしまったことをここに懺悔したい。
httpsでリクエストを組み立てるとき、Content-Lengthの計算方法には工夫が要る
上記の通り、だいぶ低レベルなAPIだから、POSTリクエストを送るときにリクエストボディを見てContent-Lengthを良い感じに設定してくれる仕組みなどはないようだ。なので、自分で計算する必要がある。
このとき、サンプルコードなどを見ながら、何気なくこんな書き方をして大失敗をした。
const payload = JSON.stringify(payloadObject); const options = { method: 'POST', headers: { 'Content-Length': payload.length } };
なぜか?Content-LengthとString.lengthの仕様を見ればわかる。
Content-Length エンティティヘッダーは、受信者に送信されるエンティティ本文の長さをバイト単位で示します。 developer.mozilla.org
length データプロパティは、String オブジェクトの文字列長を UTF-16 コード単位の数で表します。 developer.mozilla.org
長さの定義が揃っていないのである!このため、うまくペイロードを送信できず、受付先の処理が失敗していた。この不具合は、ASCII文字のみのファイル名では顕在化しないため、原因箇所に気付くのが遅れてしまった。
解決するには、Buffer.byteLength(payload, 'utf8')
のように書けばよいようだ。
S3イベント通知では、keyに日本語が含まれる場合、エスケープされる
読んで字のごとしである。keyに日本語が含まれないケースでは成功するのに、日本語が含まれると失敗するという不具合を調べていたところ、S3イベント通知のオブジェクト内ではkeyがUTF-8のエスケープ文字列に変換されていた。これをそのままHTTPリクエストに詰めて別アプリケーションに送信すると、実際と異なるキーにアクセスしにいって空振りしてしまう。これを回避するには、Lambda内で文字列のエスケープを解除してやるとよい。エスケープの解除は、decodeURIを使えばよい。
まとめ
あまり触ったことのない道具を使ったら火傷した、という話に集約されると思う。手元でちょっとしたお試し、という段階でこういうことに気づけたのはよかったけれど、ドキュメントを斜め読みせず見ていたらもっと事前に気づけたところもありそう。なので、ドキュメントをもっときちんと読めると良いですね、という教訓で締めくくりたい。