Unyablog.

のにれんのブログ

ISUCON 14: ClickHouse と OpenTelemetry で ISUCON の計測環境を作ったら快適だった

ISUCON 14 に id:utgwkk, id:wass80 と「ミレニアムサイエンススクール」というチーム名で参加した。

結果は 28875 点で 22 位!去年は fail して最下位だったので反省を活かすことができたのでは?

一昨年に Kibana / Elasticsearch / Filebeat で解析環境を組んでサクセスしていたが、今年は OpenTelemetry (Otel) をベースにモニタリング環境を作り直し、バックエンドは ClickHouse、フロントは Grafana の構成でやってみた。これがなかなか良かったので紹介。

コンテスト当日のアプリ改善周りは utgwkk の記事を見てください。

blog.utgw.net

序: ISUCON で必要な計測

ISUCON ではパフォーマンスを継続的に確認してボトルネックを探しながら改善していくのが王道ルート。 インスタンスの CPU 使用率や RAM 利用量といった一般的なメトリクスについてはベンチ中に netdata なり htop なりを眺めていれば良いが、それだけではアプリケーション内部や各 API パスの具体的な負荷状況は把握できない。

そこで、 Nginx のアクセスログMySQL のスローログ、さらにはアプリケーションの Trace を出力して集計することが重要になる。

有史以前からこの手のツールはいろいろあり、CLI だと kataribept-query-digest あたりが有名なところ。

そんな中、Nginx のアクセスログや pt-query-digest を常時集計してメトリクス化し、ダッシュボードで一元的に表示できるようにしたのが一昨年の記事だった。

nonylene.hatenablog.jp

またこれで組んでもいいのだけど、 適当に投入しまくって適当に使うと若干重かったり、クエリが手でぱっと書けなかったり…といった Kibana / Elasticsearch 特有のめんどくささがある(あと飽きた)のも事実なので、今回は別の構成を試してみることにした。

ClickHouse と OpenTelemetry と Grafana でサクセス

Kibana は Elasticsearch と密に紐ついているので、Elasticsearch をやめるとなると Grafana など他の可視化ツールを使うことになる。

いろいろ探していたところ、Grafana と ClickHouse の組み合わせが良さそうということで使ってみた。

また、Nginx 公式の otel module があることを発見したので、これを機に Opentelemetry (Otel) を使って Trace も(Google Cloud Trace ではなく)自力で取り込んでみることにした。

ClickHouse

ClickHouse はもともと Yandex で作られていたものが OSS になり企業としてもスピンアウトしたものだそうで、カラム志向のデータ構造をした OLAP 向けデータウェアハウスとのこと。

clickhouse.com

Observability についても力が入っており(確かにモニタリングは OLAP の一種と言えそう)、Otel でログや Trace を ClickHouse に流し込み、Grafana の公式プラグインを使って Trace / Metrics / Timeseries の可視化ができる。

clickhouse.com

各種データの操作は SQL ベースの言語で行う。 Observability の文脈で SQL というのはなかなか珍しいが、Prometheus のわけのわからないクエリよりも100倍分かりやすくて良いと思う*1

また、データウェアハウスらしくデータの様々な加工・集計・取り出しが高速に行えるので、 Otel の Processor で行うようなデータの前処理・ Grafana で行うような後処理を ClickHouse で完結できるのも良いポイント *2

立てる

まずは ClickHouse を立てる。今回は公式 Docker imageKubernetes 上で動かした。MySQL を動かすのと大体同じ感覚。

ユーザーを作る

デフォルトで作られる default ユーザーは権限最強で危険なので、Grafana と OpenTelemetry Collector (otelcol) 用のユーザーをそれぞれ用意する。

-- Grafana user
CREATE USER IF NOT EXISTS grafana IDENTIFIED BY 'password' SETTINGS max_execution_time CHANGEABLE_IN_READONLY, PROFILE `readonly`;
GRANT SELECT, SHOW ON *.* TO grafana;

-- otel_collector
CREATE USER IF NOT EXISTS otel_collector IDENTIFIED BY 'password';
CREATE DATABASE IF NOT EXISTS otel;
GRANT CREATE, INSERT, SELECT, dictGet ON otel.* TO otel_collector;

otelcol 経由でデータを投入する

otelcol-contrib は Exporter(送信先)として ClickHouse を設定できる。以下を参考にして otelcol-contrib の設定を行う。

clickhouse.com

設定して otelcol-contrib を立ち上げると、otel 用の Table を自動的に ClickHouse 側に作成してくれる。

SHOW TABLES

Query id: 4a180332-e499-492d-917b-b61ea8e342bb

    ┌─name──────────────────────────────────┐
 5. │ otel_logs                             │
 6. │ otel_metrics_exponential_histogram    │
 7. │ otel_metrics_gauge                    │
 8. │ otel_metrics_histogram                │
 9. │ otel_metrics_sum                      │
10. │ otel_metrics_summary                  │
11. │ otel_traces                           │
16. │ otel_traces_trace_id_ts               │
17. │ otel_traces_trace_id_ts_mv            │
    └───────────────────────────────────────┘

その後 otelcol で journald やファイルを読んだり、Trace を受信すると Clickhouse に各種データが投入され、Grafana で確認できる。

Explore で見るログ

Trace も Nginx から送ってみる。Nginx 公式 apt repo から Nginx と otel module を入れ、 otelcol に送るように設定することで簡単に Trace を見ることができた。

Nginx リクエストの Trace

スクリーンショットに Trace を取得するための SQL クエリが見えているが、このクエリは Grafana の流儀に合わせてデータを取り出す必要があるのでちょっと複雑。

ただ、基本的なクエリは Grafana ClickHouse Plugin の Query Builder で十分対応できるので直接 1 から書くことはない。Percentile など混み入った関数を使った集計やフィルタをしたくなったら、クエリを適宜編集することになる。

Materialized view を作成する

ドキュメントにも書いてあるが、otelcol によって自動的に作られた Table をそのまま利用することは推奨されていない。というのも、データ構造や貼られている Index は最低限のものであり、実際の利用に適したものにしたほうが効率が良いため。

これは前処理をしっかりしてから入れましょうね、という話ではなく、ClickHouse ではデータはそのまま入れて ClickHouse 側で処理を行うことが推奨されており、その処理を自分に都合の良い Schema でやりましょう、という話である。

We recommend users avoid doing excessive event processing using operators or transform processors. These can incur considerable memory and CPU overhead, especially JSON parsing. It is possible to do all processing in ClickHouse at insert time with materialized views and columns with some exceptions - specifically, context-aware enrichment e.g. adding of k8s metadata. For more details see Extracting structure with SQL.
...
We recommend users disable auto schema creation and create their tables manually. This allows modification of the primary and secondary keys, as well as the opportunity to introduce additional columns for optimizing query performance. For further details see Schema design.

Integrating OpenTelemetry | ClickHouse Docs

自分で Otel 用の Table から再定義してもいいが、今回は 面倒なので 自動作成される otel_* Table 自体は取り込んだ Trace / ログをそのまま保存する Table として触らずにしておき、 Nginx trace / pt-query-digest log / App trace / App log を集計する際はそれぞれ専用の Table を作成して利用することにした*3。これは ClickHouse の Materialized View 機能を使うことで実現できる。

clickhouse.com

ClickHouse で Materialized view を使うと、ソースとなるテーブルに Insert があった時にそれを元に加工した行を別の Table に Insert することができる。実質的にデータの前処理の役割を果たし、参照時は高いパフォーマンスで参照することができる。

たとえば Nginx の Trace 専用の Table (nginx_accesses テーブル) を作り、取り込んだデータ (otel_traces テーブル) を元にしてその Table に Insert するには以下のように設定する。

CREATE TABLE IF NOT EXISTS nginx_accesses (
    Timestamp DateTime64(9) CODEC(Delta, ZSTD(1)),
    TraceId String CODEC(ZSTD(1)),
    Operation LowCardinality(String) CODEC(ZSTD(1)),
    Duration UInt64 CODEC(ZSTD(1)),
    HTTPStatusCode LowCardinality(String) CODEC(ZSTD(1)),
    HTTPMethod LowCardinality(String) CODEC(ZSTD(1)),
    HTTPPath String CODEC(ZSTD(1)),
) ENGINE = MergeTree()
PARTITION BY toDate(Timestamp)
ORDER BY (HTTPMethod, Operation, toDateTime(Timestamp));

CREATE MATERIALIZED VIEW IF NOT EXISTS nginx_accesses_mv
TO nginx_accesses
AS SELECT
    Timestamp,
    TraceId,
    concat(SpanAttributes['http.method'], ' ', dictGet("path_regexp_dict", ('normalized'), SpanName)) AS Operation, # パス正規化用
    Duration,
    SpanAttributes['http.status_code'] as HTTPStatusCode,
    SpanAttributes['http.method'] as HTTPMethod,
    SpanAttributes['http.target'] as HTTPPath
FROM otel_traces
WHERE ServiceName = 'nginx';

注: Materialized view は、作成に既にあるデータは反映されない(反映してくれる POPULATE オプションがあるが、上記のようなケースでは使えない)。そのため、既存のデータを使うには Materialized view と同等のクエリを使って SELECT し、宛先 Table に既存のデータを Insert する必要がある。
Materialized view を変更するたびに宛先の Table の内容を変更しなおすのも面倒なので、変更時は Table / View ごと作り直すようにした。

構文としては SQL 的なので一度慣れたら簡単で、コンテスト中も App(Go で書いていたので Chi)のログに合わせて App ログ用のテーブルを作って otel_logs テーブルから流し込んだりしていた。

Grafana で可視化する

こうして Log も Trace も全部 Otel で ClickHouse に突っ込んで、ClickHouse 内で使いやすい形に整形し、Grafana でクエリして表示できるようになった。

Nginx モニタリングダッシュボード

例えばパスごとの p90 を上位から集計してテーブルで表示する(↑における右下のパネル)ためのクエリは以下のようになる。

SELECT Operation, quantile(0.9)(Duration)
FROM "otel"."nginx_accesses"
WHERE ( Timestamp >= $__fromTime AND Timestamp <= $__toTime )
GROUP BY Operation
ORDER BY quantile(0.9)(Duration) DESC
LIMIT 10000;

(ClickHouse とは少しずれるが) 今回は Nginx の層で Otel を有効にして Trace id をつけているので、 Nginx のダッシュボードから Trace を辿ると App の Trace も一緒に確認できて便利だった。

Nginx と App の Trace

前回同様、pt-query-digest も30 秒ごとに結果を集計してリアルタイムで出すようにした。下の記事では Filebeat などで前処理を頑張っているが、今回は Otel から ClickHouse に生ログを流し、ClickHouse 側で JSON をパースするようにしている。

nonylene.hatenablog.jp

pt-query-digest モニタリングダッシュボード

otelcol 側で処理を行わないので、競技インスタンスの負荷も少なく、ベンチマーク中もリアルタイムでリクエストの状況を見ることができた。また、リクエストパスの正規化など前処理の変更があっても各インスタンス上の otelcol を触る必要がないのも楽だった。

Trace もログも同じ Datasource に統一された形で入っているので、それぞれ ClickHouse でパースしてメトリクス的に利用しやすいのも良いポイント。今回はやっていないが、 Trace と Logs を関連づけるのもできそう。

パフォーマンスも申し分なく、時間を広げたり変更してもキビキビ動いてくれた。 Elasticsearch / Kibana のときよりも少ないリソースだったがより快適なモニタリング環境ができたと思う。

その他の点

  • ClickHouse
    • CLI の出来が良い。補完機能や履歴検索機能などよくある Shell にある機能が充実している。
    • エラーログがわかりやすい。 CLI でクエリを打っているときもそうだし、otel でデータを投入するときも権限の不足などエラーの原因がすぐにわかった。
    • 概念周りのドキュメントが分かりやすくて充実している。例えば上記に挙げた otel の記事を読むと otel の概要も把握できて便利。ただリファレンス部分は若干機能の追加に追いついてない部分があったりした。
    • Elasticsearch でも Ingest pipeline で同じような前処理ができるが、設定のやりやすさがかなり違うと思う
    • もちろん万能ではなく、何も考えずに数百万件のログをスキャンして Order by するようなクエリを打つと時間がかかったり RAM が 4GB でも不足したりする。ほとんど困ることはなかったが、適度に Schema / クエリ設計は行うことが求められる。
  • Nginx が Otel を吐き出してくれるのがかなり楽だった。ログを解析すると regexp を頑張って書かないといけないので。

なかなか良いものができたと思うので、来年も活用していきたい。Grafana に感じていた壁も解消された気がする。

ISUCON 14 当日の動き

  • いつもようにサーバー周りを色々やっていた。 MySQL の isucon user のホスト部がはじめから % になっていて感動した。
  • プロファイリングは今までと同様 Google Cloud Profiler を利用した。無料で優秀。
  • アプリはあまり手を入れていなかったが、終盤に GET /api/owner/charts のキャッシュ改善やクエリ改善を少し行っていた。数千点上がったのでヨシ!
  • デプロイスクリプトISUCON_MATCHING_INTERVAL 周りをちゃんとケアすることができずにトラブってしまったので反省。
  • 終了 20 分ほど前にほどほどにいい感じのスコアが出たので、フリーズしてあとはチームメイトに ClickHouse の紹介などをしていた余裕のある回だった。なおスローログもアクセスログも切り忘れたし Nginx の Otel 出力もそのままにしていました。

ということで今回の ISUCON も楽しかった。最近は Observability 関連の知らない技術を触ってみる機会にもなっていて、 Otel 自体もほぼ初めてちゃんと触ることができたのでよかった(Trace しかできないと思っていた)。

問題もしっかり作り込まれていて良かったです。ベンチなどの環境も非常に快適でした。チームのメンバー、そして運営のみなさん、ありがとうございました!

*1:自分の Grafana への忌避感は PromQL によるものが大きい

*2:Prometheus だとそもそもクエリが遅かったり、Grafana 側で Transform する羽目になってめっちゃ重くなったりするよね

*3:正確には Trace 自体についても Materialized view を利用して正規化している。長くなるので別の記事に書くかも

kubectl の wrapper を作る際の tips (ZSH 補完など)

kubectl に自動で namespaceや context を付与するような wrapper を作るとする。

そんなコマンドのインターフェースとして、

$ kubewrapper [...wrapper flags] [kubectl args / flags]
(例) $ kubewrapper -p staging describe pod ...

とすると

$ kubectl --context foo --namespace bar [kubectl args / flags]
(例) $ kubectl --context foo --namespace bar describe pod ...

といったコマンドが exec される、すなわちいくつかの flags を受け取って kubectl 用に変換しつつ args としては kubectl の引数をまるまる受け取るといったものが考えられる。

こういったコマンドを作るためには、補完をはじめとしていくつか注意点があるのでメモ。

外部 plugin 対応

通常の kubectl サブコマンド(getlogs など)では、 --context や --namespace といった flag はサブコマンドの前に置いても動作する。

しかし、外部プラグインを呼ぶサブコマンドでは動作せず、サブコマンドのあとに flag をつける必要がある。

$ kubectl --context foo awesome-plugin
Error: flags cannot be placed before plugin name: --context

get, logs などは後置でも動作するので、常に後置にする(args 1つ目の直後)ように作ると良い。

上述の例でいうと、以下のようになる。

$ kubectl [kubectl 1st arg] --context foo --namespace bar [kubectl remaining args / flags]
(例) $ kubectl describe --context foo --namespace bar pod ...

kubectl の補完を利用する

kubectl では強力な補完機能を備えており、

source <(kubectl completion zsh)

すると _kubectl 関数が ZSH にインストールされて、kubectl の呼び出し時に補完用関数として呼び出されるようになる。

上述した kubewrapper での補完でも、 kubewrapper のオプションの補完もしつつ、kubectl の補完も使えるようにしたら便利だが、どうすればよいのか?

_kubectl は何をするのか

_kubectlcobra を通じて生成されているが、その中では type されたコマンドの __complete サブコマンドが呼ばれている。

($ kubectl completion zsh の結果より)
requestComp="${words[1]} __complete ${words[2,-1]}"

色々細かい処理を省略すると、$words には今まさに打ち込んでいるコマンドの配列となっており、ZSH は 1-index なので words[1] は実行しようとするコマンド、 ${words[2,-1} は残りの引数ということになる。

すなわち、 $ kubectl get po<tab> を打っているとき、裏では _kubectl 内で $ kubectl __complete get po が呼ばれている。

_kubectl をカスタムコマンドで使う

ここで上述の kubewrapper の補完関数として _kubectl を設定するとどうなるのか。

_kubectl はあくまで今打ち込まれているコマンドを打つので、

$ kubewrapper -p staging get po<tab>

と打ったときは

$ kubewrapper __complete -p staging get po

が呼ばれることになる。

ここで kubectl の補完結果を流用するには、 wrapper 独自のオプションを消し、namespace や context flag を付加して kubectl の __complete コマンドを exec する、すなわち kubewrapper が以下を exec すれば良い。

$ kubectl __complete --context foo --namespace bar get po

※ __complete の前に namespace や context をつけると plugin 同様エラーになる

これを実現するには、wrapper の argv で __complete が 2 つめに来ていたら、$ kubectl __complete を打とうとしていると解釈すれば良い。wrapper で必須の引数が __complete の後にくることになるので、順序を入れ替えて解釈すると楽。

$ kubewrapper __complete -p staging get po
-> wrapper 内部では以下のように解釈する
$ kubewrapper -p staging __complete get po
-> そうすると wrapper の機能で自然と __complete が打たれる
$ kubectl __complete --context foo --namespace bar get po

wrapper のオプション補完

ここまで話した通り kubectl 関係の補完は _kubectl を通じて wrapper を呼び出して $ kubectl __complete に変換すればいい感じになるが、 その前に wrapper の必須 flag を埋めてもらう必要がある。

wrapper の必須 flag がなければそれを補完、満たされていれば kubectl の補完を発動させたい。

これは気合でいい感じに補完関数を書くことになる。

How do I check whether a zsh array contains a given value? - Unix & Linux Stack Exchange を参考にしつつ、以下のようにした:

if [[ $words[(Ie)-p] == 0 ]]; then
  # _arguments に wrapper の補完のみを記載する
  _arguments ...
else
  # _arguments に kubectl の補完も加える
  _arguments ... \
    '(-)*:kubectl args: _kubectl'
fi

(-)* は kubectl の args が現れた後は全てのハイフンの補完を無視するという意味。

zsh.sourceforge.io

こうすると、最初は wrapper に必須の flag のみが補完され、全て埋まったら wrapper の他の flags とともに kubectl の補完も表示されるようになる。

WSL2 を Windows 11 で起動させ続ける

Windows 11 からかはわからないが、WSL2 がしばらく放置していると自動でシャットダウンされるようになった。

ターミナルや VS code を全て閉じるとシャットダウンされる傾向にあるようで、例えば ssh-agent や systemd の service が動いていてもいつの間にかシャットダウンされるので厳しい。

調べてみると init にぶら下がっているプロセスがなくなると死ぬのでは?ということだった。 vmIdleTimeout の情報も出てくるが、これは WSL2 が乗っている VM を起動させ続ける話で、WSL2 で動いている Ubuntu などは関係なく終了する。

qiita.com

対策として、Powershell 側から wsl.exe を発行し続けるなど色々あるみたいだが、 /etc/wsl.conf に起動コマンドを設定することで回避ができたのでメモ。

learn.microsoft.com

/etc/wsl.conf で以下のように設定すると sleep コマンドが init にぶらさがって起動してくれる。

[boot]
command=sleep infinity

これで設定してから数日経ったが、起動し続けてくれていそう。

git-browse-remote の python 版を作った

git-browse-remote を長らく使っていたが、最近の Ruby では動かなくなっており、インストール後に手で該当箇所を修正してしのいでいた。

ただ、もう Ruby / Gem はこれにしか使ってなかったというのがあり、環境を新しくした機会に Python で書き直した。

github.com

Git の操作で libgit2 / pygit2 を使ったが、結構コマンドインターフェイスと感覚が違って難しかった。 GitPython のほうが楽だったかも。

Pulumi で Google Cloud と k8s のリソースを管理するようにした

プライベートで Google Cloud を使っていて、今まではあまり IaC は使わずポチポチでやっていた。GKE ぐらいしかまともに使ってるリソースがなかったのでそれでよかったけど、最近 VPS をやめて各種クラウドに寄せるようにしており、クラウドのリソースが増えてきた。

ポチポチだと見落とす設定もあったりするので、IaC (Pulumi) を本格的に取り入れることにした*1

ついでに k8s のリソースも kpt と Helmfile から Pulumi 管理にしたので、そのあたりの話。

Pulumi

Pulumi とは IaC ソリューションの一つで、要するに Terraform 的なもの。 はじめは Terraform とか知ってるし普通すぎるし Pulumi のほうが面白そう、といった理由で使い始めたのだけれど、今でも満足して使っていている。

Terraform との大きな違いは、Python や TypeScript などの一般的な言語を使って記述すること。

www.pulumi.com

www.pulumi.com

※ いろいろ言語が選べるが、この記事では自分が使っている Python での話をしていく

一般的な言語が使えることの何が嬉しいかというと…

プログラミングと同じ作法・感覚での記述

Terraform だと、HCL という Hashicorp 独自の言語を使う必要があり、ちょっと変わった処理をしようとするとすぐリファレンスを見る必要がある*2。また、Provider のバージョン更新やディレクトリ構成など、 Terraform での作法を色々調べないといけない。

一方 Pulumi は普段プログラムを書くときと同じ感覚で IaC の記述ができる。 やることは非常に明快で、リソースに対応するクラスを使ってインスタンスを作るだけ。各種 Provider は Python module として提供されており、それらの更新やインストールは pip 経由で行うので、いつも通り requirements.txtpyproject.toml でバージョンの管理ができる。

この "いつも通り" が頭を使わずに済んで便利。

もちろん HCL より色々柔軟に書けて便利というのもあるが、それは個人レベルだとどっちでもいいかな。

IDE による強力なサポート

TypeScript や Python、Go など人気のプログラミング言語IDE のサポートが強力で、補完やドキュメントの参照などがスムーズに行うことができる。 Pulumi は Provider にちゃんと型ヒントやコメントをつけてくれているので、VSCode などで快適に IaC のコーディングができる。

Terraform にももちろんそういった VSCode 拡張はあるけど、どうしても MS が公式で提供しているものよりはクオリティやパフォーマンスが落ちてしまう。

Q. プログラミング言語で IaC…? Chef でみんな諦めたはずでは…?

あれは確かにつらいけど、背景が違うと思う。今は型があるし、IDE も強いし、Ruby ベースのよくわからない DSL ではない。また、生成したいものは基本的に宣言的なリソースであり Linux サーバーのような変化しうるものを相手にするものとは違うので、複雑な独自処理を書く機会は少ない。

CDK が便利だよね、というのと同じ感覚で見れば良いと思う。

その他嬉しいこと

Provider は Terraform と同等

Pulumi を使い始める前は Terraform で使えるものが使えないような事態を危惧していたけど、Provider は想像以上にたくさんある。

www.pulumi.com

これらすべてを Pulumi が毎秒更新しているかというとそんなことはなく、多くは Terraform provider から自動生成されたものらしい。なので Terraform でできることはだいたいできる。

www.pulumi.com

一部 Native provider というものがあり、これは Pulumi 開発のもので API から自動生成しているため対応の速さが自慢とのこと。

www.pulumi.com

Diff が見やすい

文字列の差分が色付きで出てくれて非常に見やすい。細かいパラメータの変更があったときにどこが変わったのかを目 grep せずに済む。

Import がめっちゃ楽

補足: 書いてる途中で Terraform にも似た機能が最近 Experimental リリースされたことを発見した Import - Generating Configuration | Terraform | HashiCorp Developer
Pulumi のほうが気楽そうではあるけど、TF でも自動生成してくれるようになるのはよさそう。

最初はポチポチで作って後から IaC で…ってのはよくあることだと思う。こういうときはポチポチで作ったリソースを State に Import することになる。

Pulumi ではこれが楽で、 Import 完了後にリソースに対応するコードを出力してくれるから、これをコピペすれば IaC 対応が完了する。

Please copy the following code into your Pulumi application. Not doing so
will cause Pulumi to report that an update will happen on the next update command.

Please note that the imported resources are marked as protected. To destroy them
you will need to remove the `protect` option and run `pulumi update` *before*
the destroy will take effect.

import pulumi
import pulumi_gcp as gcp

sa = gcp.serviceaccount.Account("sa",
    account_id="sa",
    display_name="sa",
    project="XXXXX",
    opts = pulumi.ResourceOptions(protect=True))
...

また、ドキュメントには Import コマンドの例が基本的に書かれているのも嬉しい(例: https://www.pulumi.com/registry/packages/gcp/api-docs/serviceaccount/account/#import)。

オープンソース

Terraform は最近ライセンスが BSL に変わってしまった。普段エンドユーザーとして使ってる分にはあまり変わらないが、やっぱりオープンソースだと嬉しいし、応援していきたい。

微妙なこと

State の refresh は自動で行われない

Terraform では plan すると refresh が必ず行われるので、IaC の外で変更があったときに気づきやすい。Pulumi は flag をつけないと自動で行われないので変更を見逃して適切な対応ができない可能性がある。

常に IaC でしか変更しないならいいけど、GKE のアップグレードとか色々あるよね。

Google Cloud で使う

Google Cloud 向けには Native provider と Terraform から変換した Classic Provider がある。今回はチュートリアルに従って Classic Provider を使っている*3

使ってみた感想としては普通に便利に使えるのだけど、 Google Cloud では Terraform を使ったほうが良さそう…😇

というのも、Google Cloud は Terraform を公式サポートしており、ドキュメントにも Terraform に関する記載が豊富だし、Provider の更新に Google の社内チームが関わっている。

github.com

また、最近はリソースを作っていると同等の TF 定義が右のパネルに出てきてくれたりする。

Pulumi を使っているとこれを参考しながら Pulumi のコードに落としていくことになるので、ちょっと悲しい…。普通に使えるんだけどね。

Kubernetes で使う

※ Terraform の k8s provider は使ったことないです

Manifest

自分は k8s の Manifest 自体は Kustomize などで YAML で書けばいいと思っており、こういうのを使う嬉しさは主に Pruning (不要になったリソースの削除)だと考えている。

その用途として kpt を使っていたけど、しょっちゅうよくわからない感じで壊れてイヤになってきたので Pulumi 管理にすることにした。そもそも kpt の主目的はそういうとこじゃない、というのもある *4

Manifest は Kustomize で組んでいるので移行は非常に簡単で、 Pulumi の Kustomize 対応を使って kustomization.yaml があるディレクトリの指定をするだけだった。

from pulumi_kubernetes.kustomize import Directory

Directory(
    "main",
    directory=".",
)

こうすると Kustomize をビルドして、ビルド結果に含まれる Deployment などの各リソースをそれぞれ管理してくれる。

使った感触としては非常に良くて、Server side apply してくれて安心だし、kpt よりも apply が早いし、diff も前述の通り見やすくなった。 変更時は READY になるかをちゃんと見てから古いリソースを削除したりと、安心して適用がしやすいのも良い。

Kustomize と Pulumi の組み合わせ、おすすめです!

Helm

Helm はもともと Helmfile で管理していた。それほど不満はなかったが、Pulumi でも Helm の管理ができるので Manifest と一元管理することにした。

Pulumi は Helm を入れるためのリソースとして ChartRelease があり、Chart が Pulumi 側で一旦展開され、てインストールするもの、Release がインストールも Helm に任せるものとなっている。

www.pulumi.com

↑にあるように一長一短あり、Chart だと Pulumi が各リソースを管理しているのでリソースの細かい diff まで確認することができる一方、Hooks など Helm 関連の機能が使えない、 Release だと Helm の全機能が使えるが diff は values の範囲に限られてしまう。

今回は互換重視で Release にした。これも良い感じに使えていて、Helmfile より UI が良くなった上に $ pulumi up コマンド一回で k8s Manifest と一緒にデプロイできるようになり、楽になった。

一方、これは cdk8s でも感じたことだが、values を Python で書くことになるので REAMDE とかにある例をそのままコピーできないのは面倒くさい。せっかく Pulumi ・ Pythonなので、 values は YAML として置いておき、PyYAML を使って読むようにしても良いかもしれない。


Pulumi のユーザーは Terraform より少ないのでナレッジは少ないけど、ドキュメントがしっかりしているので特に困ることはなかった。同じような話で Google Cloud などサポート的に Terraform を使ったほうが便利な局面もあるものの、 TF の Provider を自動変換してることもあって機能面では不自由なく使うことができる。なにより自分に馴染んだ言語で書けるというのがすばらしく、そして楽しい!

k8s のサポートもよくできているし、普通とはちょっと違うものを使ってみたい欲も満たせて*5なかなか良いものだった。これからも使って & 推していきたい。

*1:逆に Itamae は使わなくなった

*2:Object の書き方がいっぱいあるのやめてほしい…

*3:今は Native は Preview だし、 Classic は Google 公式の TF Provider が元になってるしで Classic のほうが少なくとも今は良いよね、という話もあった https://github.com/pulumi/pulumi/discussions/12470

*4:最近 kubectl 側で ApplySet というのが議論されているが、まだ alpha で今使うものではなさそう

*5:これが一番大事

検索システムのフロントを SSR・Remix で作り直した

かなり昔に Elasticsearch ベースの検索システム(Heineken)を作っていた。

Elasticsearch で部内 Wiki 検索高速化 - Speaker Deck

特に更新せず数年動かしていたのだけど、サーバーの置き換えに伴って Kubernetes に置きたいよねという話になり、ついでに Elasticsearch も新しくしたいよね、となった結果、現状のフロントエンドだと最新の Elasticsearch では動かないということがわかった。

nonylene.hatenablog.jp

フロントエンドの改修が必要なわけだが、ここでフロントエンドの構成を見ると…

  • FlowType
  • create-react-app
  • PureComponent
  • Bootstrap 3

古すぎる!絶対アップデート難しいし触りたくない技術しかない。

フロントまわりの構成を変えたいとずっと思っていた(フロントから Elasticsearch に直接アクセスする構成のやめたかった)こと、また SSR あんまり触ってなかったことがあり、1から作り直すことにした。

~~ここから2年弱が経過~~

フレームワークの選定

React を Vanilla で使うことは Webpack 頑張ればできるのだけど、最近は何かしらのフレームワークを使うことが主流らしい。

ja.react.dev

SSR もやりたいし、はじめは Next.js で作ろうと思っていた…のだけど、 Next.js は色々聞いてると考えることが多くて大変だな…という印象になっていた。検索アプリなので SSG は不要だし、キャッシュも静的ファイルの範囲内で十分だし、 fetch の override とかしないでほしい。もっと軽量なものが使いたい。自分は薄いフレームワークが好きなのです。

そこで Remix を使うことにした。 Remix は SSG はやらなくて SSR 専門だし、React router がベースなので馴染みもあるし、 Web standards はいいぞってトップページで主張していて好感度高い。超薄くはないけどまあ SSR するならこんなもんでしょう。

remix.run

Remix で実装した

ということで数日かけて Remix で Heineken のフロントエンドを再実装した。今まではブラウザが fetch で直接 Elasticsearch にアクセスする構成だったが、 Remix のサーバーが Elasticsearch に問い合わせつつ SSR をして、 react-select などフロントで一部だけ描画する感じになった。

github.com

置き換えた感想としては、

描画が早い!!

あまりパフォーマンスのことを考えずに実装しても Elasticsearch の検索含めて 100ms 程度で描画されるようになった。SSR がすごいという話もあるし、各種コンポーネントのデータを並列で取得しているのも見ごたえあるし、パフォーマンスをあまり何も考えていない実装でそれを実現できているのが偉い。

結構ファーストビューの速さって体感に大きく関わるんだなと実感した。

細かいところ

Remix は外部ナレッジこそ少なめなものの、公式ドキュメントやチュートリアルが充実していて、 SSR 初心者でも割とスムーズに書くことができた。

とはいえ、色々思ったところ・つまづいたところはあったので書いていく。

Route

Remix では routes 以下にファイルを配置すると自動的にファイル名がパスとして認識され、そこで定義したコンポーネントにルーティングされる。SPA でルーティング書くの意外と面倒なので、これはなかなか便利だった。React router を内部で使っているんだろうけど、実際に意識することは全然なく、良い感じにやってくれている。

ファイル名ベースとディレクトリ名ベース両方使えるのも便利で、自分はディレクトリベースの route 指定を主に行うようにした。ディレクトリの中にそのパス関係のコードを全部置くことで Code colocation が上がってわかりやすい。

remix.run

Nested routes

Remix では Nested routing という機能があり、 /foo/bar/baz/1 といった URL では /fooコンポーネントを描画し、その中に /foo/bar を描画、 /foo/bar/baz/foo/bar/baz/1 と続くように描画させることができる。

URL 設計をきれいにつくるときれいな階層型のサイトができるということなんだろうが、これはちょっと微妙だなと思った。Example にあるようなシンプルな構成のサイトだといいけど、(React で作りたくなるような JS ベースのサイトは特に!)そんなことはなく、下部のパスが同じだからといって上部のガワが共通であるとは限らず(特に上部パス)、結局下から上に伝えたり、上から下に伝えたりする必要が出てくると思う。コンポーネントが同じだったとしても、Props の内容とか変わってくるんじゃないですかね。それを親側の route で URL をパースして書き換えるのか?いやうーん…。

今回もそうで、 Nested routes として共通化できたのは上部のヘッダーぐらいで、検索関係のページの Route は共通化されたコンポーネントを組み合わせた大きなコンポーネントとなり、いつもの SPA といった感じになった。

remix.run

URL や Nested routes にはこだわらず、どのようなデータをサーバーから取得したいか(loader がどのようなデータを返すのか)、で Route を分けるのが並列性を考えるとよいのかもしれない。

エラーハンドリング

Remix のエラーハンドリングは Route の中に ErrorBoundary という関数を定義して、そこでエラー内容を通常のコンポーネントの代わりに描画するというものだが、このやり方がなかなか難しかった。

remix.run

例えば検索フォームの Route を考える。ここで遷移(Search params の変更)が起きたときに loader で何かおかしなことがあればエラーを出したいのだが Route ファイル内に ErrorBoundary を定義して単純にエラーを出すとフォームが丸ごとエラー表示に置き換わるのでフォームの修正や再送信ができなくなる。

かといって通常の Route と同じ Component を流用して描画を行うのも難しい。ErrorBoundary 内では loader から得られるデータが使えないので描画に必要なデータを得ることができない。これは環境変数まわりで特に困った(後述)。

その他、loader 内のエラーがうまくクライアント側に伝わらないような挙動もあり、Remix でのエラーハンドリングは難しいな…という印象が残った。

github.com

ローディング

ローディングは defer / Suspense という仕組みが用意されており、loader 内で時間のかかるデータを待たずに一旦レスポンスを返しつつ、データが取得できるまでブラウザ側で代わりに表示するコンポーネントを指定できる。これ自体は非常に便利なのだが、Search params を変更したときはローディング状態にならないという仕様になっている。

github.com

今回作るような検索アプリでは URL に Search params として検索クエリを持つのが一般的だが、クエリを変更し Search params を変更しても Suspense はローディング状態にならため、ページ遷移が反映されていないように見えてしまう(最初のページロードだけローディングが出る形になる)。

対策として、上記コメントにある通り navigation の状態を見ることでローディングかどうかを自前でも判定することにした。うまく動くようになったものの、結局 Suspense の機能の外側でローディング中の文字を出す羽目になり、一体何をやっているのかという気持ちに。

github.com

環境変数

SSR ではサーバー側には環境変数があるがクライアント側は持っていないので、安易に process.env を使うとクライアント側で死ぬ。そこで Remix では loader で env を返すことを推奨されている。

remix.run

ただこれだと前述の通りエラーハンドリングで問題になる。ErrorBoundary で表示されるエラーは loader がエラーになった場合も含まれるので、 loader を ErrorBoundary で呼ぶことはできず環境変数を取得する術がない。

github.com

どうすればいいかというと、上記 Thread にある通り Root Route など別の(基本的にエラーにならないであろう)上位 loader で必要な process.env を返すように記述して子 Route から読みに行くか、Root Route 内でで Env を Context に追加するかといったところ。 どちらでも動くことを確認したけど、今回は React で慣れている Context を使うようにした。

Bootstrap

今回も Bootstrap を使ってスタイリングを行ったが、 SSR で Vanilla な bootstrap を使おう*1とするといくつかハマりポイントがあった。

Bootstrap の初期化

Bootstrap の CSS は import すればサーバーでもクライアントでも動くが、JavaScript はサーバーでは動かず、 import "bootstrap" すると document がなくエラーになる。

Bootstrap のコードはクライアント側だけで動いてくれればいいので、クライアントのみにバンドルされるコードである entry.client.tsx 内で import することにした。

super-heineken/app/entry.client.tsx at fdcdc2b4cdec48190296b513cdbacf3291ea4ab7 · kmc-jp/super-heineken · GitHub

Bootstrap の提供する APIコンポーネントで使う

その後、 Component 内で Bootstrap が提供する JavaScript コードを onClick 内で使いたいという場面が出てきた。ただ、 (今回使おうとしていた) Bootstrap の Collapse はコード上で評価した瞬間に document にアクセスするので、クライアント上でしか評価しないようにしないとエラーを吐いてしまう。

stackoverflow.com

これに関しても *.client.tsx の仕組みを使い、以下のようにして解決した。

remix.run

  • bootstrap.client.tsx 内で必要なものの import だけを行い即座に export するようにする。その export されたものをコンポーネント側で Import する
    • サーバー側では Import すると undefined になる。クライアント側では Bootstrap の Collapse が得られる
import { Collapse } from "bootstrap";

export { Collapse };
  • onClick などクライアントでしか動かないコード上で使う

その他、 bootstrap-select は BS5 で使えなくなっていて react-select に置き換えたりした。ただ react-select は emotion を使っていて SSR と相性が悪いのでそんなにおすすめはできない感(できるのだが *2、微妙 *3 *4) ...。 Downshift がいいらしいけど style 書くのめんどくさくてやめた。

…といった感じで、初めての SSR というのもあって、SSR 自体のつまづきポイントも含めて Remix のつまづきポイントは結構あった。まあ慣れてくるとどう対応すればいいのかもわかってくるけど、薄さに惹かれたわりには考えることは多かったなと思う。

最近の技術を触るのは楽しかったし、結果爆速なアプリケーションができたので満足はしている。食わず嫌いしている Nextjs や何もわからない Hono とかもまた触ってみたい。

*1:react-bootstrap だと React に寄ったコードになってしまい、後々のスタイルの流用がしづらい

*2:https://emotion.sh/docs/ssr

*3:https://github.com/emotion-js/emotion/issues/2800

*4:https://github.com/JedWatson/react-select/issues/3590

ISUCON 13 に参加した

去年11月、Smiling Face with Halo というチームで ISUCON 13 に参加した。チームメンバーはいつもの id:utgwkkid:wass80

スコアは 0! よって最下位!! 直前には 73k くらい出てはいた。

ちょっと遅くなってしまったけど、個人的に楽しい問題だったし振り返ってみる。

以下チームメイトの記事とレポジトリ。

blog.utgw.net

github.com

いつものごとく謎のユーザーで Commit されているのがだいたい自分。基本インフラ周りやってた。

DNS 対策

インフラ担当はたいていやることが決まりがちだったりするんだけど、今回は DNS 攻撃の対策担当になって色々考えていた。

まず MySQL の DB の参照を pdns と app で分けてみた結果(元から分かれてたかも?忘れました)、 DNS の参照負荷がかなり大きいことがわかった。そこで、一旦 DNS 関係を MySQL ともども一つのホストにまとめることにした。手始めに pdns が打つクエリにインデックスを貼ってみたけど、より高速に攻撃が来るようになっただけで一筋縄ではいかなさそう。

さすがに一台を DNS に潰しているのはよくないということで、 dnsdist (初見)を入れて真面目に対策を検討することにした。 tcpdump で見たところ大量の NXDOMAIN が来ているよう。

まずは、一定以上 NXDOMAIN が来たら Drop するなど BAN の仕組みを入れようとした。ただ、 dnsutil で送信元 IP アドレスとポート番号のペアで BAN を決定させる方法が分からず、うまくいかなかった。ベンチマークの IP アドレスは同じなので IP アドレスのみを基準として BAN をしてしまうと、正規リクエストも来なくなってしまう。異常なリクエストの送信元ポートは基本同じだったので、これがうまくできたらよかったんだけど。

さらにクエリを見ていると、NXDOMAIN のときのリクエストされるレコードは明らかにランダムっぽい。 regex で気合で防げるか・・・?と思いきや、アプリケーションが実際に使う record も(ベンチが走ったあとは)ランダム性が高く、確実に見分けるのは難しそうだった。

頑張っている様子。これで防げるのは一部

最終的に、NXDOMAIN なら多少遅延を入れてもいいのでは?ということに気づいた。攻撃が送られる送信元のポートが変わってないということは、レスポンスを遅延させると頻度も減るはず。

addResponseAction(RCodeRule(dnsdist.NXDOMAIN), DelayResponseAction(300))

これが非常に効果があり、攻撃頻度が無視できるレベルになり 1 台分のリソースがほぼほぼ空いた。うまく解決できてよかった~

pdns は実は使ったことがあったのだけど、dnsdist は初めてだったので楽しかった!設定言語が lua なのがよい。luaPython みを感じて割と好き。

反省点

いつも同じことしてるので細かい設定や各種ツールのインストールはスクリプトにしたほうがよさそう。

また、今回は前回やっていたような解析周りをあまり提供できていなかった(Kibana は用意していたが結局使わず)。需要があれば当日その場で作る、という気持ちでいたけど、ああいうのは突然欲しくなるものだし、なくてもなんとかなるけどあれば使うものなのでやっぱり準備しておいたほうが良かった。

さすがに前日深夜から全てを準備し始めるのはよくなかったね…。なんか GCP Ops Agent とか Prometheus 使ってみるかとちょっと頑張って諦めてたりしてたし…。

nonylene :tennouji_rina:
23:48 うおー立てるか
23:49 めちゃくちゃ風呂入ってた
01:39 google cloud managed prometheus 使おうかと思ったけど aws だと微妙だな~っていう結論になったので去年同様でいきます
03:26 とりあえず前回と同じ url でたてた(バージョンアップしたけどまあ大丈夫であろう)
03:30 会場 https://github.com/innocent-team/isucon13
03:37 tracer とかも変わらず使えそうだな

poyo.hatenablog.jp

そうそう、ぷりんくんのこの記事がめっちゃよかったです。Grafana で pprof 見れるのすごすぎる。クラウド使うと OSS 版より機能よかったりするし快適そうでいいですね。でも Kibana も UI 使いやすくておすすめだよ!!

その他

  • 前回からコードをレビューするムーブをしはじめて、今回も少し貢献できたので良かった
  • 最後の15分ぐらいでチーム3人の成果が集まってきて、デプロイごとに1万点上がっていくのがめちゃくちゃ楽しかった。最後は fail になっちゃったけど、最後ギリギリまで fix を入れて盛り上がってこそ ISUCON って感じがする。でもやっぱり fail は悔しいので今後も keep safe でギリギリまでやっていきたい。
  • tracing を外し忘れた結果、最後のベンチで明らかにリクエストが止まっている様子や追試うまく動いている様子がわかってちょっと面白かった。fail の覚悟ができてよかったけど外したほうがスコアに寄与しそう。

…ということで今回も楽しい問題でした、運営の皆さんありがとうございました!特にインフラ面でも触りがいがあるのは、さすが Sakura さんだなあと思いました。次は fail せずに勝ちたい!