LambdaでS3イベント通知を受け取ってHTTPリクエストを送信したいとき気をつけること

S3に何らかのファイルが置かれたとき、そのことをアプリケーションに通知したいとき、S3イベント通知を使うと便利なのだが、いくつかハマりどころがあったので、備忘しておく。

S3イベント通知は、特定のバケットにおいて何らかの操作がなされたとき、そのことを連携しているサービスに通知してくれる。コンソールをポチポチするだけでイベントオブジェクトをLambdaが処理してくれるようになるので大変便利だ。

docs.aws.amazon.com

このイベントオブジェクトの構造は以下のようになっている。*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のランタイムが登場するようなので、ギリギリ間に合わなかったようだ。

docs.aws.amazon.com

そんなわけで、httpsというモジュールを使う必要がある。

nodejs.org

使い心地については、リンク先にある以下のサンプルコードがわかりやすいと思う。

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') のように書けばよいようだ。

nodejs.org

S3イベント通知では、keyに日本語が含まれる場合、エスケープされる

読んで字のごとしである。keyに日本語が含まれないケースでは成功するのに、日本語が含まれると失敗するという不具合を調べていたところ、S3イベント通知のオブジェクト内ではkeyがUTF-8エスケープ文字列に変換されていた。これをそのままHTTPリクエストに詰めて別アプリケーションに送信すると、実際と異なるキーにアクセスしにいって空振りしてしまう。これを回避するには、Lambda内で文字列のエスケープを解除してやるとよい。エスケープの解除は、decodeURIを使えばよい。

developer.mozilla.org

まとめ

あまり触ったことのない道具を使ったら火傷した、という話に集約されると思う。手元でちょっとしたお試し、という段階でこういうことに気づけたのはよかったけれど、ドキュメントを斜め読みせず見ていたらもっと事前に気づけたところもありそう。なので、ドキュメントをもっときちんと読めると良いですね、という教訓で締めくくりたい。

「初めてのGo言語」を読んだ

Goと仲良くしていく必要性を感じていたので、書店でいろいろ見比べて買ってきた。プログラミングしたことない人向けの記述は不要だったので、Go特有の概念や考え方がよくまとめられているのは非常に使いやすいと感じた。通読しても良いし、必要なときにリファレンスとして紐解くにも便利な構成になっているから、今後も机のそばに置いておきたい。

以下は本書に基づくGoに対する第一印象

基本的にはふむふむという感じで読んでいた。エラーを例外として扱わず関数の戻り値として返すコンセプトなどは今も仕事でやっていて困っていないし、よいのではないか。あまり並列処理を明に扱った経験はないのだけど、ゴルーチンは便利そう。以前別の言語で軽く触ったときは、同期処理とつなぎこむところがもう少しおまじないめいていて難しかった記憶がある。うまい抽象化ができているんだと思う。

なるべく安全に寄せて、便利な自動変換は予期せぬ不具合の温床になる、明示的にできるものは明示的にしようという考え方自体は理解できた。しかし、イミュータブルみたいな話になると、「ここは人間を信頼しよう」みたいな話になるのが呑み込みきれなかった。もう一つのコンセプトとして高速にコンパイルしたいという事情があって、トレードオフの結果ということなんだろうか?仮にそうだとすると、一般的な開発環境の計算速度が向上したり、コンパイル最適化の技術が進歩していけば、明示的にイミュータブルであると宣言できるようになるのだろうか*1

さて、ここまでは机上の話。今度は実際に手を動かして何かを作ってみたい。

*1:実際、本書によればGoの初期にジェネリクスがなかったのは、コンパイル速度を犠牲にせず実現する方法がなかったからだそうだし

初のNASを迎え入れて対峙したいくつかの課題とその打開策など

まとめ

  • NASを買った
  • バックアップにはS3 Glacier Deep Archiveを採用したが、いくつか注意点がある
  • IOPSが足りない気がして設定をあれこれ見直したけど、効果を定量的に測定しきれていない
  • 折角買ったのでいろいろ活用していきたいね

なぜNASを買ったか

ネットワーク機器スペース(通称わくわくインターネットランド)に鎮座するNAS

同人活動などもやっている都合、大容量のファイルをずっとアーカイブしたい事情があった。これを今まではDropboxでやってきていたのだけど、いい加減なんだか複雑な気持ちになってきたのだ。

もちろん、Dropboxは素晴らしいサービスだ。ファイルの共有も簡単にできるし、バージョン管理されているから、操作ミスでファイルを壊してしまってもなんとかなる可能性が高い。お金さえ払えば、それなりのディスク容量も提供してくれる。

ただし、あくまでデータは人に預けたものだし、毎月1000円以上のお金を払う必要はある。その価値はあるけれど、今後もずっと払い続けていく固定費と考えると悩ましいものがあった。

とはいえ今まではそうしてきたし、大きな不満はなかった。ではなぜNASを買ったかというと、以下のような事情がある。

  • ライフステージが変わり、同人活動以外でも共有すべきデータが増えてきた
  • 音楽や写真などのデータが溜まってきていて、Dropboxにバックアップすべきデータとそうでないデータに仕分けるのが苦痛になってきた
  • 職業柄、フルマネージドのサービスばかり触っていてもよくないと思った

どんなNASを買ったか

こんなのを買った。ディスク容量は4TBだが、もちろん増設はできる。RAIDは今のところ組んでいない。これによる信頼性の低下については、後述するがS3 Glacierへのバックアップによって担保したいと考えている。

なぜこれにしたかというと、以下のようなことを考えたからだ。

  • HDDの交換が簡単そうな作りになっていた
  • マルチギガビット対応なので、10Gbps環境のおうちネットワーク環境を最大限生かせる
  • 機械学習による画像のタグ付け機能などが充実していた

課題と対応

バックアップをどうするか

NAS一台では故障のリスクは無視できない。ディスク破損によりデータが飛んだりしたら最悪だ。このリスクは、RAIDを組んでも低減しきれないと考えたので、RAIDは組まないことにした。代わりに採用したのが、S3 Glacier Deep Archiveへのバックアップだ。

masawada.hatenablog.jp

上記の記事を大いに参考にした。ただし、この後NASのOSがアップデートされたのか、AWS側でライフサイクルポリシーを変更しなくても、直接S3 Glacier Deep Archiveのストレージクラスを指定してアップロードできるようになったようだ。便利便利。

クラウドストレージを脱出して結局クラウドストレージに依存してるじゃん、という気はするけれど、コストを計算してみると月々数ドルとかになりそうだったので許容した。

ただし、この点については二つ失敗をしたのでここに書き留めておく。注意されたい。

初回のバックアップはそれなりに費用が掛かる

当たり前の話ではある。しかし、初回は試行錯誤も発生するので尚更お金が掛かりやすいということに留意したほうがよい。多分、最初はあまりデータ量のない状態で何度か試すとよかったのだと思うけど、一度入れてしまってからバックアップを取り始めたから後の祭りである。

アップロードに失敗した場合にゴミデータが残り続けることに注意

コスト状況を確かめようとCost Explorerを見ていたら、DeepArchiveStagingStorageという見慣れない費用が随分掛かっていた。

ある日のコスト発生状況抜粋

なんだこれ?調べてみると、マルチパートアップロードが途中で止まった場合に残ったデータたちらしい。

Glacier Deep Archive ストレージクラスのオブジェクトの CompleteMultipartUpload (複数アップロード完了) リクエストが完了する前に、マルチパートオブジェクトに使用されているバイト数。

docs.aws.amazon.com

何度かバックアップに失敗したので、そのときに作られてしまったのだろう。これはしかるべきライフサイクルポリシーを設定することで、定期的に削除することができる。

ゴミデータを削除するためのライフサイクルルール

この点にメスを入れることで、一日0.2USD程度で大量のデータを保存できるようになった。やったね。

なんか転送が遅いんだけど

SMBでマウントしていると、なんだか転送が遅い気がした。具体的に言うと、RPGツクール製のゲームを起動するときめちゃくちゃ待たされる、など*1スループットは数百Mbps前後出ていそうなので、IOPSが足りていないのではないか*2。そう考えて、非同期IOを有効化するなどの手を打ったらだいぶ改善した気がする。これについてはあまり定量的な議論ができておらず、やや歯がゆい。うまいモニタリングの方法はないだろうか。

非同期IOはクラッシュ時などにデータ消失のリスクもあるはずだけれど、今回はそれを容認することにした。大量のファイルを転送するときはカット&ペーストでなくコピペしてから削除するような小技を使うことになるかもしれない。

NASを導入して嬉しかったこと

メディアサーバーが家で稼働する形になり、家のオーディオ機器から手軽に音楽が再生できるようになったのはよかった。オーディオ機器からの再生の都合でストリーミングサービスに頼りがちになっていたけれど、これでおうちの音源を手軽に流すことができる。

あとは、Dropboxの容量節約のためにクラウドストレージを複数管理するような苦しみから解き放たれたのも嬉しいですね。

今後やりたいこと

NASのモニタリング

今はほぼモニタリングできていない。Mackerelなどを導入して稼働状況を監視できるようにしたい。Mackerelのインストールはコマンド一発というわけにはいかないようだけど、以下のようなグッズを提供してくださっている方がいるところまでは調べることができた。

github.com

SSDキャッシュの導入

SATAポートが一つ余っているので、SSDキャッシュを使えば高速化が図れるのではないか。そのうち懐が暖まったらやってみたい。

UPSの導入

なんとUPSがない。絶対いい状態ではないけれど、結構高いのよね……。SSDキャッシュよりは優先度高いと思う。おすすめUPS情報お待ちしています。

アプリケーションを動かしてみる

QNAPのNASはコンテナイメージを持ってきて稼働させることができるようだ。折角なので、この辺をうまく使って面白いことができないか模索したい。

*1:ゲームはローカルに置けという主張は正しいけど、軽いゲームくらいならNASに置けないかなと思ったのだ

*2:今時のツクールはJavaScriptで動いているので、結構な数の.jsファイルを転送していそう

Androidアプリ開発に入門してみた

サーバサイドのアプリケーションやコマンドラインツールは作ったことがあるけれど、考えてみるとモバイルアプリというものは作ったことがなかった。何事も経験ということでやってみる。なぜAndroidかというと、普段使っているのがAndroidであるため。

環境構築

プライベートではWSLで開発しているので、以下の記事を参考にさせていただいた。

zenn.dev

usbipdのインターフェイスは少し変わっているようで、上記の記事通りにやると怒られた。

$ usbipd: error: The 'wsl' subcommand has been removed. Learn about the new syntax at https://learn.microsoft.com/windows/wsl/connect-usb#attach-a-usb-device.

最初にWSLと共有可能なように設定し、その後バインドという流れになるようだ。共有可能とするには管理者権限が必要。sudoが欲しい!!!

$ usbipd bind -b 2-1
$ usbipd attach --busid=2-1 --wsl

インストーラの表示がまあまあグリッチしていて不穏。後でWSLをアップデートしたら治ったので一安心である。しかし、どうにも動きがもっさりだし、うまくデバイスを認識してくれている様子がない。心配。バッテリーも心もとなくなってきたので、Hello, worldするには至らなかった。

何を作るか

環境構築をしながらつらつら考えてみる。自分は何かを作るときには日常のお困りを解決するグッズを作ってみることにしているのだが、いまいち思い浮かばない。正確に言うと思い浮かばないは嘘なのだけど、サーバサイドとの連携が必要なアイディアしかなかった。それはそれで悪くないのだけど、初手でやることではないな、と思った。 要するに、スマートフォンに何ができるのかよくわかってないのだな。

今後すること

  • やっぱりこのノートPCでは限界がありそう。家のデスクトップPCとかでやる必要があるのではないか
  • 最初の一冊的な本を見つける
  • モバイルアプリの特徴は、端末にくっついている様々な機能を使えることだと思うので、どんなものがあるか調べる

この関数を使われるんですか? はい、それでしたらまず関数利用申込書を記入いただいて……

世の中には、軽々に使ってほしくないメソッドというものがありますよね。通常のフローと異なる経路でデータを削除するメソッドとか。 手っ取り早くdeprecatedにしてしまえればいいんですけれど、何かしら理由があれば許可し続けたいものもあるんですよね。

同僚がこういうことで困っていて、ジャストアイディアを口にしたら参考になったと喜んで貰えたのでメモっておきます。

TL,DR;

  # 緊急時に通常のバリデーション等を無視して削除するメソッド
  # 理由なしに緊急削除をしようとしたら実行時エラー
  #
  # 引数なしにおける関数定義
  # 引数がなければ実行時エラーする
  @spec emergency_delete :: none
  def emergency_delete do
    "Do not use this without a reason!" |> raise
  end

  # 理由があるなら許すよ
  # でも空文字列は許さん
  @spec emergency_delete(String.t()) :: :ok
  def emergency_delete(reason) when is_bitstring(reason) and byte_size(reason) > 0 do
    "Deletion completed without any validation!"
    |> IO.puts
  end

思いついた背景

紙の書類などの文化で、「例外的な手続きをするので書類のここに理由を書いてね」みたいなものがよくある気がする。これをコードで素直に表現してしまえ。

コメントと比べて何が嬉しいの?

  • コメントを見ずにうっかり使った人を確実に弾ける
  • コードレビューする側としても、呼び出し先のコメントを見に行くより気付きやすい
  • lintツールが十分整備されていない状況でも使える
  • 型に依存しないので、動的型付け言語でも使える

課題

  • 実行時エラーなのがイマイチ。コンパイルエラーやlintなどで落としたいですよね
  • コードの中に長い自然言語が現れるので頻出するとノイジーかも

(おまけ)なんでテストコードがElixirなの?

同僚がElixirわからんと言っていたので、ついでに話題にしたかった。一旦どの辺がわからないのか、などの叩き台にしたいので、自分が特徴的だと思う部分を雑に話題にしておく。Supervisorがどうのという話もあるけれど、今は一旦構文の話をしておく。

  • |>はパイプライン演算子で、左辺の評価結果を右辺にある関数の第一引数に代入する
  • 同じ名前の関数を複数定義した場合は上から順にパターンマッチを試していって、マッチした関数が適用される
    • パターンマッチは引数の数意外にも細々条件が付けられる
  • 何一つマッチしなければFunctionClauseErrorが発生する
  • Erlangのモジュールはセミコロンを先頭に付けた形で呼び出す(例: :rand.uniform(n))
  • @spec... で型を註釈することで静的解析してくれる

GitHub CoplilotとMermaidの組み合わせが結構便利っぽい

皆さん、図はお好きですか。作るのは好きなんですが、途中で「この図は本当にわかりやすいのか?」ということが気になってしまいがちなvilagiaです。

個人的にMermaidで作図をしていたんですが、GitHub Copilotを動かしたVSCodeでやっていたら案外と便利だったのでメモがてら書いておきます。

結論

  • GitHub Copilotはインデントされた箇条書きをそれなりに補完してくれる
  • GitHub Copilotは開いているファイルもネタ元にして補完してくれているらしい
    • 成果物を書きながら「この辺って図にまとめるとどうなってるんだっけ?」をMermaidで描き始めると、大項目に対応した中小項目を100点満点中60点くらいの感じで出力してくれる
  • もう少し詳しく

あくまでイメージだけれど、例えば以下のような入力をしたとする

mindmap
root((推理小説))
    探偵
    事件
    

すると、こんな感じのサジェストをしてくれる。

mindmap
root((推理小説))
    探偵
    事件
        犯人
        被害者
        証拠

GitHub Copilotの動作例。推理小説の構造をMermaidによって記述しようとしている。

もちろん人間がやりたいことと違うこともあるけれど、そこは適宜ツッコミを入れつつ直していけばよい。自分が予期していなかった有用な切り口の項目が一つでも出てくれば儲けものである。

Mermaidである必要なくない?

それはそう。ただ、human readableな書式であるからこそ良い感じの補完が効きやすいのではないかなー。そして、超凄いオートコンプリートによってにょきにょき図が生えていくのは楽しい。便利なVSCode拡張があるので導入も楽。お勧めです。皆さんもお試しあれ。

感想

こういうものが普及してくると、便利なオートコンプリート機能を持つシステム(例えばGitHub)にあらゆる情報を集約した方が便利という話になってきそう。軽量なメモツールとかが不利になってくるのかもしれないなぁ。それはそれでなんだか残念なので、うまく生き残ってほしい気がする。

音声認識を使い倒すと言う試み

最近家にあるいい感じの安楽いすにタブレットアームをつけた。これは結構良いのだが、文字入力がめんどくさいと言う問題があった。また、自分は何か考え事をする時、テキストにまとめたりするのだけれど、整った文章を書こうと言う意識に引きずられて考えの内容のほうに意識が向いていないなと思うことがままある。

これらの問題をいちどに解決するのに、音声認識機能で文字起こしをすることが有効なのではないか。そう考えて昨日あたりから試し始めている。この試みは7割程度うまくいっているように感じる。キーボードを叩いたりするのではなく、考えている対象について意識を向けながら目の前に表示される自分の考えを反芻する。どうせ完璧な文章は、このやり方では作れないので、つらつらと発散的な考えを行うのには余分な神経の使い方をせずに済んでいる。

じゃぁ残りの3割は何なのかと言うと、多分この文章を読んだ人にはある程度わかっているのではないだろうか。この文章はほぼ口述に基づいて書かれていて、キーボードを使っていない。見ての通り大変散漫な文章である。下書き、あるいは下書きの下書きを作るために、自分の中にあるものを一旦ダンプする場合には、これは問題にならない。しかしiPadの自動文字起こし機能はおばかである。ちょっと難しい語彙を出すとすぐに全然違う変換をしてしまう。機械学習マルコフ連鎖か何かそういう技術を使っていて、この話の流れならこの単語が出るだろうと言うようなものが文字起こし処理の中で使われているように感じられる。結果として、いくらはっきりと言い直したとしても、iPadが信じる自然な流れに反する単語が登場した場合、かたくなに変換結果を変えようとしないことがある。今出した語彙という言葉も、最初は全然違う単語として変換されていた。こういうところに神経を使っていると、一旦集中が削がれてしまって、ダンプ処理が妨げられてしまっているような気がする。

7割はうまくいっているので、このやり方はもうしばらく続けてみたいと思っている。けれど、残り3割の欠点は結構大きいので、そのうち嫌になってやめてしまうかもしれない。そうなる前に劇的なバージョンアップが入って、これらの欠点がいちどに解消してしまえば嬉しいのになぁ。これは願望です。

技術でなんとかするならば、この文章を生成AIに丸投げして編集や構成を勝手にやってもらえたら便利そうである。しかしガーベッジインガーベッジアウトであるから、変換等がガタガタなものが入力されてしまえば生成AIも結構困るのではないか。まぁこんなことをうだうだ言っているよりさっさと試してみろと言うのはもっともな話なので、気が向いたらやってみようと思う。

おまけ

使っている安楽いすは、IKEAで売られている以下の製品だ。お値段の割にすわり心地が良いのでお勧め。ただし長く使っているとだんだんボルトが緩んでくるので適宜増し締めをするという注意は必要。この辺は値段なりだなと思う。

https://www.ikea.com/jp/ja/p/poaeng-armchair-birch-veneer-knisa-light-beige-s19240788/