kawasin73のブログ

技術記事とかいろんなことをかくブログです

The fastest HTTP parser of Rust

速さは正義。どうも、かわしんです。

Rust で一番速い HTTP パーサーは C で書かれた picohttpparser の Rust バインディングpicohttpparser-sys です。ガハハ。

$ RUSTFLAGS="-C target-feature=+sse4.2" cargo +nightly bench
    Finished bench [optimized] target(s) in 0.28s
warning: the following packages contain code that will be rejected by a future version of Rust: traitobject v0.1.0
note: to see what the problems were, use the option `--future-incompat-report`, or run `cargo report future-incompatibilities --id 78`
     Running unittests src/main.rs (target/release/deps/rust_http_parser_bench-63366b14855362d6)

running 16 tests
test tests::bench_dumb_http_parser   ... bench:       2,085 ns/iter (+/- 537) = 337 MB/s
test tests::bench_http_box           ... bench:         592 ns/iter (+/- 43) = 1187 MB/s
test tests::bench_http_bytes         ... bench:       2,347 ns/iter (+/- 341) = 299 MB/s
test tests::bench_http_muncher       ... bench:         795 ns/iter (+/- 141) = 884 MB/s
test tests::bench_http_parser        ... bench:       3,791 ns/iter (+/- 594) = 185 MB/s
test tests::bench_http_pull_parser   ... bench:       6,990 ns/iter (+/- 658) = 100 MB/s
test tests::bench_http_tiny          ... bench:      12,675 ns/iter (+/- 1,219) = 55 MB/s
test tests::bench_httparse           ... bench:         276 ns/iter (+/- 11) = 2547 MB/s
test tests::bench_milstian_http      ... bench:      23,279 ns/iter (+/- 2,616) = 30 MB/s
test tests::bench_picohttpparser_sys ... bench:         135 ns/iter (+/- 24) = 5207 MB/s
test tests::bench_rhymuweb           ... bench:       6,868 ns/iter (+/- 631) = 102 MB/s
test tests::bench_rocket_http_hyper  ... bench:       4,119 ns/iter (+/- 420) = 170 MB/s
test tests::bench_saf_httparser      ... bench:       5,870 ns/iter (+/- 528) = 119 MB/s
test tests::bench_stream_httparse    ... bench:       1,781 ns/iter (+/- 202) = 394 MB/s
test tests::bench_thhp               ... bench:         177 ns/iter (+/- 14) = 3971 MB/s
test tests::bench_uhttp_request      ... bench:         261 ns/iter (+/- 24) = 2693 MB/s

test result: ok. 0 passed; 0 failed; 0 ignored; 16 measured; 0 filtered out; finished in 19.03s

Pure Rust な HTTP パーサーで一番速いのは thhp でした。

github.com

環境

$ cargo -V
cargo 1.68.0-nightly (8c460b223 2023-01-04)

背景

仕事で Rust を使うようになり何か Rust を使って自分で作りたいと思ったのが発端です。

1から作るのは大変なので何か有名なプロダクトを Rust に移植しようかなと思っていて、もともと興味のあった HTTP サーバかデータベースを色々調べていました。

そこで、h2o の作者の Kazuho Oku さんのスライドを見つけて感銘を受けます。2014 年の発表資料ですが今でも感動できる資料です。特に 21 ページの "characteristics of a fast program" はとても良かったです。

  • characteristics of a fast program
    • executes less instructions
      • speed is a result of simplicity, not complexity
    • causes less pipeline hazards
      • minimum number of conditional branches / indirect calls
      • use branch-predictor-friendly logic

www.slideshare.net

このスライドでは最速の HTTP パーサーとして picohttpparser が紹介されていました。そこで、picohttpparser を Rust に移植することにしました。

が、すでに Rust で picohttpparser より速い HTTP パーサが実装されていたら新規性が薄れるので Rust の HTTP パーサのベンチマークを取ることにしました。

調査

Rust の HTTP パーサは crates.io で "http parser" と調べて出てきたものを片っぱしからベンチマークに突っ込みます。

github.com

いくつかのクレートは以下の理由でベンチマークが取れませんでした。

その結果が最初に貼り付けたログです。

なぜ速いのか

Pure Rust なもので特に早かったクレートは thhphttparseuhttp_request の 3 つでした。これらのどれも picohttpparser と同じようにステートレスにパースしています。

github.com

uhttp_request は改行で split した後にさらに空白で split するなどやや無駄があるように思いました。また、各文字が正しい値になっているかの validation をしていません。そのためあまり参考にはならないかもしれないです。

github.com

httparse はダウンロード数の多いクレートなので Rust の中のデファクトのライブラリだと思われます。

github.com

thhp は Totemo-Hayai (とても速い) HTTP Parser の略だそうです。作者の方の Twitter を見る限り、picohttpparser を意識して書かれているようです。

httparsethhp のコードを読むとそれぞれ違いがあります。ただ、どこが有意な差を生み出しているのかはさらに調査が必要そうです。

  • method の評価
    • httparse: 先頭 4 バイトを match 構文で比較
    • thhp: 1 文字ずつ TCHAR_MAP で検証
    • picohttpparser: SIMDtoken_char_map で検証
  • path の評価
    • httparse: SIMDURI_MAP で評価
    • thhp: 1 文字ずつ 0x20 < c && c < 0x7F で評価
    • picohttpparser: SIMDIS_PRINTABLE_ASCII(c) ((unsigned char)(c)-040u < 0137u) で評価
  • version の評価
    • httparse: 先頭 8 バイトを match 構文で比較
    • thhp: 先頭 8 バイトを 1 文字ずつ評価
    • picohttpparser: 先頭 8 バイトを 1 文字ずつ評価
  • field name の評価
    • httparse: parse_headers_iter_uninit でやっているがなんか長い。HEADER_NAME_MAP で1文字ずつ評価している
    • thhp: 1 文字ずつ TCHAR_MAP で検証
    • picohttpparser: SIMDtoken_char_map で検証
  • filed value の評価
    • httparse: parse_headers_iter_uninit でやっている。SIMDHEADER_VALUE_MAP
    • thhp: SIMDFIELD_VALUE_CHAR_MAP で評価
    • picohttpparser: SIMDIS_PRINTABLE_ASCIIで評価。さらに追加の検証も入っている。

結論

thhp がすでにあるので picohttpparser の Rust への移植は諦めました。。。thhp は filed value 以外でも SIMD を使うようにすればもっと速くなりそう。

SIGSTOP シグナルは EINTR を発生させるのか

エラーハンドリングはヌケモレなく、どうもかわしんです。ちゃんとエラーハンドリングしてますか?

以前のブログでも書いた通り、プログラミングする以上発生しうるエラーのうち回復可能なエラーは必ずハンドリングするべきです。

kawasin73.hatenablog.com

で、system call のエラーハンドリングで忘れられがちなのが EINTR (Error INTerRupt) エラーです。

ブロッキングする ("slow" な) システムコール中にシグナルや ptrace が発生して処理が中断された時に返されるエラーで、大抵の場合エラーハンドリングとして同じシステムコールを再度呼び直すことになります。

いちいち EINTR の時だけ再度システムコールを呼びなおすのは面倒くさいので、SA_RESTART というフラグをシグナルハンドラに設定するとシグナルが送られた場合でも EINTR を返すことなく処理を継続してくれます。(ただし、再開不能な場合は EINTR が発生するらしいです。)

さて、シグナルの検証をしていたところ、SIGTERM などのシグナルを送ると確かに EINTR が発生するが、SIGSTOP を送って SIGCONT で再開した時には予想に反して EINTR が発生しないことがわかりました。今回はその謎に迫ります。

SIGSTOP とは

SIGSTOP とはプロセスを一時停止させるためのシグナルです。SIGCONT を送るとプロセスは再開します。

SIGSTOP は特別なシグナルで SIGKILL 同様、シグナルハンドラを設定することもシグナルを無視することもできません。そのため、SA_RESTART をつけるかつけないかを選ぶことはできないです。

カーネルのコードを読む

実際に SIGSTOP が他のシグナルと比べてどのように実装されているのかを Linux のコードを読んで調べます。Linux version は v5.19.17, アーキテクチャx86 を対象としてみます。

とりあえず、SA_RESTART がどのように実装されているかを確かめます。実は Linux のコードでは SA_RESTARThandle_signal() 関数の1ヶ所でしか使われていません。

SA_RESTART がついていない場合は EINTR を返すようにしてますが、そうでない場合は続行するようにしてるっぽいです。

static void
handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
    bool stepping, failed;
    struct fpu *fpu = &current->thread.fpu;

    if (v8086_mode(regs))
        save_v86_state((struct kernel_vm86_regs *) regs, VM86_SIGNAL);

    /* Are we from a system call? */
    if (syscall_get_nr(current, regs) != -1) {
        /* If so, check system call restarting.. */
        switch (syscall_get_error(current, regs)) {
        case -ERESTART_RESTARTBLOCK:
        case -ERESTARTNOHAND:
            regs->ax = -EINTR;
            break;

        case -ERESTARTSYS:
            if (!(ksig->ka.sa.sa_flags & SA_RESTART)) {
                regs->ax = -EINTR;
                break;
            }
            fallthrough;
        case -ERESTARTNOINTR:
            regs->ax = regs->orig_ax;
            regs->ip -= 2;
            break;
        }
    }

https://elixir.bootlin.com/linux/v5.19.17/source/arch/x86/kernel/signal.c#L805

handle_signal()arch_do_signal_or_restart() から呼び出されています。get_signal() から non-zero な値が返ってきた時に handle_signal() を呼び出し、それ以外の場合は handle_signal()-ERESTARTNOINTR のような処理を行いシステムコールを続行させるみたいです。get_signal()シグナルハンドラが設定されている時に non-zero な値を返す ことになっています。そのため、SIGSTOPSA_RESTART を設定されている時と同じような挙動をする みたいです。

void arch_do_signal_or_restart(struct pt_regs *regs)
{
    struct ksignal ksig;

    if (get_signal(&ksig)) {
        /* Whee! Actually deliver the signal.  */
        handle_signal(&ksig, regs);
        return;
    }

    /* Did we come from a system call? */
    if (syscall_get_nr(current, regs) != -1) {
        /* Restart the system call - no handlers present */
        switch (syscall_get_error(current, regs)) {
        case -ERESTARTNOHAND:
        case -ERESTARTSYS:
        case -ERESTARTNOINTR:
            regs->ax = regs->orig_ax;
            regs->ip -= 2;
            break;

        case -ERESTART_RESTARTBLOCK:
            regs->ax = get_nr_restart_syscall(regs);
            regs->ip -= 2;
            break;
        }
    }

    /*
     * If there's no signal to deliver, we just put the saved sigmask
     * back.
     */
    restore_saved_sigmask();
}

https://elixir.bootlin.com/linux/v5.19.17/source/arch/x86/kernel/signal.c#L865

本を読む

コードを読んで、SIGSTOPSA_RESTART を設定されている時と同じような挙動をすることがわかりました。ただ、これは Linux v5.17.19 の x86 だけの挙動で他の場合はどうかはわかりません。そこで仕様としてどうなっているのかを調べます。

ただ、Linux の仕様は文書としてはないような気がするので本を読みました。

詳解 UNIX プログラミング (第3版)

"Advanced Programming in the UNIX Environment" (Third Edition) の日本語版です。

"10.5 割り込まれたシステムコール" にはおまけみたいな文体で、signal() 関数でシグナルハンドラを確立したときにシステムコールを再開するかどうかは UNIX 実装によって違い、System V ではデフォルトではシステムコールを再開せず、Linux では再開すると書いてありました。つまり、シグナルに対するデフォルトの挙動は Linux では SA_RESTART ということみたいです。

LINUX プログラミング インタフェース

"The Linux Programming Interface" の日本語版です。

"21.5 システムコールへの割り込みと再開" では 2 つの重要なことが書いてありました。

  • いくつかのシステムコールでは SA_RESTART をつけていても再開できず EINTR を返す場合がある
  • SIGSTOP で停止した後に SIGCONT で再開した時にいくつかのシステムコールでは EINTR を発生させる。
    • epoll_pwait()
    • epoll_wait()
    • inotify file descriptor に対する read(), semop(), semtimedop(), sigtimedwait(), sigwaitinfo()

1つ目については、確かに handle_signal() 内の ERESTART_RESTARTBLOCKERESTARTNOHAND の場合に EINTR を設定していたので確認できました。

2つ目については、Linux のコードをざっと最初に眺めただけでは知らなかったので重要な情報でした。SIGSTOPRA_RESTART を有効にした場合と同じような挙動をするものの、例外もあるということでした。

まとめ

SIGSTOPRA_RESTART を有効にした場合と同じような挙動をするものの、例外もあるということでした。頑張って正しく EINTR をハンドリングしましょう。

OS はキャッシュのライトスルーを選ぶのか

メモリとキャッシュと整合性。どうも、かわしんです。

最近「詳解 LINUX カーネル」という分厚い本を読んでいるのですが、割と序盤の第 2 章で気になったことがあったので調べてみました。

ライトスルーとライトバック

ご存知の通り、CPU にはキャッシュがあってメモリアクセスの遅延を高速化しています。 メモリの内容への書き込みではキャッシュとメモリを同時に書き換える「ライトスルー」方式とキャッシュの中身だけ書き換えてメモリへの書き込みは遅延させる「ライトバック」方式があります。

ライトスルーは書き込みのたびにメモリアクセスが発生し遅くなってしまうが簡単に実装できるし、複数段キャッシュの L1 キャッシュで使えばその書き込み遅延も軽減することができます。 一方で、ライトバック方式は高速に書き込みができるけど整合性を保つために実装が複雑になりがちだし、バグが起きやすいかもしれません。

とここまでは Wikipedia にも載っている話です。

ja.wikipedia.org

世の中の文献ではこの二つの方式を比較して両方には一長一短があるよねという話を CPU を作るハードウェアベンダの視点から論じています。

OS がライトスルー方式を選ぶ理由はあるのか

ところが、「詳解 LINUX カーネル」によるとページテーブルエントリには PWT (Page Write-Through) フラグがあり、Pentium では OS がライトスルー方式かライトバック方式かを選ぶことができるらしいです。

僕の認識ではメモリのキャッシュは透過的なモノです。ライトバック方式のデメリットは CPU の実装上のデメリットであり、CPU の利用者である OS の立場からは、ライトバック方式のデメリットはなく高速なライトバック方式を採用しない理由がないです。 実際に Linux では全てのページフレームでキャッシュは有効であり書き込みの際には常にライトバック方式が設定されるそうです。

では逆に、なぜPentium などの CPU はライトバック方式のみではなくライトスルー方式も選択可能な形で実装しているのか、OS がわざわざライトスルー方式を採用するケースがあるのかどうかが気になります。

ということでざっくり調べてみましたが、軽く調べてみた限り CPU を作る立場から比較する記事が多かったです。

論点

結論

はっきりとはわからなかったですが、いい線いってるんじゃないかなと思います。詳しい人いたら教えてください。

新しい技術を追わない

時代はワークライフバランス。どうもかわしんです。

新しい技術は追わない。これは僕の個人的な方針です。別に最先端を追い続けることを否定するわけではありません。

ここでいう新しい技術は、web 界隈の新しいフレームワークとかライブラリとか SaaS とかです。

新しい技術を追い続けるのって大変

休日も時間を費やして勉強するのって大変ですよね。若いうちは Twitter で流れてくるいろんな技術記事を片っ端から貯めて通勤・通学時間に読んでましたが、もう 26 歳になって体力も時間も限られてきました。今はたまった記事も読まなくなったしそもそも興味を惹かれる記事が流れてこなくなって貯まらなくなりました。

必要ないものにモチベーションが湧かない

今勤めてる会社は社内ツールとか社内フレームワーク、ライブラリが溢れているガラパゴスな環境だから、勉強するモチベーションが湧かないです。使わないので。

あと、僕の興味が新陳代謝の激しい User facing な分野から Engineer facing な分野に移ってきたこともあります。

stable なものが好き

数年で陳腐化してしまうものを勉強するのってコスパ悪くないですか?

数年の時を経て淘汰された末に洗練された枯れた技術の方が好きです。そういう技術はドキュメントも整備されてるし、落とし穴も先人が踏み尽くしてくれてるので効率がいいような気がします。知の高速道路をダッシュするみたいな?

あと自分の作るものも数年で陳腐化するようなものを永遠にメンテし続けたくないので、安定したものに依存したいです。一度作ったらほったらかしにできるのが理想。

必要になっても勉強すればいい

新しい技術を追わない、時代においていかれるからといって不安は特にないです。

必要になったらドキュメント読んで勉強すればいいし、それで追いつけるくらいの自信はあります。

ただ、ちょっとアドバイスを求められた時とかに 1 から調べて答えないといけないので、その辺りの初速がガタ落ちするのが大きなデメリットです。やっぱりやるならやるで業界のデファクトに則ってやりたいし、やってほしい。

とはいってもなんだかんだ新しい技術は仕入れちゃうよね

追いはしないですけど、一応知りはします。ざっとホームページとか見てふむふむこんなものね、みたいな。技術の本質を理解しておけば、新しい技術とはいってもその応用なので。

新卒で入社して1年が経った

お久しぶりです。どうも、かわしんです。前回の投稿から丸々1年が経ったのでブログを投稿したいと思います。

1 年間何をしてたのか

1 年前、新卒として某外資系 IT 企業にソフトウェアエンジニアとして就職し、それから某 Android アプリを開発するソフトウェアエンジニアとして黙々と仕事をしていました。

3 年ほど前にアプリケーションエンジニアから離れることを決心して努力を重ねてきましたが、チーム配属の結果アプリケーションエンジニアとして戻って来ることになりました。僕は Android アプリエンジニアとしてエンジニアのキャリアを始めているので 5 年ぶりに原点に戻ってきた形です。正直チームが決まった時は就職せずに自分のやりたい方向を貫くかどうかかなり迷いましたが、社内でのチーム異動の文化があること、英語の環境で働く経験をしてみたかったこと、大企業で働いてみたかったこと、チーム異動によっては外国で働くことができる可能性、充実した福利厚生に惹かれてそのまま就職することにしました。就職して 1 年経ちましたがなんとかクビになることもなく楽しく仕事をできています。

就職するにあたり、1 年間勉強を止めることにしました。思い返せば 7 年前の大学 2 年生の時に起業してから休日も勉強か仕事をしていたのでここで少し休憩するのもいいかなと思ったのが理由です。新しい環境で成果を出すのに集中したいと思ったのもあります。このブログの更新もそれに伴って止まっていました。僕の github ページ の草たちが 2020 年の 4 月を境にパタリと止んでしまっているのは我ながらあからさまだなと思ったりしています。業務の内容が反映されていないのもありますが、土日は毎日遊んでいました。

大きな会社で働く違い

入社してからの大きな違いは、巨大なコードベースの全てを理解することなく変更を加えていくことです。今までの開発は全て自分 1 人か少人数で 1 から作りあげるタイプの開発だったのでそのプロダクトのコードは全て頭に入った状態で開発していましたが、僕が担当するアプリは歴史のあるアプリでそこそこ巨大でたくさんのエンジニアが同時に開発をしているアプリです。最初から全てを理解することは到底不可能で、必要な情報をコードベースの中から探し出して機能を加えていくのは初めてで新しい経験になりました。

暇になった

別の大きな変化として、突然暇になったことがあります。今までは常に仕事として実装する機能や勉強したいことがあったので時間があれば勉強か仕事をしていました。しかし、ワークライフバランスを重視する会社に入ったことで下っ端の僕がやる量は毎日時間内に終わるように管理されており、逆に長く働くことは推奨されません。また勉強することもないので突然夕方からやることがなくなり、暇になったことに最初は困惑していました。最初の方は本を読んだりゲームをしてました。(PUGB mobile とか world of warships blitz とか)。あと youtube を習慣的に観るようになりました。8 月の終わりに引っ越してからはバルコニーが広かったので友達を呼んで少人数でパーティをよくしてました。

勉強をしなくなった理由は、意識的に勉強をしないと決めたこともありますが、勉強するモチベーションがなくなったこともあります。僕の今までの勉強や開発のモチベーションは、僕が必要な物を作ることでした。僕の実生活であったら便利だなとか仕事のプロダクトです。そのプロダクトを作るにあたり必要なライブラリとかを開発していましたが、そのモチベーションは "必要だったから" です。htask管理上手のうさちゃん に必要だったからですし、わざわざ自宅 Kubernetes をやって wanpoll を作ったのも、うさちゃんをコスト低く運用する為でした。 Twilter も自分が楽に twitter を監視する為に作りました。仕事では、ネストした jsoncsv に変換する nested_csv を作ったりしてました。しかし、案外それ以外にもシンプルに興味があったから作ってみたもの (e.g. Cコンパイラとか OS とかトランザクションとか) もありますが。

ぶっちゃけ今の時点でこれ欲しいなって物はないので、これからは自分の興味のある勉強を再開していこうと思ってます。

英語

外資系の会社なので社内のコミュニケーションは全て英語です。またコロナのせいでリモートで仕事してるので日本人同士の雑談みたいなのもほとんどなく全部英語です。自分の入社前の英語スキルは自己紹介をカンペを見ながらやるレベルでとてもきつかったのですが、半年くらいしたらなんとかカンペなしで相手に伝えられる程度には成長できました。とはいえ、日常会話はまだまだ不自由で談笑できるまでではないです。個人的には単語や熟語の知識が欠落しているので単語の勉強を強化していきたいです。

最初は聞き取るのが絶望的にできなくて Google Meets の captions 機能がなかったら今まで会社に残れたかどうか怪しいです。今でも聞き取るのは苦手です。でも会社の人は優しいので聞き直してもゆっくり言ってくれますしなんとかなってます。

まとめ

なんだかまとまりのない文章になりましたが、言いたかったのはこの2つです。

  • この 1 年休憩してました
  • これから頑張ります

よろしくお願いします。なんか facebook に投稿してそうな内容だな。

原因不明のエラーなどない

お前のいう想定外はただの調査不足。どうも、かわしんです。新年度が始まり周りのみんなは働き始めているのを感じますが、僕は4月6日入社なのでもう少しモラトリアムを満喫します。

さて、去年開催された GoCon 2019 Spring で「エラー設計について / Designing Errors」という発表がありました。

docs.google.com

この発表ではエラーを「既知のエラー」「未知のエラー」と分類し、アプリケーションを動かしながら発生した「未知のエラー」に対するハンドリングを追加しながらどんどん「既知のエラー」に変換していくことで、ハンドリングされないエラーを減らしていこうというアプローチを紹介していたものと僕は認識しています。

しかし、具体的にエラーとはどこから発生するのかという説明がなかったため、いつどこから発生するかわからないエラーと戦い続ける必要があるようにも誤解される方がいるかもしれないと思ったのでこの記事を書くことにしました。

結論からいうと、全てのエラーは事前に予測可能であり恐れる必要はありません

前提

ここでは OS の上で動くソフトウェアのプログラミングを対象として扱います。C 言語とか Go とかの高級言語でのプログラミングです。

カーネルの中とか FPGA などのハードウェアプログラミングは対象としませんが本質は同じではないかなと思っています。

エラーの実態

エラーはプログラミング言語や書き手によって表現方法が違いますが、大抵の言語では「返り値」「例外」「シグナル」のどれかまたは複数で表現されます。どれを使うのがいいかということについては色々な考え方があると思うのでこの記事では触れません。

エラーはどこで発生するのか

我々がプログラミングをする上で扱うエラーの発生源は大きく以下の3つに分類できます。

  • 自分で定義して作り出すエラー
  • 関数を呼び出した時に発生するエラー
  • その他の実行時エラー

「自分で定義して作り出すエラー」は内部状態に対して入力値が異常である場合に処理を中断して発生させるエラーです。例えば、入力値のバリデーションエラーであったり、残高がないのにお金を引き出そうとしていた時のエラーなどがわかりやすいと思います。

「関数を呼び出した時に発生するエラー」は呼び出した関数の中で定義されて作り出されたエラーであったり、その関数がさらに別の関数を呼び出して発生したエラーであったりします。これらエラーはそのまま呼び出し元に返されることもありますし、別のエラーに変換されて返されることもあります。

さらに関数が呼び出した関数によるエラーを突き詰めて辿っていくと、全て「関数の作者が定義して作り出すエラー」と「システムコールから発生するエラー」にたどり着きます。システムコールは OS 上で動くソフトウェアとカーネルの境界をつないでいる命令で、 C 言語や Go などの大抵のプログラミング言語では関数の形で定義され、関数呼び出しと同じように扱うことができます。

システムコールがわからない人はコンピュータサイエンスの基本なので調べてみてください。簡単に説明すると、普通のプログラマが書くプログラムは「ユーザランド」という安全な領域で動き基本的にはメモリ演算しかできず、ネットワーク通信やファイル I/O、ディスプレイの出力、ハードウェア機器の操作などは悪いプログラムに乗っ取られるのを防ぐために全て「カーネルランド」という隔離された領域でしか実行できません。しかし、ユーザランドでしか動かないソフトウェアは何もできないので、ユーザランドは「システムコール」を発行してカーネルランドに処理の実行を依頼するという形になっています。

「その他の実行時エラー」は、不正な CPU 命令や不正なメモリアクセスを想定しています。これらが発生した場合は CPU 例外が発生し OS で定義された例外ルーチンが実行されアプリケーションは停止されたりシグナルが飛んできたりします。(詳しくないので間違っていたらすみません。)しかし、これらのエラーは大抵のメモリ安全な高級言語では仕組み上そもそも発生しえないものです。逆に発生するとデバッグは大変です。

また、ゼロ除算も実行時エラーです。言語によってはゼロ除算をした時の挙動は未定義だったりしますが言語によってはゼロ除算をするときにエラーを発生させてくれます。

全てのエラーは定義されている

さて、エラーの発生源を辿っていくと全て自分か誰か(ライブラリの作者など)が定義したエラーかシステムコールから発生するエラーにたどり着きました。

自分か誰か(ライブラリの作者など)が定義したエラーは自明に定義されていますが、システムコールから発生するエラーも全て定義されています。

例えば外部とのネットワーク通信で使うソケットの読み取り命令 recv を見てみましょう。recv man page とググれば以下の Linux のマニュアルが出てきます。または、ターミナルで man 2 recv と実行すると実行した OS のマニュアルが読めるはずです。

https://linuxjm.osdn.jp/html/LDP_man-pages/man2/recv.2.html

ここにはエラーの章があり、全ての発生しうるエラー(EAGAIN EWOULDBLOCK EBADF ECONNREFUSED EFAULT EINTR EINVAL ENOMEM ENOTCONN ENOTSOCK)とそのエラーがどのような時に発生するのかが定義されています。逆に言えばここに定義されていないエラーは絶対に発生しません。

ここまでで、原因不明のエラーなど存在せず全てのエラーが定義されているということがわかったと思います。

エラーハンドリング

さて、(理想的には)全ての関数から発生するエラーは全て定義されているのでそれに対するエラーハンドリングをしていけばいいということになります。

ここでエラーは大きく2つに分類されます。「ハンドリングする(回復可能な)エラー」か「ハンドリングしない(回復不能な)エラー」です。全て定義されているのでそこに想定外のエラーなどありません。

その関数のレイヤーで回復可能な場合はエラーハンドリングをして処理を続行し、そのレイヤーで回復不能な場合はそのエラーを呼び出し元に返し上のレイヤーでエラーハンドリングされることを期待します。

発生したエラーを全て上のレイヤーに戻していればプロセスは異常終了してしまいます。要件の厳しくないアプリケーションサーバなどであれば異常終了させて再起動して後でツギハギの対応をすればいいかもしれませんが、データベースサーバなど一度起動したら数年単位で落ちることが許されないソフトウェアであれば実装時に1つ1つのエラーを精査して回復可能なエラーを拾っていきます。想定外のエラーはただの調査不足です。

しかし現実

しかし、現実はそんなに簡単ではありません。ライブラリから発生するエラーがドキュメントなどで明確に定義されていない場合があります。その場合は冒頭で紹介したような「未知のエラー」を「既知のエラー」に変換していくアプローチが有効だと思います。

一方でもう1つ解決策があります。それは「ライブラリを使わない」ことです。ライブラリを追加すれば追加するほど自分が理解していないコードが増え、把握できていないエラーが発生する可能性が高まります。関数ごとのエラーが定義されていないライブラリは使わずに自分でシステムコールの上にエラー定義されたライブラリを築き上げていくのも有効な解決策だと思います。大変ですが。

言いたかったこと

  • 関数を作るときは発生し得るエラーを定義しろ
  • 全てのエラーを精査して、必要なハンドリングをしろ
  • エラーは全て定義されていて予測可能である。恐れる必要はない

番外編:境界をずらす

エラーの発生源は境界ごとに定義されていることで想定外のエラーのないソフトウェアを作ることができます。アプリケーションとライブラリとの境界(ライブラリ関数呼び出し)、ユーザランドカーネルランドの境界(システムコール)を今回の記事では紹介しました。システムコールについては完全にエラーが定義されドキュメント化されています。

さらに、システムコールの実装の中身をカーネルの中に辿っていくと同様に「カーネルの中で定義されたエラー」と「ハードウェアから発生するエラー」にたどり着きます。ハードウェアから発生するエラーはソフトウェアとハードウェアの境界で定義されたエラーです。

このように境界ごとに明確にエラーを定義することで想定外のないソフトウェアが実現可能になります。

番外編:ハードウェア障害

この記事では暗黙の前提として、ハードウェアが正しく動くことを前提としていました。しかし、ハードウェアも壊れることがあります。メモリが破壊されたり、ストレージが破壊されたり。これらの障害は OS のレイヤーで検知されてエラーとして発生することもありますが、アプリケーションが管理している領域のメモリの値が変わってしまったり、1 + 1 を 3 と CPU が計算してしまったような場合には対応が難しいです。エラーハンドリングの処理自体が正しく動くかわかりません。僕は、普通のソフトウェアを作っている場合はこのような状況はどうしようもないので異常終了するべきだと思いますが、世の中にはこのようなコンピュータが正しく動かないことも想定したソフトウェアの世界があるのかもしれません。(ないのかもしれません)

失敗した AWS Batch ジョブを Slack に通知する (Terraform を使って)

サーバーレスでピタゴラスイッチ。どうも、かわしんです。イベントをサーバレスで繋げてピタゴラスイッチを作るのって案外楽しいもんですね、GUI コンソールで作ってる限りは。

さて、今回は AWS Batch のジョブ実行が失敗した時に Slack に通知する機能を作りたかったのですが、断片的な記事しか見当たらなかったのでこの記事でまとめようと思います。また、今回はインフラ構築ツールとして Terraform を使います。

多分、断片的な記事を普通に繋げてると動かないハマりポイントがあるので、後学の為に注意喚起するという目的もあります。

全体のアーキテクチャ

全体の流れはこんな感じでイベントを繋げていきたいと思います。

Batch -> CloudWatch -> SNS -> Lambda -> Slack

AWS Batch ではジョブの状態が変わるたびにイベントが発生します。 CloudWatch Event Rule を設定してイベントの中から FAILED になったイベントのみをフィルタリングして SNS Topic に流します。

AWS SNS はイベントを受け取ったら Lambda を起動して、Lambda に登録した Node のコードがイベントを整形して Slack に Webhook を叩いて通知するという流れです。

CloudWatch から Lambda を直接起動することもできますが、SNS を経由することで Slack の他にもメールなどの他のチャネルへの通知を拡張することができる為、SNS を経由することにしました。

今回は、この CloudWatch と SNS と Lambda を Terraform を使ってセットアップしていきます。

Lambda で動かすパッケージ

さて、CloudWatch のイベントを Slack へ通知する Lambda Function は標準では用意されていません。イベントの JSON を整形して Slack のコメントとして Webhook を叩く処理を実装する必要がありますが、一から書くのは面倒です。

そこで、aws-to-slack というツールスタックを利用することにしました。

github.com

aws-to-slack は CloudWatch や Lambda のセットアップまでを含めて make deploy を使って簡単に完了させることができるツールスタックです。しかし、AWS リソースのセットアップには CloudFormation を使っている為 Terraform との相性は悪く、勝手に AWS リソースを作られるのも気に入りません。

make package コマンドを実行すれば Lambda で実行する Node のパッケージがコンパイルされる為、Lambda 上で動かすコードだけを aws-to-slack では利用することにしました。

事前に以下のコマンドを実行してコンパイル結果である release.zip を生成します。

$ git clone https://github.com/arabold/aws-to-slack.git
$ cd aws-to-slack
# 生成物 release.zip ができる
$ make package

aws-to-slack ディレクトリに生成された release.zip を任意の S3 バケットにアップロードしておきます。

構築する Terraform

S3

S3 にアップロードした aws-to-slack の release.zip を参照する為に data.aws_s3_bucket_object.lambda_to_slack を作ります。

/*
 * https://www.terraform.io/docs/providers/aws/d/s3_bucket_object.html
 */
data "aws_s3_bucket_object" "lambda_to_slack" {
  bucket = "<bucket_name>"
  key    = "<key_prefix>/release.zip"
}

IAM Role

aws-to-slack の CloudFormation で指定されているような IAM ロールを作ります。

/*
 * https://www.terraform.io/docs/providers/aws/r/iam_role.html
 */
resource "aws_iam_role" "iam_for_lambda" {
  name = "iam-for-lambda"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
}

/*
 * https://www.terraform.io/docs/providers/aws/r/iam_role_policy_attachment.html
 */
// https://github.com/arabold/aws-to-slack/blob/1451c1beae7b8f635c42161587bddbce04442857/cloudformation.yaml#L68-L70
resource "aws_iam_role_policy_attachment" "iam_for_lambda1" {
  role       = aws_iam_role.iam_for_lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy_attachment" "iam_for_lambda2" {
  role       = aws_iam_role.iam_for_lambda.name
  policy_arn = "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess"
}

resource "aws_iam_role_policy_attachment" "iam_for_lambda3" {
  role       = aws_iam_role.iam_for_lambda.name
  policy_arn = "arn:aws:iam::aws:policy/AWSCodeCommitReadOnly"
}

Lambda

Lambda のセットアップをします。Lambda Function の具体的な設定は aws-to-slack の CloudFormation を参考にしています。

Slack の Webhook URL は https://my.slack.com/apps/manage/custom-integrations にアクセスして生成します。

/*
 * https://www.terraform.io/docs/providers/aws/r/lambda_function.html
 */
// https://github.com/arabold/aws-to-slack/blob/1451c1beae7b8f635c42161587bddbce04442857/cloudformation.yaml#L46-L63
resource "aws_lambda_function" "lambda_to_slack" {
  s3_bucket         = data.aws_s3_bucket_object.lambda_to_slack.bucket
  s3_key            = data.aws_s3_bucket_object.lambda_to_slack.key
  s3_object_version = data.aws_s3_bucket_object.lambda_to_slack.version_id
  function_name     = "lambda-to-slack"
  handler           = "src/index.handler"
  role              = aws_iam_role.iam_for_lambda.arn
  memory_size       = 256
  runtime           = "nodejs10.x"
  # Cross-region metrics lookup requires at least 10s
  timeout = 15
  environment {
    variables = {
      SLACK_CHANNEL  = "<channel_name>"
      SLACK_HOOK_URL = "https://hooks.slack.com/<hook_api_path>"
    }
  }
}

/*
 * https://www.terraform.io/docs/providers/aws/r/lambda_permission.html
 */
resource "aws_lambda_permission" "sns_to_lambda_to_slack" {
  statement_id  = "lambda-to-slack"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.lambda_to_slack.function_name
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.cloudwatch_slack.arn
}

CloudWatch

resource.aws_cloudwatch_event_rule.batch_failed_job を設定して Batch から発生するイベントをフィルタリングします。これは以下の AWS 公式のチュートリアルを参考にしました。

参考 : チュートリアル: 失敗したジョブイベントに関する Amazon Simple Notification Service アラートを送信する

/*
 * https://www.terraform.io/docs/providers/aws/r/cloudwatch_event_rule.html
 */
resource "aws_cloudwatch_event_rule" "batch_failed_job" {
  name = "batch-failed-job"

  event_pattern = <<PATTERN
{
  "detail-type": [
    "Batch Job State Change"
  ],
  "source": [
    "aws.batch"
  ],
  "detail": {
    "status": [
      "FAILED"
    ]
  }
}
PATTERN
}

/*
 * https://www.terraform.io/docs/providers/aws/r/cloudwatch_event_target.html
 */
resource "aws_cloudwatch_event_target" "sns_slack" {
  rule = aws_cloudwatch_event_rule.batch_failed_job.name
  arn  = aws_sns_topic.cloudwatch_slack.arn
}

SNS

SNS の設定はこんな感じになります。resource.aws_sns_topic.cloudwatch_slack.policy については後述します。

/*
 * https://www.terraform.io/docs/providers/aws/r/sns_topic.html
 */
resource "aws_sns_topic" "cloudwatch_slack" {
  name = "cloudwatch-to-slack"

  // https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/resource-based-policies-cwe.html#sns-permissions
  policy = <<POLICY
{
  "Version": "2012-10-17",
  "Id": "__default_policy_ID",
  "Statement": [
    {
      "Sid": "__default_statement_ID",
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": [
        "SNS:GetTopicAttributes",
        "SNS:SetTopicAttributes",
        "SNS:AddPermission",
        "SNS:RemovePermission",
        "SNS:DeleteTopic",
        "SNS:Subscribe",
        "SNS:ListSubscriptionsByTopic",
        "SNS:Publish",
        "SNS:Receive"
      ],
      "Resource": "arn:aws:sns:<region_name>:<account_id>:cloudwatch-to-slack",
      "Condition": {
        "StringEquals": {
          "AWS:SourceOwner": "<account_id>"
        }
      }
    },
    {
        "Sid": "TrustCWEToPublishEventsToMyTopic",
        "Effect": "Allow",
        "Principal": {
            "Service": "events.amazonaws.com"
        },
        "Action": "sns:Publish",
        "Resource": "arn:aws:sns:<region_name>:<account_id>:cloudwatch-to-slack"
    }
  ]
}
POLICY
}

/*
 * https://www.terraform.io/docs/providers/aws/r/sns_topic_subscription.html
 */
resource "aws_sns_topic_subscription" "cloudwatch_slack" {
  topic_arn = aws_sns_topic.cloudwatch_slack.arn
  protocol  = "lambda"
  endpoint  = aws_lambda_function.lambda_to_slack.arn
}

ハマりどころ:SNS トピックの Policy

さて、最初の時点では aws_sns_topicpolicy は指定していませんでした。その状態で試してみると、CloudWatch Event Rule から SNS Topic への送信に失敗します。

さらに、AWSGUI コンソールから CloudWatch Event Rule を作ると Terraform で作ったルールと主導で作ったルールの両方でイベントの送信に成功するようになります。そのあとに主導で作ったものを削除して Terraform で作ったものだけに戻すと失敗するようになります。

ここで Terraform の aws_cloudwatch_event_target のドキュメントをよく読むと以下のように注意書きがされています。

Note: In order to be able to have your AWS Lambda function or SNS topic invoked by a CloudWatch Events rule, you must setup the right permissions using aws_lambda_permission or aws_sns_topic.policy . More info here .

https://www.terraform.io/docs/providers/aws/r/cloudwatch_event_target.html

つまり、SNS トピックに送信する場合には aws_sns_topicpolicy を指定する必要があるということです。しかし、具体的に何を書けばいいのかは教えてくれず無造作に AWS のドキュメント へのリンクが置かれているだけです。

このドキュメントを読むと、一度 SNS トピックを作った後でその Policy に以下の Policy を追加するように書かれていました。

{
  "Sid": "TrustCWEToPublishEventsToMyTopic",
  "Effect": "Allow",
  "Principal": {
    "Service": "events.amazonaws.com"
  },
  "Action": "sns:Publish",
  "Resource": "arn:aws:sns:region:account-id:topic-name"
}

そこで実際に作成した SNS トピックの Policy を調べて前述の resource.aws_sns_topic.cloudwatch_slack.policy が完成しました。

しかし、この Policy の中には SNS トピックの ARN が含まれています。Terraform のように1度のオペレーションで Immutable にリソースを作成する場合は事前にリソースの ARN を参照することは自己参照になりできません。

つまり、これを綺麗に Terraform で実現することはできないのです。だから、Terraform のドキュメントでは具体的な手法を説明するのではなく無造作にリンクが置かれているだけなのだと理解しました。不親切ですが。

一方で、アカウント名と SNS トピックの名前がわかっている場合はリソースの生成前に ARN を予測することができるため、今回は直接指定して事なきを得ました。

最後に

これで AWS Batch の失敗ジョブを Slack に通知することができるようになりました。初めてサーバーレスというものを使ってみましたが案外面白かったです。

ただ、GUI だと裏側でよしなにやっているごにょごにょした部分を自分で実装する必要があり Terraform などを使うと案外めんどくさいことがわかりました。

AWS Batch はジョブ起動のレイテンシーを考えない場合はコスト低くできる(低いとはいってない)方法なので使ってみてはいかがでしょうか?(AWS Batch ジョブの SSM 連携がないのでハマりました)