maybe daily dev notes

私の開発日誌

リモート会議で 「今声聞こえてますか」 の確認、いる?

問題提議

リモート会議などで良く耳にする、初めて発話する人の第一声 「今私の声聞こえてますか」、これが必要なのか考えてみた。

この一言は次のようなシチュエーションでしばしば聞かれる。

司会 「… それでは、次はXXさんどうぞ。」
XX 「はい、よろしくお願いします。今私の声聞こえてますか?」
司会 「はい、大丈夫です 🙆」
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

このやり取りの欠点は、あまりにも冗長なことだ。体感的にはほとんどの場合問題なく声が聞こえるので、わざわざ確認しなくて良いのではと感じる人も多いだろう。この記事では、所要時間の観点からこの確認の要不要を検討する。

他のやり方と比べてみる

試しに、以下のダイレクトに話を始めるケースと所要時間を比較してみたい。なお、以下では↑の方を 慎重版 、 ↓の方を 直接版 と呼ぶ。

司会 「… それでは、次はXXさんどうぞ。」 
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

この時、XXが話を始めるまでのパターンは以下の4つが考えられる。 なお、ここでは簡単のため以下の仮定を置く:

  • 正常系は、XXの環境に異常がなく、正常に通信できる場合とする。
  • 異常系は、XXのマイクにのみ不調がある場合とする (XXは声は通らないが司会の声は聞こえる)。それ以外の異常は考えない。
  • 司会者は10秒間XXさんの声が聞こえなければ異常とみなし、その旨をXXに伝える
  • XXは3秒返事がないかあるいは司会から伝えられたときに、自分の環境に異常があることに気づく
  • XXは環境の異常に気づいたら、10秒後に必ず修正できる
  • その他の時間は適当に仮定しているが、パターン間で一貫していれば結論に影響はない。
# パターン1: 慎重版 正常系 合計8秒
司会 「… それでは、次はXXさんどうぞ。」 (T=0)
XX 「はい、よろしくお願いします。今私の声聞こえてますか?」 (T+5秒)
司会 「はい、大丈夫です 🙆」 (T+8秒)
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

# パターン2: 慎重版 異常系 合計21秒
司会 「… それでは、次はXXさんどうぞ。」 (T=0)
XX 「はい、よろしくお願いします。今私の声聞こえてますか?」 (T+5秒)
XX 返事がないため環境の異常に気づく (T+8秒)
司会 「XXさん、声が聞こえないようです🙅」 (T+10秒)
XX (環境を修正して) 「今私の声聞こえてますか?」 (T+18秒)
司会 「はい、大丈夫です 🙆」 (T+21秒)
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

# パターン3: 直接版 正常系 合計0秒
司会 「… それでは、次はXXさんどうぞ。」 (T=0)
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

# パターン4: 直接版 異常系 合計23秒
司会 「… それでは、次はXXさんどうぞ。」 (T=)
XX 「ありがとうございます。それでは、話を始めたいと思います。…」 (T+5秒)
司会 「XXさん、声が聞こえないようです🙅」 (T+10秒)
XX (環境を修正して) 「今私の声聞こえてますか?」 (T+20秒)
司会 「はい、大丈夫です 🙆」 (T+23秒)
XX 「ありがとうございます。それでは、話を始めたいと思います。…」

慎重版の時間的な利点は、異常系の際に発揮される (パターン2と4に注目)。慎重版ではより早期に異常に気付けるので、対応が早まるのだ。この効果はどれほどだろうか。

ここでは、異常系に遷移する確率をpとしよう。このとき慎重版と直接版それぞれで、話を始めるまでにかかる時間の期待値は下式となる:

慎重版: 8*(1-p) + 21*p = 9 + 13p
異常版: 0*(1-p) + 23*p = 23p

このため、慎重版の方が期待値が短いのは 9+13p < 23p 、つまり p > 0.9 の時となる。90%以上の確率でマイクに不調が発生する限界環境では、慎重版のやり取りを使おう!それ以外のありふれた環境では直接版の方がお得である。

ちなみにパラメータは恣意的に慎重版が有利になるように決めている。実際は司会者はXXの声が聞こえるまで10秒も待たないはず。今回の設定では、司会者が8秒以内に異常に気づける場合は、慎重版でも直接版でも異常系にかかる時間が等しくなるため、慎重版の時間的メリットはなくなる。

結論

話し始めるまでにかかる時間の期待値だけを考えると、「今聞こえていますか?」の確認をするメリットがある状況は考えづらい。確認せずに本題を話し始めよう。


こういう話ってよくあるよね

ということでこの記事は一旦結論がついたのだが、これだけで終わるのも何なので、ついでに他の話との関連性を無理やりもたせてみる。

この話の要点は、何らかの分岐処理をする際はそれぞれの分岐先に遷移する確率を考えれば最適化できるんじゃないという点。こういう状況はよくあって、例えば:

楽観ロックと悲観ロック 楽観ロックでは、基本的に処理の競合が起こることはないだろうと見積もって排他処理を行う。このためもし競合が起きた場合に解決するコストは高いが、競合が起きない状況では比較的低コストで処理することができる。悲観ロックはその逆で、競合が頻繁に起こる状況で利用する。

CPUの分岐予測 私がつい先日学んだ話。コードを書く際に if else のどちらに処理が飛びがちかをコンパイラに教えることで、CPUに処理を最適化させることができる。こちらの記事も参照。

tmokmss.hatenablog.com

シャワーを浴びながらふと思いついた題材だったが、意外と面白い話だったので記事にしてみた。おわり

P.S.

とはいえ慎重版のやり取りにも効用はあり、例えば以下が挙げられる。

  1. 定型句のやり取りをすることで、話者の緊張を和らげる
  2. 第一声として無難な発言をして、話者の喉を整える
  3. この言葉に紐付けて、マイクミュートの確認を習慣化できる

他にも比較の観点があれば、ぜひ教えて下さい。

MariaDBコントリビューション録その6 (完) - どのOSSにコントリビュートするのか

前回のあらすじ

MDEV-18873に取り組み中。これはクエリ内で指定されたperiodの名前が空文字列 (``) であるときに、MariaDBがクラッシュするというバグだ。

ALTER TABLE t ADD PERIOD IF NOT EXISTS FOR `` (s,e);

一旦自力で考察してPRを作成したが、レビュワーの指摘を受け推論に粗があることに気づく。さらに考察を深めよう。

tmokmss.hatenablog.com

考察する go deeper

これは2ヶ月前に取り組んだため、正直詳細は忘れてしまった。ざっくりとまとめる。

もともとは過程で呼び出される末端の関数にNULLチェックを入れていた。これはクエリの処理の中ではかなり後段での対処となるため、あまりにも対症療法的で、実は多くのバグを取りこぼすことになる。

より良い方法は、できる限り処理の前段でエラーにしてしまうことだ。結局の所根本的な問題はクエリ内の period name が想定しない文字列になっていることなわけで、これは period name として扱われるトークンを取り出せた時点で検証可能である。最終的にはクエリを字句解析する時に、period name となる文字列をバリデーションする形となった。

この考察をするにあたり、メンテナのニキータさん (@FooBarrior) からは以下の助言をいただいた (雰囲気で意訳):

  • 現象の根幹に当たれ。木を見て森を見ずのような状態に陥るな。
  • 他の類似処理 (今回はcolumn nameやtable name) でどうやっているのかを探れ。同じ解決策が適用できることも多いはずだ。
  • 既存のコードベースが長大だからといって読まない言い訳にはならない。適切な問題解決のためには徹底的なコードリーディングと深い考察が必要だ。

大変身に染みるアドバイスだった。今後同じような活動をしようとしている読者の皆様にもぜひ意識してみてほしい!ちなみに今回は、報告されているケース以外にも問題となるクエリを作ってみて、さらにそれらがなぜ問題となるのかをコードを追いながら考えることで、根本原因の吸い上げに成功した。いわゆる帰納法に近い考え方だろう。おそらく他のトラブルシューティングでも普遍的に有効な方法と思われる。

squash必要?

こぼれ話。MariaDBではPRを出す際にすべてのコミットを1つにまとめて都度 force push しながら修正していくことが求められている。 最後にGitHubでSquash mergeすれば良くない?と思うが、これはマージ前にメンテナが別ブランチでテストするときに1コミットだと都合が良いということ。

オールウェイズforce push

案外この規則を採るプロジェクトは他にもあるようで、例えば Ruby on Rails なんかもそう。rails/rails

force push運用は過去の作業・レビューログが消えるなどなかなか受け入れがたいが、素直に郷のしきたりに従うことをオススメする。 よほど今後もそのOSSにコントリビュートしたく、自分が現状を改善してやるんだという気概を持っている場合はその限りではないかもしれない。ただし、それを変えるには技術的なブロッカーがあるかもしれないし人的なブロッカーもあるだろう。茨の道であることは覚悟しなければならない。

完結

上記のようなやり取りをしつつ、途中2ヶ月ほど期間が空いてしまうこともあったが、無事 マージされたのだった 🎉

ちなみにこの検証中に関連する別のバグを見つけたが、それはレビュワーと話して今回のPRのスコープ外としてもらった。(ちょっとSQLの知識が足りなすぎてキツくなってきたのが本音… そもそも period 自体MySQLにはない機能なので、未だにあまりピンときていない) 直したいという方はこちら!

以上が今回のIssueの顛末。割と区切りも良いので、このシリーズは一旦ここで締めようと思う。最後にこれまでの(わずかばかりの)MariaDBへの貢献で考えたことをまとめる。

MariaDBにコントリビュートしてみて

今回MariaDBにコントリビュートしてみて良かったことは沢山ある。例えば

  • バグ修正はパズル的な要素があり面白い
  • C++の大規模コードに触れることが普段ないので新鮮
  • 普段触っているコードよりははるかに複雑なので、良い頭の体操になる
  • RDBMSの裏側をすこーーしだけコードレベルで覗けて、やや心理的な抵抗が下がった

ただ、少しつらいところもあって、こんな感じ。

  • SQLの標準に関する知識がないと自力で修正するのがキツい。そもそもどう直すべきかを決められないことも (今回だとperiod nameに求められる具体的な形式など。)
  • あまり知識のない自分に対してメンテナのレビューコストを割いてもらう申し訳無さ。パズルみたいで楽しー😆 なんて遊んでいる場合ではない。
  • 今の仕事とはやや縁遠い知識にはなるので、より趣味的な学習となる

ここらへんのPros Consは人によって全然変わるので、Prosが圧倒的に上回る人も多いはず!私にはMariaDBが最適というわけでもなさそうだなと感じたという話。

そんなわけで最近はめっきり普段使うソフトウェアにばかり手を付けている。特にAWS CDKは、以下の点でちょうど良い感じ。

  • 自分が慣れた技術スタックなのでメンテナに初歩的な手間を掛ける事は稀
  • 毎日使うツールなので、機能追加やバグ修正に直接利益がある
  • AWSOSSなので、業務時間に堂々と作業できる
  • CONTRIBUTING.md がよく整備されているので初心者でも迷わない
  • コミュニティ (cdk.dev) が活発で教え合いの交流が楽しい

github.com

色々なものに触れてみて初めてそれぞれの良し悪しが分かるという点もあるので、そういう意味でも今回の経験は良かった。ということで、MariaDBシリーズ完!引き続き趣味の開発は続けたいので、その一環で何かOSSにも関わる機会があればと思う。 おしまい

ISUCON12予選通過しそうでした

ISUCON12予選に参加しました!結果、スコア自体は予選ボーダー通過していましたが追試で失格でした 😭😭😭

チームいすもなで参加 もなちゃんすまん…

過去にはISUCON9, 11も参加していずれも上位30~50%tileくらいに留まったので、今回はその反省も踏まえています。

今回の方針

Go使う

大きな変化はこれです。ISUCONで使う言語は実質RubyとGoの二択だと思ってます (他の言語はISUCON向けの情報が充実してない印象) が、これまでは業務でGoを使ったことがないのでRubyを選択してました。ただしRuby以下のつらみが見過ごせませんでした:

  • 諸々のツール (profilerなど) が古く使いづらい
  • unicorn/pumaの設定などチューニング項目も増える
  • 静的解析が弱いゆえにデプロイしてからエラーに気づくことも多い

またこれは曖昧な情報なのですが、Goの方が処理能力高い気がします。ISUCON11予選をRubyとGoで解いたのですが、Rubyでかなり苦労して達成したスコアをGoだと軽々と乗り越えられるなあという感じでした。

結局Goは業務で使ってなくても2〜3日でなんとなくいじれるようになりましたし、上記Rubyのつらみが全て克服できるので、最高でした。

ソロ参加

今回は準備がしっかりできるか怪しかったので、迷惑がかからないよう1人で参加しました。後述しますが、これはこれで良い点も多かったです。

準備

ISUCON11予選をGoで解き直しました。解き直す時にこれを見ながらやったのですが、とても良かったです。 ISUCON11 予選問題実践攻略法 ツワモノの思考法がよく分かりますし、あまり突拍子もない事はしなくても予選通過できるのだということを示してくれています。今回12予選を解いているときもこの思考法はめちゃくちゃ参考になりました。

11予選を解くとISUCONに必要なGoの知識は大体インプットできました。ISUCONはN+1解消とバルクインサートとオンメモリキャッシュの書き方だけ抑えておけば、なんとかなる気がします (フラグ)。

前日はお菓子と食料を用意した上でよく寝ました。

当日

8:00 起床

興奮して早めに起きてしまいました。人の頭は起きてから2時間後くらいからフル稼働始めるものらしいので、ちょうど良かったのかもしれません。

10:00 開始

開始後1時間は割と定形作業なので、マニュアルを作っておきました。おかげで落ち着いて作業できるので良かったです。コマンドも事前にここにまとめています。(とはいえ今回はdocker-composeで動くので準備していたデプロイスクリプトが使えなかったりsqliteの対策は何もしてなかったりなど、やはり想定外はありますね…)

ソロISUCON 道標 · GitHub

やや特異な点としては、初手決め打ちでMySQLサーバーとアプリサーバーを分離する点です。過去の傾向からこれは確実に必要になる施策のため、またサーバーを分けることで htop でそれぞれの負荷を見やすくなるため、何も考えずに初手で実施しています。

ちなみに一点困ったのは、マニュアルに記載されていた方法ではポート443のSSHポートフォワーディングができなかったことです。社用Macbookで参加していたので、何か制限があったのかもしれません。解決に時間がかかりそうなので、フロントが必要になったら深堀りしようと後回しにしました。しかし、今回は結局最後までフロントエンドを見ずに終わってしまい、これが良くなかったです(後述)。

諸々のログも取れるようにして、この時点でのスコアが4117です。

11:00 コードやログを読み始める

ログを見ながら、取り組みやすい改善を入れていきます。今回の目標はそれなりに良いスコアを取ることだったので、実装が難しい大それたことは全て避けました。結果的には、たまたまこれが功を奏したようです。(MySQL載せ替えで苦労した人の話を伺う限り)

11:05 MySQLにインデックスはる

MySQLのスロークエリログに出ていたクエリに全てインデックスをはりました。sqliteはログを取れてないので、一旦無視です。

なお従来のISUCONだと01_Schema.sql のようなファイルがあって、それが POST /initialize のたびに反映されていたのですが、今回はありませんでした(!)。このためMySQLスキーマはコード管理せず、直接DBをいじってます。変更取り消したいときダルそうだなと思いましたが、結果的にはMySQLにあまり触れなかったので問題なかったです。

11:40 まとめて採番できるように

スロークエリログを見るとダントツで採番クエリが発行されていたので、とりあえず手を付けました。

採番は前職の時によく考えた問題です。現状では1リクエストの中で複数の採番をするために1つずつ採番クエリを発行していたので、まとめて採番するようにします。この記事の方法を丸パクリしました。 MySQLで採番機能(シーケンス)を実装する方法を整理する - Qiita ストレージエンジンは特にデメリットもないのでMyISAMにしましたが、今回は全体的にトランザクションが使われてないので、InnoDBでも大差ない気はします。

とりあえずまとめて採番できるインターフェースにしつつ、これだけでは何も性能は変わらないのですが、呼び出し側の改善はn+1の解消なども関わりそうだったので後ほど実施することにしました。

12:20 SQLiteにインデックスはる

SQLite初見だし実装力にも自信がないので、SQLiteはそのままにしておこうと考えました。付け焼き刃の対策として、インデックスだけは貼りましたSQLiteは全く計測できてないので、発行される全てのクエリに対して必要なインデックスを張った形になります (計測せよとは…)。/initialize も十分間に合ってました (約10秒)。これでスコア5107になりました。

さらなる最適化としてtmpfsを考えましたが、ググった限り意外と導入に時間掛かりそうなのと、負荷上がったときにもしメモリ溢れたら詰むなあと考えて止めました。

後、ビューワーに DB Browser for SQLite というツールを使いました。初めて使う道具でCLIは難しく、やはりGUIが良いですね。

12:53 retrievePlayerのバルク化, competitionScoreHandler のN+1解消

プレイヤーの詳細をsqliteから取得する retrievePlayer 関数ですが、これがあちこちでN+1問題を引き起こしていました。このため、複数プレイヤーを一括取得する retrievePlayers 関数を作り導入していきます。

ちなみに実はここで実装した関数がバグっていて、後々不穏な挙動を引き起こします。

ひとまずはそこそこリクエストが来ていて実装も単純な competitionScoreHandler 関数に導入して、N+1問題を一つ潰しましたこれでスコア6994

13:13 competitionRankingHandler のN+1解消

同様のアプローチでN+1を潰します。Go + Golandで挑んでますが、やはりコーディングはしやすいです。コンパイルが通ったら大体ちゃんと動くので、今回は全体的にほとんど詰まることなく実装を勧められました。

これでスコア8040。

13:41 CSV入稿をバルクインサートに

そこまでアクセスがないエンドポイントなのでスコアに効くかな?と思いつつ、わかりやすい改善ポイントなので着手しました。思いの外スコア上がった印象です。ルールをよく見ると、このエンドポイントは他のものに比べて10倍のスコアがあるので、実はかなり重要だということに後半から気づきました。

なお、ここで冒頭に作ったバルク採番の仕組みが有効に使えていて、採番クエリもかなり削減できました。

ここ以降勢いづいたのでスコアの記録があまりありません。ベンチマーク履歴は試合終了後も見えるかなと思ってましたがそんなことはなかったです><

14:36 プレイヤーのオンメモリキャッシュ

現状いろいろなエンドポイントが retrievePlayer を呼んでいるので、おそらくキャッシュが有効だろうと考えました。SQLiteの計測ができていないので割と勘ベースではあります。{tenantId}#{playerId} の文字列でキャッシュすることにしました

disqualified の状態がたまに変化するので、その時はキャッシュをクリアする必要があります。

14:45 コンペティションのオンメモリキャッシュ

脳がオンメモリキャッシュに慣れたので、ついでに retrieveCompetition もキャッシュ化しました。Go力が全くないので、めちゃくちゃクソみたいな実装です!

10分くらいでできそうだったので何も考えずに実施しましたが、実はこれがかなり効きました。今考えるとコンペティションはユーザー間で共有されるのでキャッシュが有効なのは当然な気もします。

これでスコアが16056になりました。 この辺りでリーダーボードを見て、これ予選突破できるんじゃねと妄想してドキドキしました。

15:04 competitionScoreHandlerの採番を改善

またまた採番のバルク化です。シンプルに効きます。

ちなみに講評で知りましたが、UUID化しても問題なかったらしいです。APIレスポンスの形式は変えちゃだめだと思ってたのですが、IDのフォーマットは何でも良かったようですね。ただしUUIDは長い文字列なので他の処理が重くなる可能性もあるとのこと。

15:16 プレイヤーのスコアを重複保存しない

CSV入稿時にプレイヤースコアを保存しますが、実は必要なのはプレイヤーごとの最高スコア (=CSVで最後に出てくる行) だけでした。重複排除して保存するようにします

これだけだとバルクインサートの数が少し減るだけなのであまり効かないのですが、これにより次のN+1問題を解消することができます。

15:26 playerHandlerのN+1解消

プレイヤーがコンペティションごとにスコアを1行しか持たなくなったので、プレイヤーの全スコアをまとめて取ってこれるようになりました

15:54 ランクのソートをSQLite側で実行する

こうなるとソート処理もアプリ側でやるほど複雑ではなくなるので、Sqliteでインデックスも使いながら実行します

だいぶ実装がシンプルになり気持ちが良いですね!

16:15 減点要因を直す

ベンチの結果を見て、やたら減点されている (-20%とか) ことに疑問を感じていました。

Score Breakdown: base=19344, deduction=3288

当初は負荷が高いからロック競合起きているのかな―と思いましたが、さすがにここまで解消しないということはアプリのバグのようです。エラーが起きるエンドポイントはベンチマーカーが教えてくれるので、アプリのログを見ながら原因を探します。

結果的には、 プレイヤーをバルク取得する retrievePlayers 関数の実装がだめだったようです。クエリのIN句が長すぎる場合に落ちるようでした。とりあえず1000件ごとにクエリを分けるように修正してみたところ、エラーが解消されました。SQliteのクエリ長に上限があるようですね。

アプリのログで write: broken pipe という旨のエラーが多発していたので、これも疑ったのですが、減点数の割にログ件数が多すぎたので無関係と判断しました。クライアントがレスポンスを受け取る前に切断すると起きる現象のようですが、ベンチマーカーがそのような実装になっていたんでしょうか?

16:28 billingReportByCompetition の改善

このエンドポイントも10倍のスコアを持つので、何かしら改善しようと思いました。とはいえ結構複雑な実装なので、簡単に手を入れられることは少なそうです。とりあえずコンペティションが終わってないときは何の計算もしないことが分かったので、早期リターンするようにしました。テナントDBの中を見る限り finished_at がNULLの行は半分くらいあったので、そこそこ効いたはずです(スコア差分うろ覚え)。

ここまでで22593です

16:40 3台目を使う

今までのサーバー利用状況は、1台目: nginx+app 2台目: mysql で3台目が完全に遊んでました。かわいそうだったので、 1台目からappを切り離して3台目に渡します。

今回は nginx の負荷がかなり軽微 (CPU 5%程度) なので今度は1台目が遊びがちになりますが、それでも多少スコアが上がりました。23227 もしかすると誤差の範囲だったかもしれません。

仕事を与えられて生き生きとするisu3

17:00 flock の見直し

正直ここまででできることが尽きたので、最後に今まで見て見ぬ振りしていたflockをなんとかしようと考えました。よく見るとこれまでのN+1解消などにより、多くの箇所でflockを取る意味がなくなっているようです。不要そうなところを消していきました。

未だに解せないのは、消すことでスコアが下がる場合があった (減点ではなく元スコアが下がる) ことです。どちらかというと整合性エラーが起きるのかなと予想していたので、これは意外でした。ここまで17:10くらいで時間が余っていたので、flockをつけたり消したりしてベンチマークを取り、最高のflockパターンを残しました。

講評を聞いて、トランザクションの代わりにflockを使っていたのだと意図を知りました。ISUCONは設定上の開発者がヌケている前提があるので、それを意識して解法をメタ読みするのが重要かもしれないですね。

ベンチガチャ

終盤のリーダーボードで8位だったので、ワンチャンあるなと欲がでました。

ベンチマークを回すたびにスコアが ±10% くらい変動するので、終盤30分くらいひたすらベンチマークを回し続けました (もうこれ以上は簡単にできる施策がなさそうだった)。17:50くらいにSSRくらいのスコアを引けて、最終スコア 28957 でフィニッシュです。SQLiteをそのまま残すパターンとしてはできる限りやれたんではと思うんですが、どうなんでしょう。

結果

運営チームの追試によりブラウザチェックで失格…!失格!!!

原因はこれです:

// キャッシュの変数を宣言
var playerCache *cacheSlice
var competitionCache *cacheSliceCompetition

// POST /initialize でキャッシュを初期化
func initializeHandler(c echo.Context) error {
    playerCache = NewPlayerCacheSlice()
    competitionCache = NewCompetitionCacheSlice()
    // ...
}

おわかりいただけたでしょうか。

これでは POST /initialize を実行せずに各エンドポイントが叩かれた場合に各キャッシュがnull pointer referenceでpanicします。ベンチマークでは起こり得ない挙動だったので、気づけませんでした。

フロントエンドを無視したのが仇となったと思います。最近はnull安全なTypeScript ばかり書いていたので、私のヌルポセンサーが劣化していたというのも原因の一つでしょう。また、そもそも追試の項目をよく確認しておらず再起動後にベンチマークを回すだけに留まっていたという詰めの甘さも問題でした。

ここはチェックリストで改善できるはずなので、次回は気をつけます。

振り返り

結果は残念でしたが、ソロ参加は次の点で意外と良かったです。

  1. デプロイの競合もマージコンフリクトもない
  2. このため開発フローがかなり単純化できる
  3. 一切会話しなくて良いので集中できる
  4. ダメ元感があるのでプレッシャーも減る

ただし絶対的にできる作業量は減るので、限界は感じます。よく連携できた3人チームにはまず勝てないと思います。

Goも最高でした。今思えばRubyはある種のしばりプレイだったんだなと思います。欲を言えばNULL安全ならもっと良かった…………。来年参加するときはもう少し慣れておきます。

今回失格に終わったのはメッチャクチャめちゃくちゃ残念ですが、これを戒めとして今後も慢心せず精進していきます!運営の皆さまありがとうございました!!

会社をマイクロサービスアーキテクチャのシステムとして捉えたら

はじめに

会社組織をマイクロサービスアーキテクチャとして捉える見方があります。この記事ではその概要、またそうした場合に考察される「良い」チームのあり方についてまとめます。

マイクロサービスの要点

ここでいうマイクロサービスとは、技術分野におけるアーキテクチャパターンのこと。マイクロサービスアーキテクチャ内の各サービスは以下の特徴を持ちます。なお、ここでのユーザーはそのマイクロサービスのユーザーを指します (エンドユーザーや他のマイクロサービスなど)。

  1. 各サービスはAPIを公開し、ユーザーはそれを通してのみサービスを利用できる
    • サービスの内部実装をユーザーに意識させてはならない
    • 注) ここでのAPIはHTTPでJSONを云々などの話に限らず、サービスを利用するインターフェースの決め事全般を指す
  2. APIはユーザーとの契約なので、基本的には破壊的変更を加えてならない
    • APIは変更しない限りは、マイクロサービス内部の実装は自由に変更できる
  3. サービスは満たすべきサービスのレベルをユーザーと合意し (Service Level Agreement, SLA)、そのレベルを満たすように努力する

他にも色々あるでしょうが、今日の話に関連する部分だけピックアップしました。 上記の特徴により、各マイクロサービスは独自に意思決定をし、迅速な改善を実現することができます。

マイクロサービスの例

マイクロサービスの代表的な例の一つとして、AWSの各サービス群が挙げられるでしょう。Amazon S3やSQSなど、それぞれのサービスは独自にAPIを定義し、そのAPIに破壊的変更を加えない範囲で継続的に改善することに成功しています。

Amazon CEO(当時)のジェフ・ベゾスが社内の開発者達に通達したとされるBezos Mandateはあまりにも有名です。2002年の話ながら、現代のマイクロサービスに必要な特徴と多くの点で共通しているので、ぜひ見てみると良いでしょう。

会社とマイクロサービスアーキテクチャの類似点

会社の各チーム(部署)は、それぞれがマイクロサービスであるとみなすことができます。 例えば、経理部のあるチームは経費精算申請のリクエストを送ると払い戻しが返ってくるサービスのようなものですし、特定分野のエンジニアが集うチーム(コミュニティ)は関連技術の質問を投げると答えが返ってくるサービスとしてみなすこともできるでしょう。

このように考えると、上記で上げたマイクロサービスの特徴を、会社内の各チームにも適用することができます。以下では、それぞれの類似点とその考え方をチームの運用にどう活かすかを考えてみました。

定められたAPIを通したサービスの利用

マイクロサービスは、ユーザーと取り決めたAPIを経由した利用しか許可されません。ここでいうAPIとは、そのサービスを利用する上での決められたインターフェースです。会社のチームの場合でも、例えば決められたツールでのみ経費申請を受け付けるとか、質問を受ける際は一定のフォーマットを強いるなどといった、ある種の「入力」に関してユーザーにルールを課すことがあります。

これにより、各入力を標準化された方法で処理することができます。入力のバリデーションや担当者の割り振りなどを自動化することも見据えることができ、非常に望ましい状態と言えるでしょう。

合意のないインターフェースを介した利用、例えばチーム内のメンバーに直接DMするといった方法はこの規則に反します。これは以下の点で望ましくありません:

  • ユーザーがチーム内の構成員というある種の内部実装を意識している
    • チーム内の内部実装の変化、例えば構成員の移動などが発生した場合に、ユーザーはこれまでの方法が使えなくなります
  • 上記の自動化プロセスを無視するため、サービス側の効率が低下

もちろん、これはシステム間の連携というよりは人間同士の繋がりの話なので、例外はあるでしょう。しかしながら、基本的には定められたAPIを通してやり取りをすることが望ましいのは共通です。

APIに破壊的変更を加えない

上記のAPIに破壊的変更が発生した場合、そのサービスに依存するすべてのユーザーは変更に適応する必要性が生じます。例えば、経費申請で入力すべき情報が増える、そもそも経費精算で使うツールが変わるなどといった変更です。

変更への適応には、確実にコストが伴います。こちらも状況にはよりますが、必要のない限りはAPIに破壊的変更を加えないというのも、同様に当てはまるでしょう。

また、APIに変更を加えないという前提であれば、それ以外の変更は自由に可能です。構成員の変更も内部の業務プロセスの改善も、APIが変わらないのであれば個別ユーザーへの相談は不要です。このためチーム内で意思決定を迅速にできるということが大きな利点となるでしょう。

サービスのSLA

会社の各部署でも、サービスのレベル(品質)についてユーザーと合意を得るべきです。技術文脈でSLAと言えば可用性のパーセンテージがよく想像されますが、実際はサービスの品質に関するあらゆる項目が含めることができます。例えば、経費申請してからN日以内に振り込むだとか、質問には平日の9〜17時JSTのみ回答しますというのも一つのサービスレベルでしょう。とにかく、ユーザーに提供するサービスの品質について、一定の計測可能なレベルを決め、ユーザーと合意を得ます。

チームはこのSLAを達成するための努力をすべきです。誰が担当するかやメンバーの急な欠勤などには左右されない、常にSLAを満たせる仕組みを作ることに尽力しましょう。

一方で、SLAを超える部分については、ある種の安全余裕です。SREの分野にもError budgetという考え方があり、その余裕を使ってリスクのある実験をするということもできます。チームの運用においても、案件の対応を適度に遅らせながら、合間に他の重要なタスクをこなすといったことが可能でしょう。

SLAを定めることで、サービス運用側は運用状況の良し悪しについて指標を得ることができますし、またサービス利用側はサービスに対して必要十分な期待で利用でき、計画も立てやすくなりますね。

SLAを超える仕事

まれに、SLAを超えた仕事をするメンバーがいる場合もあります。例えば、9〜17時JSTの対応で合意を得ている状況で、その人だけ深夜にも対応を行うなどです。この行為自体はユーザーからも歓迎されるものですし、問題ないでしょう。しかしながら、この活動によって、ユーザーが本来のSLAを勘違いしてしまうリスクがあります。「XXさんは深夜対応してくれたのにあなたはしてくれないのか?」などといったクレームが想像できますね。

このような状況を回避するためには、以下が重要と思われます:

  • SLAを超える対応をする人は、それが本来のSLAを超えた対応であること・他の対応者には同じ対応を期待してはいけないことを、ユーザーに対して強調する
  • サービスの運用側は、現在のSLAをわかりやすい形で常に告知する

また、SLAを超えた仕事をするメンバーは、それが本当にユーザーにとって価値のあることだと信じているのであれば、それをチーム全体のサービスレベルとして引き上げることを目指しても良いかもしれません。チームメンバーに働きかけ、SLA自体を向上させるわけです。

いずれにしても、こういった考慮をせずにSLAを超えた対応をするのは、実は周囲に思わぬ影響をもたらす可能性があるということは認識すると良いでしょう。

この例えの限界

実際の組織の意思決定プロセスは、上位のチームの承認が必要な場合があります。ある種、そのサブ組織のツリー内で分散モノリスができてしまったような状態と言えるでしょうか。この場合は意思決定を迅速にするというメリットは失われるかもしれません。

また、この話はチームの定型業務をサービスとしてみなした場合のみ成立すると考えられます。非定型業務で厳密なAPISLAを定めるのは非効率なためです。あえて例えに含めるなら、非定型業務はチームが新たに価値を発揮できる場所を探すPoCのようなものかもしれません。

まとめ

ということで、会社をマイクロサービスとしてみる話でした。この視点から見ると、技術的に良いマイクロサービスとはという話をチームの運用にも流用できることが分かります。あなたのチームはどのような役割を担うサービスでしょうか?

個々のチームの改善は自ずと会社全体の改善に繋がります。今回紹介したような考え方も使いながらチームを運用して、改善を加速しましょう。

AWSでn秒ごとのループ処理、どうする?

はじめに

サービスを開発していると、n秒ごとに何らかの処理を定期実行したい要件が見つかることがあります。 例えば、10秒ごとにあるAPIエンドポイントにアクセスして結果を保存したいなどです。この記事ではこのような機能の実装方法について考えます。

EventBridge + Lambda の限界

n > 60、つまり1分間以上の間隔の場合は、EventBridge + Lambdaによる処理が最適解となる場合が多いでしょう。

しかしながら、EventBridgeのスケジュール式の最低分解能は1分なので、それよりも短い間隔で呼び出すには適しません。

docs.aws.amazon.com

1分未満の間隔でループを回す場合は、次の方法を取ることが多いです。

方法1. ECSのサービスとして実装

ループ処理を適当な言語で実装し(以下はPythonの例)、ECSのサービスとして起動します。

from time import sleep

def main():
  # 10秒ごとにdoSomething関数を実行する
  while True:
    doSomething()
    sleep(10)

main()

この方法のメリット・デメリットは以下のようになるでしょう:

メリット

  • コンテナを1つ起動するだけなので、実装は直観的でシンプル
  • サーバーレスの基盤であるFargateを使えば運用もある程度楽
  • 処理が常時起動・自動復旧することをECSが保証してくれる

デメリット

  • ECSなのでVPCが必要になり、サーバーレス性はやや低い
  • sleep中もコンピュートリソースを消費するので、コストが高いかも?

類似の方法としてLambdaを使って同じことをする方法もあります。Lambdaは最大15分で強制終了されるため、定期的にLambdaを呼び出しつつ、呼び出したLambdaの中でsleepしながら処理をループする方法です。ただし、今回主眼としたいコスト面についてはECS Fargateを用いるこちらの方法と同等になると考えられるため、この記事では省略します。

方法2. Step Functionsのステートマシンとして実装

別の方法は、Step Functionsを使う方法。基本的にはWaitしながらLambdaを呼び出すことをループします。ただし、Step Functionsは1回のExecutionあたり履歴が25,000件を超えるとエラーになるので、適当な回数で再起動する必要があります。それらを考慮すると、以下のような定義になるしょう。

Lambda関数の中でループ回数をカウントアップして、カウントが一定数を超えたらステートマシンを再起動します。

def handler(event, context):
  count = event.get['count'] or 0
  doSomething()
  return { 'count' : count + 1 }

この方法のメリット・デメリットは以下のようになるでしょう:

メリット

  • VPCいらずで、よりサーバーレス

デメリット

  • マネージドにステートマシンの常時起動を保証できないので、別途監視する必要あり
  • Step Functionsのステート遷移がコスト高いかも?

ということで、2つの方法を紹介しました。

上記の方法1と2では、色々と違いはありますが、多くはケースバイケースの判断となるでしょう。そのような中でも絶対的な指標となる(低ければ低いほど良い)、コストについてまず注目してみます。

コストの比較

方法1と2のコスト主要因は、以下となります:

  • 方法1: ECS Fargateの利用時間
  • 方法2: Lambdaの呼び出し・利用時間、Step Functionsの状態遷移回数

具体的に見ていきましょう。ここで、諸々のパラメータを定義します :

  •  T : ループの処理間隔 [s]
  •  T_c : 1ループ当たりのLambda処理時間 [s]
  •  v_{cpu} : FargateのCPU数 [vCPU]
  •  v_{ram} : FargateのRAMサイズ [GB]
  •  l_{ram} : LambdaのRAMサイズ [GB]
  •  C_{fc} : Fargateの1vCPUあたりの料金 [USD/h]
  •  C_{fm} : Fargateの1GB RAMあたりの料金 [USD/h]
  •  C_{li} : Lambdaの呼び出しあたりの料金 [USD/invocation]
  •  C_{lt} : Lambdaの利用時間あたりの料金 [USD/GB/s]
  •  C_{s} : Step Functionsの状態遷移あたりの料金 [USD/遷移]

方法1のコストは非常に簡単です。一時間あたりの料金は以下になります。

 C_1 = v_{cpu}C_{fc} + v_{ram} C_{fm} [USD/h]

CPUとRAMの料金を足すだけです。単純ですね。

方法2の1時間あたりのコストは、以下です。

 C_2 = \frac{3600}{T}(C_{li}+ l_{ram}T_c C_{lt}   + 3C_{s}) [USD/h]

  \frac{3600}{T} は1時間あたりのループ回数です。1ループあたりはLambdaの料金に加えて、Step Functionsの遷移回数3回分 (上図を参照) の料金が課金されます。 また、厳密にはステートマシンの再起動による遷移料金も含まれますが、軽微なため無視しています。

では、各変数に2022/6現在の値を代入して、計算してみました。 ここからパラメータをいじることができます。

それぞれのコストは諸々のパラメータによるのですが、いくつかの例を見てみましょう。特定の例における比較があたかも一般論として独り歩きしてしまうのは避けたいです。以下はあくまでも特定の例における比較であることに注意してご覧ください。

一例

以下のようにパラメータを設定した場合です:

  •  T_c= 100 ms : 1ループ当たりのLambda処理時間 [s]
  •  v_{cpu}= 0.25 vCPU : FargateのCPU数 [vCPU]
  •  v_{ram}= 256 MB : FargateのRAMサイズ [GB]
  •  l_{ram}= 256 MB : LambdaのRAMサイズ [GB]

このときのグラフはこうなります。

青線が ECS Fargateのコスト ( C_1)、赤線がStep Functions + Lambdaのコスト ( C_2)です。ループの処理間隔  T を横軸の変数として、コストをプロットしています。

これより、処理間隔がおよそ20秒を超える場合は、Step Functionsのほうが安上がりなことが分かります。ちなみに緑のラインは、処理時間が1分を超える場合のみ利用できる、EventBridge + Lambdaの場合です。不必要なコンピュートリソースを使わない分、圧倒的に安いことが分かりますね。

繰り返しになりますが、これはあくまでも一例です。このページから、パラメータを色々と変更して動きを確認してみてください。また、計算式に間違いがある可能性もあるため、ご自身で検算した上で参考にしていただければ幸いです。

もう少し考慮点を

2つの方法では、コスト面で違いがあることが分かりました。特に処理間隔が10秒以下になる場合は、Step Functionsだとやや高くなる印象です。とはいえ、これ単一のコストは1時間で1円ちょっとなので、1ヶ月でも1000円程度でしょう。状況によっては、コストはこのアーキテクチャを決める支配的な要因にならないかもしれません。

その他のメリットデメリットとしては、上記に挙げたとおりです。特にVPCの有無や、Step Functionsで処理が実行されていることを監視する必要性などは、重要な考慮点となるでしょう。 許容される複雑度を考えながら、そのシステムに最適な技術選定をしてください。

まとめ

AWSで1分未満の間隔の定期的な処理を実行する方法について考えました。やはりいろいろな考慮点があるので、最適な技術選定をするためには、それぞれのメリットデメリットを把握する必要があります。

もし他にもこのような方法があるよ、このような考慮点があるよなどありましたら、教えていただければ幸いです。

Slack 6種のチャンネル仕分け!

Slackのチャンネルは6種類に大別できる。これを理解して設定を使い分けることで、Slackの通知地獄から開放される第一歩となろう。

なお有料版限定の機能を使う場合の話。

これが6種類だ!

Slackのチャンネルは、見え方について以下3つの直交する設定が可能:

  1. チャンネルを含むセクションを折りたたむか開くか

    右向き三角は折りたたみ、下向き三角は展開されたセクション
    セクションを折りたたむことで、画面を占有する高さを大きく節約することができる。一方で折りたたんだセクションの中のチャンネルにアクセスするためには、2クリック必要になる。このため、基本的には折りたたんだセクションには訪れる頻度の低いチャンネルを入れる。

  2. セクション内のチャンネルの表示設定: すべて表示 or 未読のみ表示

    表示設定
    未読のみ表示もまた、画面を占有する高さを節約するための機能。折りたたみとの違いは、隠されたチャンネルにアクセスするには Cmd + T でチャンネルに直ジャンプするしかないこと。すなわち、折りたたみよりもアクセスのコストが高い。一方で、表示されたチャンネルには1クリックでアクセス可能という優位性もある。

  3. チャンネルをミュートするか否か

    薄字がミュートされたチャンネル
    チャンネルをミュートすることで、新着メッセージへの通知が無効になる。基本的には、新着メッセージのたびに通知があると煩わしいチャンネルに対してこの機能を利用する。ただしこれは2の設定と組み合わせると面白くて、セクションを未読のみ表示にするとミュートしたチャンネルも表示される。このため、ミュートしていてもなお、通知はないが新着メッセージに気づくことができるのだ。
    ミュートするとこの通知が来ない

これらの設定に応じて23=8種類だが、このうち、1を折りたたむにして2を未読のみ表示にする設定は意味をなさない (併用しても特段のメリットがない) ので、有効な選択は6種類あることになる。

ミュートしない ミュート
セクション展開 & すべて表示 1 2
セクション展開 & 未読のみ表示 3 4
セクション折りたたみ & すべて表示 5 6
セクション折りたたみ & 未読のみ表示 - -

使い分け

では、この6種類をどう使い分けるか説明しよう!

  1. セクション展開 & すべて表示 & ミュートしない 自分にとって最も重要なチャンネル。常に1クリックでアクセスできるので能動的にメッセージを送信しやすいし、新着に対して通知もされる。

  2. セクション展開 & すべて表示 & ミュート 重要だがメッセージの受信頻度が高く煩わしいチャンネル。チャンネルを開けば大体新着メッセージがあるので、通知の必要を感じないときに。

  3. セクション展開 & 未読のみ表示 & ミュートしない 自分から能動的にメッセージを送ることは稀だが、メッセージはタイムリーに追いたいチャンネル。

  4. セクション展開 & 未読のみ表示 & ミュート 自分から能動的にメッセージを送ることは稀だし、メッセージもあとからまとめて読めば十分なチャンネル。

  5. セクション折りたたみ & すべて表示 & ミュートしない 3に近いが、これは新着があるときもチャンネルの表示に2クリック必要。メッセージが送られてくる事自体稀で、ほとんど放棄されている。ただし、稀に利用されるので、そのときは確実に気づきたいようなチャンネル。

  6. セクション折りたたみ & すべて表示 & ミュート これをするのはチャンネルから退室するのとほぼ同義だが、退室のログが残るのも忍びないようなチャンネル。

まとめ

以上、6種のチャンネルの声を聞き分ければ、君もSlackマスターだ!

趣味Webサービスをサーバーレスで作る ― 格安編

はじめに

最近AWSのサーバーレスサービスで作るWebサービスの雛形を公開してみた*1。小規模サービスならかなり安く (ほぼ無料になるケースも多そう) で運用できるので、割と良い選択になる場面も多いと考えている。今日はその布教記事。

github.com

全体構成

全体の構成は下図のとおり。特徴としては以下が挙げられる:

  • CDNでフロントエンド (React SPA) の静的ファイルを配信
  • バックエンドAPIAPI Gateway + Lambda (Express.js)
  • データベースはDynamoDB (詳細は後述)
  • ユーザー登録が必要なサービスのため、Eメール認証も付けている
  • ありがちな非同期ジョブ実行の仕組みも容易
  • AWS CDKで一発デプロイ

architecture

こういう構成を趣味サービスで採ることのおそらく一番のデメリットは、学習コストだろう (VMを使った構成とはかなり違うので。) このサンプルを参考に実装したり、一旦そのまま使ったりすることで、その初期学習コストを回避させることができれば良いなと思う。

他にもVMやコンテナ系サービスと比較したサーバーレス特有の考慮点がいくつかあるので、次の節にまとめる。

サーバーレスの考慮事項

AWSのサーバーレスサービスを使う時に考慮すべきことをまとめる。

メリット

サーバーレスのメリットは枚挙にいとまがないが、趣味サービスという観点で実用的なのは以下のようなところ:

  • 初期コストが安い。基本的にリクエスト量に応じた従量課金なので、ユーザーが少ないうちはほぼ無料で運用可能。
  • サーバーの運用が不要。VMの面倒を見るような作業から開放される。
  • 流行の技術なので、業務での活用も期待できる。

一方でもちろんAWSマネージドになる分考えないといけないポイントもあり、以下にそれらをまとめた。他にもあれば教えてください。

タイムアウトの短さ

Amazon API Gatewayの仕様上、Webリクエストは30秒でタイムアウトする。つまり、リクエストを送ってからレスポンスに30秒以上かかるようなAPIは作ることができない。実際レスポンスに1分もかかるようなAPIがあると、ユーザー側も不安になる・UX下がることもあろうから、妥当な制約ではある。

この制約を回避するための常套手段は、非同期ジョブ化である。APIではジョブをエンキューだけしてレスポンスを返し、非同期でジョブを実行する。今回のサンプルではAmazon SQSにエンキューしAWS Lambdaでデキュー・ジョブ実行する形で、これを実現している。

DynamoDBの特性

小規模サービスで最もコストがかかるのは大抵データベース。例えばAmazon RDS Auroraを使うとしたら、最安のインスタンス (db.t3.small) 1台でも毎月5000円くらいかかる。個人サービスにはなかなか厳しい値だろう。

一方で今回使うDynamoDBはリクエスト量に応じた課金なので、小規模ならかなり格安に使うことができる。具体的には、100万リクエストごとに1.5 USDとかそれくらいのコスト感。もし毎月1万アクセスのサービスなら、1円程度で済む計算。

このようにコスト効率の良いサービスではあるのだが、いかんせんNoSQLなのでRDMBSとは使い勝手が違う。具体的にはJOINができないため、情報を1つのリクエストで一括取得したい場合はテーブル設計にそれなりの工夫が必要になる。正直設計だけでも結構難しいし、DynamoDB Streamsなどを使ったそれなりの仕組みを実装する必要もあったりする。

serverlessfirst.com

とはいえ小規模サービスなら、強いて1リクエストで必要な情報をすべて取得することにこだわらなくても良いのでは。上記のように、1万アクセスで1円程のコスト感である。仮に1アクセスごとのリクエスト数が2倍になったとしても2円。1円のためにDynamoDBまわりの設計を複雑化するのは非合理じゃないだろうか。ここの最適化にこだわらなければ、意外とDynamoDBも素直に使える*2

将来的にもしサービスの規模が大きくなったら、その時にリファクタすれば良い。DynamoDBはテーブルをリファクタする仕組みが十分に整っている。もちろん非常に簡単な作業というではないが、それほどの規模になっているのであればモチベーションを湧かせるのは難しくないはず。

コストの上限

サーバーレス系のサービスでよく言われる気がするのは、なまじスケールアウト性能が高いため、もしDDoS攻撃を受けた時にコストが青天井になるという不安。この点について考えてみる。

architecture

まずフロントエンドの静的アセットを配信しているCDN (Amazon CloudFront) だが、ここをDDoSするのは攻撃者視点であまりメリットがないと思われる。CDNに対しての攻撃は多くの場合直接サービス品質に影響を与えられないし、課金攻撃だとしてもデータの転送量に対する支払いとなるため、攻撃にかかるコストとサービス運用者に支払わせるコストがトントンになって効率が悪いだろう。

このため、より気にすべきはバックエンドAPIの方だろう。例えば非同期Jobを走らせるエンドポイントだと1回のリクエストでジョブをエンキューできるので、重いジョブの場合は攻撃の効率が良さそうだ。

しかしこれも運用側で対策ができて、LambdaのReserved Concurrencyを設定すれば大体OK。これはLambdaの同時実行数に上限を設定する機能で、要は無限にスケールアウトするような事態を防止できる。このため、攻撃を受けても一定のコスト増加やサービス品質の低下はあるものの、無限にコストが増えることはない。あとはAWSの請求額を監視するSlack通知でも設定しておけば、早めに異常に気づいてそこから対策を考えることができる。

dev.classmethod.jp

これらのことはDDoS攻撃だけでなく急にサービスが流行りだしたときもおなじことが言えるかもしれないが、その場合は実ユーザーをお金に変換する仕組み (広告など) を事前に仕込んでおけば、運用コスト以上にリターンがあるはず。

とはいえ多くの個人サービスは攻撃を受けることも急に流行りだすこともないだろうから、アーキテクチャをシンプルに保つ意味でAWS WAFなどは入れていない。ここは考え方次第なので、最初からそういう対策を入れておくのも全然アリだと思う。趣味なのでこだわりたいところをこだわろう。

ローカルでの開発しやすさ

Lambdaを使うとローカルで開発しづらいんじゃないという声もよく聞く気がする。実はそうでもなくて、というのは今回 serverless-express というExpress.jsのサーバーをほぼそのままLambda上で動かせる仕組みを使っているため。 これにより、ローカルではただのExpressサーバーとして検証し、そのコードをそのままLambdaにデプロイするということが可能*3

SQSなどのサービスが絡む部分は、実際ローカルで検証しづらい。ここは自分だとAWS上のリソースを検証用途でも使うようにしている。最近は cdk watchcdk hotswap といったCDKの機能で、AWS上のリソースが絡む検証も非常に高速にイテレーションを回せるようになった。このおかげで待ち時間も短くストレスもない。コストに関しても今回のサーバーレス系サービスなら無視できるレベル。

dev.classmethod.jp

ちなみにフロントエンドは、SPAの仕組み上CDNで配信してもローカルで動かしても同じ動作になるので、何も考えずにローカルで動作検証できる。そんな感じで、システム全体をローカルから開発している。

塩漬けの可能性

サーバーレスに代表されるAWSマネージドのサービスは、基本的に塩漬け運用はしづらいと言われる。例えば最近だとPython 3.6のEOLに伴ってLambdaの同ランタイムはサポートが終わったので、開発者たちは対応に奔走したものだ (一応、Lambdaがサポート終了した後も関数実行自体は可能なので動作はする。)

ただし、実は今回利用したサービスの場合はそれほどユーザー影響のある変更があるとは考えづらい。AWSサービスは内部仕様は変わることはあれど、ユーザーの目にするAPI後方互換性を保たずに変更されることは少ないためだ*4。 基本的にAPIはユーザーとの契約なので、みだりに破壊的な変更は加えられないものなのである。このように、完全にサーバーレスのサービスを利用していれば案外塩漬け性能は高いかもしれない。

念のため、一般論として塩漬けは推奨されない (脆弱性に対応できない、サービスを改善しづらくなるなどが理由) ので、モチベーションが消え去ったけどサービス自体は提供し続けたいような場合の最終手段と捉えておくと良さそう。

システム全体のコストは?

AWSは無料利用枠があり、今回使うサービスの多くは毎月一定までは無料で使える (例えばAPI Gatewayは毎月100万リクエストまで無料など)。つまり、小規模な趣味サービスならほぼ永久無料で運用できる。

aws.amazon.com

無料利用枠の使い果たしたあとも、基本的には単純な従量課金なので、リクエスト量がわかれば見積もりは容易。DynamoDBの項でも書いたが、大抵100万リクエストにつき100円とかのオーダーなので、そんなもんだと思っておけば良い。VMとは異なり使ってない計算リソースが維持され続けることもないので、特に利用者数が時刻により変動するようなサービスは効率よく運用できる。

実際運用してみて

まだ限定公開ながら、この技術スタックで稼働しているサービスがある。 今はかなり小規模なので、目論見通りほぼ無料枠に収まっている。

一点想定外だったのは、Amazon Elastic Container Repository (ECR)の利用料金。素のLambdaランタイムではデプロイの際Node.jsのバンドリングに時間がかかるなど不便だったので、この時はDocker Lambdaを使っていた。 CDKだと cdk watch という機能を使って、ローカルのファイルに変更があるたびに自動で新しいDockerイメージをビルドしてデプロイするということができるのだが、これによりECRへの保存量が多めになっていた模様。

結果的には、1ヶ月で10GB程度のストレージを消費していたため100円くらい課金された。やっぱり運用してみるまで分からないこともあるね。ここはユーザーの増加に伴って増える金額ではないので、許容している。

まとめ

趣味サービス、AWSのサーバーレスで作ってみても良いんじゃないという話をした。他の手段と同様に考慮すべき事項はあるが、かなり有効な選択だと思う。サンプルも用意しているのでぜひお試しください。

*1:私のチームではこのように再利用可能なサンプルを公開することが奨励されている

*2:今はサーバーレスのRDBMSサービスも世の中にあるので、DBだけはそれを使うというのもアリだと個人的には思う。AWS外だとIaCしづらいとかそういう実用的なデメリットはあるだろうが。

*3:実際これでは1つのLambdaのコードが肥大化して良くないという話もあるのだが、一旦シンプルさのためこうしている。APIルートごとにLambdaを分けるということは容易なので、コールドスタートが長いなどの問題が表面化してきたらリファクタしよう。

*4:というよりそんな例あったんだろうか?知らない。