その手の平は尻もつかめるさ

ギジュツ的な事をメーンで書く予定です

RADIUSのサーバー・クライアント実装をRustで書いた

An English article is here: https://dev.to/moznion/released-radius-rs-2e1o

タイトルの通り、AAA (Authentication, Authorization and Accounting) のためのデファクト・スタンダードプロトコルであるところのRADIUSのサーバー・クライアント実装をRustで書きました。

github.com

また、crates.io にもpublishしてあります: https://crates.io/crates/radius

このRADIUS実装の特徴としてはtokioを使用することで非同期 (async/await) ネイティヴな実装になっていることが挙げられます。
また、RADIUSgolang実装であるところのlayeh/radiusを参考にして、FreeRADIUSのdictionaryをコード生成器に食わせることによって、RADIUSアプリケーションの構築で必要となるRustの定義ファイルがRFCに基づいて自動生成されるようになっています。

簡単なサーバーとクライアントアプリケーションのサンプルは以下のような感じです。

割と簡単に書けて良いですね。


なんやかや、今でもRADIUS使うことってあるじゃないですか? もしも突然、RustでRADIUSアプリケーションを書く必要が出てきた際にはぜひご利用ください。

ところで、Rustでそこそこの規模感のあるコードを書いたのは実際初めてみたいなものなので実装上の問題などあるかもしれませんが、その場合はissue等でご指摘いただけると助かります。

ネットワーク越しリトライ考

ここ最近では何らかのインターネットサービスを構築・運用するにあたって、ネットワーク越しのリトライを考えることは避けられなくなりつつあります。
micro services のようなアーキテクチャを採用している場合はサービス間のメッセージのやり取りはまず失敗する前提 (つまりリトライをする前提) で組む必要がありますし、たくさんのクライアントがいてそのクライアントが定期的に何かを処理してセントラルにデータを送ってくる IoT のようなシステムを構築する時もその処理のリトライをよく考える必要があります。

というわけで「ネットワーク越しのリトライ」についてここ最近考えていることをざっくりと書き留めるものであります。

前提
  • リトライをする側をクライアント、リトライを試みられる側をサーバと呼称します
  • リトライにおいて、サーバおよびネットワークはクライアントよりも弱者です
    • クライアントはリトライをコントロール出来る側にいますが、サーバとネットワークはそれをまったくコントロールできないためです
  • クライアントはリトライ時、サーバに迷惑をかけてはいけません
    • クライアントが1つポシャるのはそのクライアントだけで不具合が完結しますが、サーバがポシャると自分も含めた多数のクライアントが被害を受けるためです
  • クライアントはリトライ時、ネットワークに迷惑をかけてはいけません
    • クライアントが1つポシャるのはそのクライアントだけで不具合が完結しますが、ネットワークがポシャるとクライアントどころではなくネットワークに所属しているホストすべてが被害を受けるためです
リトライタイミングについて
  • リトライをする際にはインターバルを設けましょう
    • インターバルなしでリトライしてリクエストを殺到させてはいけません
    • 最悪いつまで経ってもサービスが復活しなくなる可能性があります
      • サーバが高負荷でリクエストを捌けなくなる
      • ネットワークが輻輳してすべてが終わる
  • リトライをする際のインターバルには backoff を設けましょう
    • 一定周期のインターバルは無いよりはマシですが、一定よりもリトライ回数の増加に応じて間隔を伸ばしていく方が好ましいでしょう
    • インターバルを設けた上で複数回失敗するということは、サーバにパフォーマンス等の深刻な問題が生じているか、クライアントに「そもそものリクエストがおかしい (サーバ側で受け入れられない)」などの致命的な問題が生じている可能性が高いためです
      • サーバに問題が生じているときは時間を置いたほうが良いので backoff を設けたほうが良いでしょう
      • クライアントの問題である場合はそのゴミリクエストを頻繁に投げるのは迷惑なのでリクエストの間隔を広げて頻繁にリクエストを投げないようにしたほうが良いでしょう
    • Exponential Backoff などがよく使われる方法だと思います (たまに Fibonacci Backoff を見ることがある)
  • インターバルにはジッターを設けましょう
    • サーバあるいはネットワーク起因で問題が発生した場合、問題が起きるタイミングは複数のクライアントでほぼ同一です
    • その複数のクライアントが同時にリトライを試みた場合、リクエストが殺到するのでサーバ・ネットワークが負荷に耐えきれなくなる場合があります
    • リトライ間隔にブレを持たせることで、リトライタイミングがある程度分散してくれると期待できるようになります
  • キリがよい時間のリトライを避けましょう
    • 例えば定期的に実行するようなクライアントの場合、「定期実行に失敗したので次の0:00 GMTちょうどに再度実行してデータを送る」というふうな「キリの良い時間」の再実行はできるだけ避けましょう
    • これはリトライの話題というよりバッチ処理のタイミングの話題ですが、「毎時ゼロ分」のようなキリの良い時間はリクエストが殺到する傾向があります
    • まあこれは民間療法に近い……
  • ジッターと同等の話題ですが、クライアントのハードウェアの電源投入時に即リトライを試みるのはやめましょう
    • IoT 的な話題ですが、メンテ等でハードウェアを一斉に再起動させることがあると思います
    • この際に全デバイスが一斉にリトライを試みるとサーバ・ネットワークが負荷に耐えきれなくなる可能性があります
    • ジッターを入れましょう
    • ハードウェアプロダクトの場合、一度アプリケーションをハードに焼き込むとリプレースが大変な場合が多いので注意しましょう。リプレースができないということは、ずっとその問題と付き合っていく必要が出てきます
リトライリクエストについて
  • リトライを前提とするリクエストについては冪等性 (何度実行しても結果に一貫性があるという性質) を担保しましょう
    • リクエストが冪等でない場合、最悪システムが矛盾した状態に陥ります
  • 破棄して良いリトライリクエストなのか、破棄してはならないものなのかをしっかり区別しましょう
  • ゴミリクエストは破棄しましょう
    • サーバは内容由来で処理不能なリクエストを受け取った場合は処理不能の旨 (例: HTTPの4xxレスポンス) をクライアントに通達しましょう
    • クライアントは処理不能なリクエストを受け取った場合はそのリクエストを破棄しましょう
    • 一定回数リトライを試みても成功しない場合はそれ以上送っても受け入れられない可能性が高いので破棄することを検討しましょう
  • リクエストを破棄する際は、そのリクエストをリプレイ可能な形でログかなにかに残しましょう
    • トラブルシューティングやマニュアルオペレーションでのリクエストの再実行に用いることができます
    • 破棄時にアラートを発報するなども良いでしょう
  • クライアントとサーバの間にバッファ (例: ジョブキュー) を挟むことができる状況の場合はバッファを挟むことを検討しましょう
    • クライアントの責任を「バッファにリトライリクエストを詰める」というところに限定できる
    • サーバはバッファから「自分のタイミング」でリクエストを取り出して処理することに集中できるようになるので、コントロールをある程度サーバ側に引き寄せられるようになるでしょう
    • 破棄してはならないリトライリクエストについてはバッファを入れた方が堅牢にしやすくなると思います
    • とはいえ
      • バッファに詰める時にポシャったらどうするかと言うと、ここにもリトライを考える必要が出てくる……
      • バッファから取ってきたデータの処理に失敗した時にどうするかと言うと、ここにも場合によってはリトライを考える必要が出てくる……
        • バッファ環境で、サーバサイドのリトライをやるにあたっては冪等性が必須となるでしょう
  • リトライリクエストの内容をメモリに蓄積している場合はデータロストの可能性を考えましょう
    • メモリに内容を保っているプロセスがダウンするとリトライすべき内容が失われます
    • それが致命的な場合はなんらかのストレージに保存しておくか、リプレイ可能なログを残すべきです
  • 失敗した部分だけをリトライする「ソフトリトライ」と、失敗した部分を含む一連の処理ごとやり直す「ハードリトライ」の両方の方法を用意すると便利です
    • 基本的にはソフトリトライを実行して、そのソフトリトライで解決しない (例: リトライが一向に成功しない) 場合はハードリトライにフォールバックして結果整合を保てるようになっているとなにかと良いです
    • ここを自律的に行うのは少々大変ですが……
  • リトライのメトリクスが取れるのであれば取りましょう
    • 常にリトライされている状況はおそらく何かがおかしいのでそれは検出したほうが良いでしょう
    • 場合によってはアラート等を上げるのも良いでしょう
    • とはいえどうメトリクスを取るのか、サーバで取るのかクライアントで取るのか、など考えることはあります
強いリアルタイムが求められる時はどうするか

とはいえ強いリアルタイム性を求められる際には「インターバルをたくさん入れる」とか「backoff を入れる」とかが難しい場合があり、そういうときはどうすれば良いんでしょうね……正直明確な答えはありませんが、考えられるのは

  • そもそもネットワーク越しでのリトライをやめる
  • サーキットブレイカ
    • リクエストに失敗してもその時点でのリクエストは通るようにしておく
    • バックグラウンドでリトライ処理を走らせておいて結果整合を図る

などでしょうか……まあ他にも色々あると思いますが。
しかし例に示した「サーキットブレイカーを入れて結果整合を図る」というのは複雑度がバリ上がりそうで大変そうな雰囲気がありますね。大変です。そもそも結果整合で良いのか? (結果整合で良いのであれば強リアルタイム性いらなくない?) というところもあるでしょう。

まあこのへんは歯を食いしばって頑張るしか無いのでしょうね……

まとめ

結局これがキングです:

  • クライアントからサーバにリトライリクエストを送る時にリクエストを殺到させるのはやめましょう
    • サーバが死にます
    • ネットワークが死にます (特にこちらは死んでしまうとどうにもならなくなる)

気をつけましょう。気をつけます。

追記

追記2

そういえば TCP 等の再送処理の話を一切していなかったことに気づきました……まあ本記事のスコープ外とさせて下さい。

pprof を使って nodejs アプリケーションのプロファイルを取る

pprof って go のやつでしょ? node のプロファイルが取れるわけ無いやろ,と僕も思っていたんですが以下のライブラリを使うことで取れることがわかりました.

github.com

使い方については Using the Profiler に書いてあるとおりで,アプリケーション側に

const profile = await pprof.time.profile({
  durationMillis: 10000,    // time in milliseconds for which to 
                            // collect profile.
});
const buf = await pprof.encode(profile);
fs.writeFile('wall.pb.gz', buf, (err) => {
  if (err) throw err;
});

という風に書いてあげるとwall time (所要時間) ベースのプロファイルを取ることができます *1.また,その下の Collect a Heap Profile に書いてある手順に従うと heap のプロファイルをとることもできます.


というわけで Express を使ったアプリケーションのプロファイルを pprof で取得するサンプルプロジェクトを作成しました.

github.com

これを試しに実行してみると,以下のようなプロファイル結果を得ることが可能です.

f:id:moznion:20201005120750p:plain

見慣れた pprof の UI ですね.

f:id:moznion:20201005120815p:plain

Flame Graph も取れて非常に便利.これ普通にガチで便利に使えます.




最初は pprof を node に使うとかめっちゃ hacky だな〜と思っていたのですが,確かに go ではないプロジェクトであっても "pprof" というプロファイリングフォーマットに乗っておくと,プロファイルの解析や UI のエコシステムに乗れるから便利なんだな〜という気づきを得ました.共通フォーマットを活用することでレバレッジを効かせていくのは重要ですね.

*1:ところで "Requiring from the command line" に書いてある手順に従ってもうまくプロファイルが取れなかったのですが,これどうやるのが正解なんですかね……?

ISUCON 9 決勝を AWS 環境に本番さながらに構築するメモ

github.com

今日 (2020-09-24) の時点では「ローカル環境」で動かす方法については記載がある一方で,何らかのリモートの環境に「本番」っぽく動かす方法についての記載が無いので,それを AWS 上に構築するためのメモを記します.

競技用 application のデプロイ

isucon.net

これを見る限り,参加者側の環境は以下の通り:

アリババクラウドさんの ecs.sn1ne.large を採用しました。
CPUは2コア (Intel(R) Xeon(R) CPU E5-2682 v4 @ 2.50GHz)、メモリは4GBの、オーバーコミットのないインスタンスです。ネットワーク帯域も100Mbpsです。

ただし、今回のアプリケーションではメモリに全ての切らない環境を再現するために、Linuxにはメモリを1GBしか認識させていません。CPUは2コアで、メモリ1GBの環境を再現しています。
参加者にはこちらのインスタンスを3台提供しました。
デプロイには Docker Compose を採用しており、ホストのUbuntu 18.04にはDockerとNginxのみをインストールしています。

AWS で言うところの c5.large で良さそうに見えます.
c5.large だとメモリが 4GB なのでメモリを絞る必要がありそうですが,コレは大丈夫でした (後述).

application のセットアップについては普通に userplaybook 流したら動きました (ansibleのバージョンによっては細々手直しする必要はありそう,実際あります).

前述したメモリの量については ulimit かなんかで素朴に制限かけるかな〜と思っていたところ,playbook がいい感じで /etc/default/grub を以下のように (mem=1G) 書き換えて制限してくれたので問題ありませんでした *1.ただし reboot が必須.

...
GRUB_CMDLINE_LINUX=" net.ifnames=0 vga=792 console=tty0 console=ttyS0,115200n8 noibrs mem=1G"
...

「ハーンなるほど,それであればこの mem=1G を外して reboot すればフルでメモリ使えるから優勝だな? ガッハッハ!!」と悪巧みをしていたところ,それは以下のレギュレーション項目によって潰されていたのでズル不能でした.しっかりしていますね.

OSのメモリサイズが提供時と同じ状態であること

帯域幅については「ネットワーク帯域も100Mbps」とのことで制限が必要そうなのでこれは tc によって行いました *2

tc qdisc add dev ens5 root tbf rate 100mbit burst 50kb limit 500kb

これを手っ取り早く /etc/rc.local にでも書いておくと再起動しても適用されるので便利ですね.

ところでこの素朴な tc による帯域制御は egress にしか適用されないので,ingress にも適用しようとすると若干工夫をする必要があります.まあちょっと頑張って ingress にも適用できるスクリプトを書くか〜と思ったのですが,冷静に考えるとベンチマーカーと外部 APIインスタンスの egress にも帯域制限を適用すれば,とりあえず通る経路が一通り 100Mbps に制限されるのでそれで良しとしました.

あと TLS 対応しているのでなんとかする必要があるのですが……これについては諦めました. HTTP:80 で listen させるようにして,ベンチマーカーにはネットワーク内の private IP を参照してもらうようにしました.

ベンチマーカーと外部 API のデプロイ

ISUCON 9 のベンチマーカーおよび外部 API の動作環境についての記載がなかったので,代わりに ISUCON 8 決勝の環境を参照しました:

ベンチマーカー x 1
 - CPU 3コア : Intel(R) Xeon(R) CPU E5-2640 v4 @ 2.40GHz
 - メモリ 2GB
 - ネットワーク帯域 1Gbps
 - ディスク SSD
外部API x 1
 - vCPU 3コア : Intel(R) Xeon(R) CPU E5-2640 v4 @ 2.40GHz
 - メモリ 2GB
 - ネットワーク帯域 1Gbps
 - ディスク SSD

ISUCON 9 決勝の環境と大差なさそうだったので,ひとまずコレに従うことにしましょう.パッと見 c5.xlarge で良さそう.
メモリはgrubで絞れば良いとして,vCPUは……まあ良いかということで放置.あと tc は application と同様に適用する.

セットアップについては bench playbook を流し込めばOK.

こちらについても TLS 証明書を用意してあげる必要があり,まあ application と同じく平文でやっても良いか……と思ったのですが,なぜか気分が乗ったので certbot + nginx で乗り切りました (手動オペレーション,この辺読みながらやった: How To Secure Nginx with Let's Encrypt on Ubuntu 20.04 | DigitalOcean).




とりあえずコレで動きます.1GB しかメモリがないので mysqlinnodb_buffer_pool とかを潤沢に使うとインスタンスごとポシャったりして大変でした(なお潤沢に使わなくてもポシャる).swap が無いことに起因する話題な気がする.大変でしたね.

*1:それはそうとしてgrub書き換えてくるansible,良いですね……ちょい怖!

*2:本番は tc ではなかったでしょうが

Jenkins + AWS CodeBuildという構成をやめました

かつて Kyoto.なんか #4 で発表した話題ですけれども:

moznion.hatenadiary.com

これはもうやってません!!!(正確に言うと運用している組織内ではリタイアメントの段階に入っています)
今はCodeBuildを単体で使っています.


かつての AWS CodeBuild は

  • ビルド結果の通知が貧弱
  • Trigger が貧弱 (pull-requestに引っ掛けてビルドタスクを回す,みたいな機能が微妙だった)
  • ビルド履歴の一覧性が貧弱

という感じだったので,その不便さを補うために Jenkins を挟んで運用していましたが,今やこれらのペインポイントはほぼ解決しており*1 CodeBuild を単体で使っても充分に快適な開発体験が得られます.
むしろ今となっては Jenkins という部品を間に挟んでしまうとシステムの複雑度が上がってとっつきにくくなってしまいますし,なにより故障部品が増えるのであまり好ましい状況ではありません.強いこだわりや理由がなければ CodeBuild を単体で使うと良いと思います.

今考えると,昨年 fujiwara さんが発表していた「隙間家具」のような感じで Jenkins を活用していたと言えそうですね (結果的に円満に隙間家具ことJenkinsを外せたので).
speakerdeck.com


というわけで現在は Jenkins + CodeBuild という構成ではなく,CodeBuild 単体で使うようにしております,というご連絡でした.こういう情報はしっかりアップデートしておかないといけないなという気持ち (新たに使う人が出てくるかもなんで).

一時期の開発を支えてくれた Jenkins に感謝です.

*1:通知はもう少し細かい設定をしたくなることがありますが

GitHub Packagesにホストされたprivate packageをsbtから使う

表題のとおりです.
GitHubのオフィシャルドキュメントを読むとmavenで使う方法gradleで使う方法は紹介されているのですがsbtで使う方法が調べてもシュッと出てこなかったのでメモとして記す次第.


基本的には以下のようにmavenやgradleと同様にbuild.sbtに対して設定してやるとよろしい.

libraryDependencies += "com.example.your" % "package" % "0.0.1"

resolvers += "GitHub your-package" at "https://maven.pkg.github.com/example/your-package"

credentials += Credentials("GitHub Package Registry", "maven.pkg.github.com", "", sys.env.get("GITHUB_REGISTRY_TOKEN").orNull)

libraryDependenciesは普通にインストールしたいprivate packageを指定し,併せてresolversにはそのpackageに至る宛先を追加します (この時,例における"GitHub your-package"は自分の好きなわかりやすい名前をつけて良い./example/your-packageについてはpackageの属する/org/repoを指定する).

credentialsについては例の通り,第4引数になんらかの形でGitHubトークン文字列を与えると良いです.この際のGitHubトークンの詳細についてはこちらを参照ください: https://docs.github.com/en/packages/publishing-and-managing-packages/about-github-packages#about-tokens
注意としては,Credentialsの第1引数および第2引数は例のとおりである必要があるということです.第2引数については「まあそうでしょう」という感じですが,第1引数を自由気ままに設定するとうまく動きません.これはこの文字列を内部的にrealmとして利用しているためです.

また,Credentialsについてはbuild.sbtに直接書くのではなく,別ファイルに書き出しておくという方法もあると思います (ref: https://www.scala-sbt.org/1.x/docs/Using-Sonatype.html#step+3%3A+Credentials).この辺は状況やお好みに応じてという感じでしょう.


以上です.GitHub Packages,便利ですね.

いい感じにPHP Runtimeのdebパッケージを作る

PHPのエッジなバージョン (例えば今の時点だと 8.0.0alpha3) を使いたい場合,多くの場合はソースコードからビルドする必要があります.その他にもランタイムのオプションをいじりたいだとか,そういった様々な理由からPHPソースコードからビルドしなければならないことがあります.あるでしょう.

ソースコードからビルドするのはまあ良いんですが,そのビルド成果物 (つまりPHPランタイム) のデプロイ対象が多い場合に都度都度その環境でビルドをするのは時間がかかってしまいますし,計算機リソースの無駄遣いです.
というわけでDebianUbuntuを利用している場合だとdebパッケージを一度こさえて,それをばら撒くようにすると経済的です.

以下はミニマムな例:

$ curl -LO https://downloads.php.net/~carusogabriel/php-8.0.0alpha3.tar.gz
$ tar zxf php-8.0.0alpha3.tar.gz
$ cd php-8.0.0alpha3
$ ./configure
$ make
$ INSTALL_ROOT="/path/to/php-dist" make install

というふうにしておき, /path/to/php-dist/DEBIAN/control

Package: php8
Maintainer: moznion <moznion@gmail.com>
Architecture: amd64
Version: 8.0.0alpha3
Description: php 8 binary

というようなcontrolファイルを置いて,

fakeroot dpkg-deb --build /path/to/php-dist/ .

というふうにしておくと良い感じでdebパッケージが作成されます.


特記事項としては

  • ./configure の際に --prefix を付与してはならない
  • --prefix の代わりに make install の際に INSTALL_ROOT 環境変数を付加する

というあたりです.

というのも ./configure--prefix を付けてしまうと,build / install された成果物がそのprefixの内容に依存してしまうため,ビルドした環境の構成 (特にpath) とdebパッケージの展開先の環境構成が異なるときにうまく動作しなくなる場合があります.
例えば,./configure --prefix=/tmp/dist として makemake install すると,成果物は /tmp/dist 以下に展開されます.その状態でdebパッケージを作成し,そのパッケージを利用して別の環境にインストールすると,モノ自体は /usr/local 以下にインストールされますが,インストールされたモノたちは /tmp/dist に依存しています (例えば php.ini の場所は /tmp/dist/usr/local/lib/php.ini にあることが期待されてしまう).びみょう.

というわけで --prefix を渡さないようにしておきつつ (つまりデフォルトの /usr/local が利用される),しかしそのまま make install するとそのまま /usr/local 以下にモノがインストールされてしまいdebパッケージを作成するには具合が悪いので,INSTALL_ROOT 環境変数によって「インストール先だけを変える」ことにより,そのディレクトリに対してdebパッケージを作るといい感じに作成できます (つまりこの場合だと php.ini の場所は /usr/local/lib/php.ini になる).

良かったですね.