Unyablog.

のにれんのブログ

pt-query-digest の出力結果を Filebeat を使って Elasticsearch に集約し Kibana で出す

今年の ISUCON では Elastic Stack を使って MySQL のスローログを Kibana で可視化したが、いくつか課題があった。

  1. パラメーターによってクエリが分散し、ボトルネックが分かりづらい

    MySQL スローログは生のクエリが記載されていて、Filebeat の MySQL モジュールはそのまま送信するため、 SELECT クエリなどパラメーターが分散しがちなデータはうまく集計することができず上位に現れづらかった。

    Elasticsearch に入れる前に何らかの方法でパラメータを取り除いておきたい。

  2. ログエントリが多い

    スロークエリの設定によっては大量のクエリが記録され、その結果大量の(かつ大きな)エントリを Filebeat で処理して Elasticsearch に登録することになる。Filebeat の負荷が厳しくなってアプリのパフォーマンスに影響が出るほか、Kibana ダッシュボードももっさりしてしまう。

    全てのスロークエリをそのまま入れて毎回 Elasticsearch で計算を行うのではなく、ある程度まとめた結果を投稿するようにしたい。データの前処理ってやつ。

これを解決するために、スローログを pt-query-digest で定期的に解析し、その結果を Filebeat 経由で Elasticsearch に投入するようにすることで、 Kibana でリアルタイムにまとめて見れるようにした。

pt-query-digest を使おう

pt-query-digest は言わずと知れたクエリログの解析プログラムで、各種パラメーターを取り除いていい感じに抽象化し、それを集計して重いクエリを出してくれる。

デフォルトだとヒューマンリーダブルな結果が出力されるが、 --output オプションを変更すると json で出力することができる。Filebeat は json をパースして内容をフィールドに変換してくれる機能がある *1 ので、 json で出力できればパースの手間をずいぶん抑えることができる。

また、pt-query-digest には --iterations というオプションがあり、標準入力を継続的に読み取って定期実行することができる。

この機能を使って、スローログの内容を継続的に読み取って、 pt-query-digest の集計結果を毎分 json で出せるようにした。

$ sudo tail -F -n +1 /var/log/mysql/mysql-slow.log | pt-query-digest --output=json --iterations=0 --run-time=1m

Filebeat との相性問題

Filebeat に出力された json を直接読みとらせるとエントリとして登録はされるが、そのままだとまだ問題がある。

pt-query-digest の結果は一つの json の中に複数のクエリの情報が入っており、下のような構成になっている。

{
  "classes": [ {slow query info}, {slow query info}, ... ],
  "global": { command info }
}

詳細例: pt-query-digestを使用したクエリログの変換について | スマートスタイル TECH BLOG

ここで、Filebeat の ndjson input は 1 行を 1 エントリとして読み込んでしまうので、コマンド結果ごと・複数クエリを内包したエントリになってしまう。それでは解析もやりずらい。

エントリをクエリごとにするには Filebeat だけでは達成できず*2、Elastic Stack 内で済ます場合は Logstash を追加で使う必要がある。Logstash はヘビーなアプリケーションで大変なので、 今回は jq を使って Filebeat に流す前に array を各行に Flatten する。

$ sudo tail -F -n +1 /var/log/mysql/mysql-slow.log | pt-query-digest --output=json --iterations=0 --run-time=1m | jq -R -c --unbuffered 'fromjson | .classes[]' 2>/dev/null
{"attribute":"fingerprint","checksum":"E3341326DCBBC41D81C9550FEAE6F248","distillate":"SELECT user_present_all_received_history","example":{"Query_time":"0.100072","query":"SELECT * FROM user_present_all_received_history WHERE user_id=1662038388045 AND present_all_id IN (1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)","ts":"2022-09-01T14:02:35"},"fingerprint":"select * from user_present_all_received_history where user_id=? and present_all_id in(?+)","histograms":{"Query_time":[0,0,0,0,27,1,0,0]},"metrics":{"Lock_time":{"avg":"0.000001","max":"0.000006","median":"0.000001","min":"0.000000","pct":"0.070352","pct_95":"0.000001","stddev":"0.000001","sum":"0.000026"},"Query_length":{"avg":"196","max":"198","median":"192","min":"195","pct":"0","pct_95":"192","stddev":"0","sum":"5508"},"Query_time":{"avg":"0.080501","max":"0.100072","median":"0.078474","min":"0.073565","pct":"0.070352","pct_95":"0.090844","stddev":"0.005999","sum":"2.254020"},"Rows_examined":{"avg":"241698","max":"241773","median":"239140","min":"241638","pct":"0","pct_95":"239140","stddev":"0","sum":"6767570"},"Rows_sent":{"avg":"10","max":"27","median":"0","min":"0","pct":"0","pct_95":"26","stddev":"11","sum":"285"},"db":{"value":"isucon"},"host":{"value":"isucon5"},"user":{"value":"isucon"}},"query_count":28,"tables":[{"create":"SHOW CREATE TABLE `isucon`.`user_present_all_received_history`\\G","status":"SHOW TABLE STATUS FROM `isucon` LIKE 'user_present_all_received_history'\\G"}],"ts_max":"2022-09-01T14:13:51","ts_min":"2022-09-01T14:02:35"}
...

-R と 'fromjson' を使っているのは、時折流れてくる非 json なログがあっても処理を続行するため。

フィールドの型問題

これに Filebeat の ndjson input を使うことで各クエリの情報が Elasticsearch に毎分登録されていくようになったが、 pt-query-digest で出力される数値が json では string になっているため、 Elasticsearch では keyword として登録されてしまうという問題があった。keyword として登録されてしまうと数値として扱えず sum や count, top N ができない。

これに関しては、 Filebeat の convert processor を使って特定のフィールドを数値に変換するようにした。その他細々とした調整をして、最終的に filebeat.yaml では以下のような設定になった。

- type: filestream
  id: mysql-digest-jq
  paths:
    - /var/log/mysql-digest-jq.log
  parsers:
    - ndjson:
        target: "mysql-digest"
        add_error_key: true
        message_key: "fingerprint"
  processors:
    - add_fields:
        target: 'event'
        fields:
          dataset: "mysql-digest"
    - convert:
        fields:
          - from: 'mysql-digest.metrics.Query_time.avg'
            type: 'float'
          # 一度登録したフィールドの type は変わらないので、すでに登録してしまっていたら to で新しいフィールドを指定する
          - from: 'mysql-digest.metrics.Query_time.max'
            type: 'float'
          # その他使うフィールドを変換する
    - timestamp:
        field: 'mysql-digest.ts_max'
        layouts:
          - '2006-01-02T15:04:05'
        timezone: 'Local'

CPU 大量消費問題

下のような Systemd service を作成して、ISUCON の走っているインスタンスで使ってみた。

[Unit]
Description=pt-query-digest json dumper

[Service]
Type=simple
ExecStartPre=/usr/sbin/logrotate -f /etc/logrotate.conf
ExecStart=/bin/bash -c "tail -F -n +1 /var/log/mysql/mysql-slow.log | pt-query-digest --output=json --iterations=0 --run-time=1m | jq -R -c --unbuffered 'fromjson | .classes[]' >> /var/log/mysql-digest-jq.log"
Restart=always

[Install]
WantedBy=multi-user.target

みんな大好き bash -c 。これで動きはしたが、ベンチを回している途中に pt-query-digest がガンガン回って CPU を1コア食ってしまうようになった。

その対策としては、 CPUWeight をかなり低く設定し、ベンチ中は MySQL に優先して CPU 資源を使わせるようにした*3

ダッシュボード作成

十分に正規化されたデータを Elasticsearch に入れることができれば、あとはこっちのもの。

Kibana でクエリのいい感じの集約もできて、本当にネックとなっている重いクエリが簡単に分かるようになった。 イベントの量もかなり減って Filebeat のリソース消費で困ることはなくなった。

めでたし。

*1:https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-input-filestream.html#_ndjson

*2:1つのエントリ内の array を複数のエントリに分割することは Filebeat ではできない(多分)ので

*3:ちなみに Nice は全然効果なかった

ISUCON 予選突破のために Elastic Stack と GCP で分析環境を整えた (:old_noto_innocent: Team)

ISUCON 12 予選にいつもの id:utgwkk, id:wass80 と :old_noto_innocent: チームで参戦した*1
結果は 50696 点で本選出場!

ここ数回と同様、自分はインフラと分析・観測を担当していた。ただ、毎年似たようなことをやっていてあまり貢献できていないなあという課題感があったので、今回はログやメトリクスの分析環境をしっかり作ることにした。目標は毎回ベンチ終了のたびにコマンドを叩くのをやめること!

App 周りについてはチームメイトの記事を参照。

blog.utgw.net

memo.wass80.xyz

Elastic Stack でアクセスログ・スローログ可視化

ログを集約・可視化する代表的なアーキテクチャとして、Elastic Stack がある。 もともと Elasticsearch + Logstash + Kibana の組み合わせの "ELK Stack" が有名だが、最近は軽量ログシッパーである Filebeat も合わせて Elastic Stack というらしい。

www.elastic.co

↑ヘラジカの漫画が自己を見つめ直す話でよかったです。

Loki + Promtail + Grafana とどちらにしようか迷った結果、あんまり使ったことのない Elastic Stack を使ってみることにした。 あと Kibana って語感が好き。

ダッシュボード 最高便利

Filebeat から Nginx のログ、 app ログ、 MySQL スローログを Elasticsearch に投げ、それを Kibana で Log stream やダッシュボードで見れる環境を作った(構築については後述)。

Nginx はパスごとのアクセス数とそのレスポンスタイムの装合計、MySQL はスローログのクエリとその時間の総合計などを出すようにしている。

App log stream

Nginx dashboard

MySQL dashboard

特にダッシュボードが便利で、アクセスログやスローログを見るときに特定の時間で絞り込んだ集計が簡単にできる*2ため、ベンチを回す → 集計結果を見る という流れがとてもスムーズにできた。

ログは結局 journalctcl で見るのが早いのであまり使わなかった気がする。次はダッシュボードでエラーログもサクッと見れるようにしてもいいかもしれない。

Google Cloud でトレーシング & プロファイリング

トレーシングやプロファイリングは Google Cloud を使うことにした。Google アカウントは誰でも持っているので、チームメイトへの権限が簡単に配れてオススメ。

Trace

Cloud Trace  |  Google Cloud

前回は OSSJaeger を使ったが、 UI が使いにくかったりインメモリだと遅かったりで微妙だったので、ちゃんとマネージドサービスを使うことにした。

Google Cloud Trace は OpenTelemetry に対応しているので、サードパーティの Trace library を使って構築できて便利だった。予選では MySQL と HTTP リクエストに対してトレースを仕込んだ。

Cloud Trace。 N+1 してそうな様子が見える

Profiler

cloud.google.com

Google Cloud Profiler は

  • 導入がとても簡単
    • go ファイルの最初にちょろっと書くだけ
  • コンソールが見やすい
  • 無料!!

という3拍子揃った神サービスだった。

Cloud Profiler

バージョン番号を変えてデプロイし続けていたら競技途中で1日のデプロイ数 quota (75)に引っかかったので、そこは注意。

悲劇

やってよかった

Slack のヘッダーの様子

…このように今回は色々リアルタイム可視化グッズを用意して挑んでみた。

ISUCON でのこのような集計は pt-query-digestkataribe / alppprof を使うのが定番だが、今回はインストールだけはしたものの一度も使わずに済んだ。コマンドだとどうしても

ログ退避 → ベンチ → 集計コマンド発行 → 結果の共有 → テキストベースの解読

といった作業が必要になるが、 Kibana や GCP コンソールでそれを一気に省略できたのが大きかった。集計結果が Slack チャンネルに流れていかず、チームメイトが見たいときに自分で操作して確認できるのも良いことだと思う。

当日ダッシュボードを触ったりと付け焼き刃なところもあったが、チームメイトからも好評だったので準備したかいがあった。

ちなみに、これらの分析のオーバーヘッドはそこそこある。echo のデバッグモードなども行っていたのでどれが原因かは分からないが、全てを外してベンチを回すと 36k だったスコアが 50k ぐらいになって盛り上がった。

構築編

Elasticsaerch + Kibana 準備

個人の GKE クラスタに強めのノードをたてて*3、そこに Elasticsearch と Kibana を立ち上げた。

Elasticsearch には elastickibana_system ユーザー向けのパスワードを設定する *4

実際にはこんな感じ。

elastic のパスワード

nonylene.hatenablog.jp

kibana_system のパスワード

      - name: password
        image: curlimages/curl
        command: ["sh", "-c", "until curl -X POST -u elastic:${ELASTIC_PASSWORD} -H 'Content-Type: application/json' -k http://elasticsearch:9200/_security/user/kibana_system/_password -d \"{\\\"password\\\":\\\"${KIBANA_PASSWORD}\\\"}\" | grep -q '^{}'; do sleep 10; done;"]

Kibana へのアクセスはちょっとめんどくさくて、

  • 人間が見る用のパス
  • Filebeat から叩く用のパス

がある。人間が叩く用のパスは OIDC を使うのが正規ルート だろうが、今回は既に構築していた oauth2-proxy 背後にしていたので Kibana 自体は anonymous でアクセスできるように した。

ただ、oauth2-proxy 背後だと Filebeat からのアクセスができなくなるので、別途 Basic Auth 付きの endpoint を用意して Filebeat からはそれを叩かせるようにした*5。こうやってポコポコ endpoint 生やせるのは k8s のいいところ。

その他、各種データは永続化する必要がある。今回は Node Pool がスケーリングしないように設定した上で hostPath を使う超雑ソリューションをしてしまった…。作ったダッシュボードとかエクスポートするまでこのインスタンス落とせないじゃん…*6。本選までには PV 使うようにしたい。

Filebeat

Filebeat は競技インスタンスにインストールする。モジュールやプラグインが充実していて、数行設定を変更するだけで journald、 Nginx、 MySQL log それぞれの収集 & メトリック化を有効化できて楽だった。

一点、 Nginx でレスポンスタイムを記録するためには、 Nginx ログフォーマットの変更 + Filebeat のパース設定の変更を行う必要がある。 kataribe 互換のログフォーマット(デフォルトフォーマットの末尾にレスポンスタイムを追加する)の場合、以下のリンクの通りにすると nginx.response.time がメトリクスとして上がるようになった。

discuss.elastic.co

追記(Nginx)

予選・本選で試してみたところ各種 id が入ったパスが分散して表示されてしまう課題があったので、 Filebeat の Nginx module の設定を触って集約して表示できるようにした。

nonylene.hatenablog.jp

追記(MySQL

本選時は init 時の MySQL スロークエリが大きすぎて Filebeat がメモりを食って OOM になる問題があった。 init 関係のクエリは頑張って記録する必要はないので、 max_bytes で一行あたりの最大長を調整し、大きすぎるログはスキップするようにすることで解決した*7

メモリ使用量との格闘の記録。Nice よりも cgroup weight をちゃんと設定したほうがいいです

- module: mysql
   ...
  slowlog:
    enabled: true
    input:
      max_bytes: 100000 # Limit up to 100k bytes

スローログそのままだと本戦ではいくつか問題が出てきたので、pt-query-digest の結果を出せるようにもしてみた。詳しくは↓を参照。発展編って感じ。

nonylene.hatenablog.jp

ダッシュボード

今回 Elastic Stack を選んで最も良かった点は、ダッシュボードが簡単に構築できたこと。 Filebeat と Kibana の連携が整っており、Filebeat のセットアップコマンドで Kibana ダッシュボードを自動生成してくれる。

Filebeat によって自動生成された Kibana のダッシュボード

この機能があったおかげで、自動生成されたダッシュボードを Clone & 参考にすることができ、 Nginx, MySQLダッシュボードを高速に設定できた。

(参考) ダッシュボードを export したものは以下。

github.com

個人的には、 Grafana よりも Kibana のほうが編集画面の UI がしっかりしていてビギナーでも使いやすいと思う。オススメ。

タイムライン

最後に、準備と当日やったことの記録。

前々日

  • 全然準備やってないことに気づく :old_noto_innocent:
  • Elastic Stack のドキュメントを眺め、 Elasticsearch と Kibana の連携までやって力尽きる

前日

  • Filebeat の検証
    • nginx と journal が取れることを確認した
    • nginx のレスポンスタイムと MySQL スローログは当日の課題とした :old_noto_innocent:
  • Kibana でのログ閲覧やダッシュボードの準備
  • トレーシングやプロファイラの試行 w/ チームメイト

当日

だいたいいつも通りで、サーバーの水やり当番をしていた。

  • 30分前に起きて、20分前からライブが始まることを知る
  • カーネル起動パラメーターを変更する
    • 起動しなくなったら最悪なので最初に
  • いつものサーバーもろもろ整備
    • サーバー眺めて不要そうなプロセス止める
    • sysctl チューニングしたり、max open files 変えたり
  • netdata 入れる
    • これはいつもどおり役に立った。ベンチマーク中のリアルタイムな負荷傾向を見るにはこれが一番。
  • filebeat 入れて各種設定
  • ダッシュボード整備(付け焼き刃ポイント)
  • MySQL のメモリ調整
    • 富豪的に設定していたらメモリがなくなってホストへの疎通が失われることが数回あったので、やや控えめに
      • 分割した後は上げといてもよかった。最後余ってたから大差ないだろうけど
  • クエリキャシュ目当てに MySQL 5.7 に戻そうとしたが無理だったので諦める
    • Ubuntu 22.04 に対応したパッケージがなかった
  • MySQL 別ホスト移行、垂直分割準備
    • 今回は初めから % のユーザーがあったので bind 変更するだけだった
  • Nginx キャッシュ
    • 今回は多分関係ない
  • GOGC の調整
    • GOGC=off にしたらメモリを食いまくってホストが死んだ(当たり前すぎる)
    • 最終的に 300 に。1000点分くらいの効果はあった
  • 最後にトレーシングとか全部 purge して、再起動試験
    • Filebeat が purge してもゾンビのように生きていてこわかった(systemctl disable した)
  • 競技終了後に MySQL スローログを切り忘れたのに気づく

最終的に nginx + App 1台、MySQL 2 台の構成になった。App がカツカツだったので、もっと時間があったら or スコアに余裕がなかったら App を増やす作業をしていたと思う。

感想

今回も楽しかったです!SQLite を知ったときはウケました。 本選も楽しみ!!

毎年 id:utgwkk id:wass80 がアプリやクエリ周り見てくれるので大感謝 :pray: 今回もアプリのコードは3分くらいしか見ていないので自分だけだと絶対ムリそう。

サーバー周りは年々整備されていてやらないといけないことが減っている印象があって、サーバー触り担当としてはこういう基盤まわりで貢献していきたいところ。今後の課題としては基盤周りのブラッシュアップと、Redis や Varnish をサクッと触れるようにすることかな。セッション周りも Sticky module に頼った分散から脱却したいと思ったり…。

その他

最近ピノキオピーのデラシネをめっちゃ聞いてます。構築中ずっと流してました。今も流してます。電子音最高です。

open.spotify.com

*1:Slack に登録している :innocent: が Noto emoji だったころのグリフを使った絵文字

*2:アクセス数のグラフを出しておくと、いつベンチが回ったかすぐに分かる。そこでドラッグすると時間の設定が可能

*3:e2-standard-2

*4:kibana_system については、手作業でやるなら Enrollment Token を使えば良さそう。k8s で立ててるとコンテナが作り直されたりするので固定パスワードを設定した https://www.elastic.co/guide/en/elasticsearch/reference/master/create-enrollment-token.html

*5:実際はさらに複雑で、Filebeat から Kibana の API を叩くには Anonymous user だとできなかったので、ingress-nginx で set_proxy_header を行い Filebeat から来たリクエストの Basic auth credential を elastic ユーザーのものに上書きする処理を行っている。

*6:Kibana のダッシュボードなどのデータは Elasticsearch に保存されているので、正確には Elasticsearch の乗ったインスタンスを落とすまで。

*7:本選後に

Elasticsearch の Docker container でパスワードを指定する

環境: Docker の elasticsaerch image 8.3.2

久々に Elasticsearch を Docker で立ち上げると、初期状態でセキュリティ機能が有効になっていて、そのまま curl しても認証が通らず API が打てなかった。

$ curl -k https://localhost:9200/
{"error":{"root_cause":[{"type":"security_exception","reason":"missing authentication credentials for REST request [/]","header":{"WWW-Authenticate":["Basic realm=\"security\" charset=\"UTF-8\"","Bearer realm=\"security\"","ApiKey"]}}],"type":"security_exception","reason":"missing authentication credentials for REST request [/]","header":{"WWW-Authenticate":["Basic realm=\"security\" charset=\"UTF-8\"","Bearer realm=\"security\"","ApiKey"]}},"status":401}

環境変数で認証周りを完全に切ることもできるが、Docker image 経由で立ち上げている場合はパスワードの設定を環境変数経由で行うこともできるのでメモ。

どうするか

ELASTIC_PASSWORD で elastic user のパスワードを指定できる。これは、 Docker image の entrypoint.sh や Elasticsearch の Docker 関係のドキュメントを見ると書いてある。

github.com

www.elastic.co

実際に ELASTIC_PASSWORD を指定すると、下のように elastic user と指定したパスワードを Basic 認証で指定して curl が通ることを確認できる。

$ curl -k https://localhost:9200/ -u 'elastic:yourStrongPassword'
{
  "name" : "elasticsearch-6c77b74fd4-g9688",
  "cluster_name" : "cluster",
...

pip install --user を config でデフォルトにする

最近(2年以上前)、pip がデフォルトで --user でインストールするようになった。

github.com

ただ、この機能が有効になるにはいくつかの条件がある。

  1. The user has not explicitly specified otherwise in the command line, environment variable, or a config file.
  2. The --prefix and --target options are not in use.
  3. The global site-packages dir is not writeable, so doing a non-user install would fail.
  4. User site-packages are enabled - to avoid surprises in isolated environments.

https://github.com/pypa/pip/pull/7002#issue-491155059

ここで、Pythonbrew でインストールすると brew はユーザー権限で入るために 3 が満たせないことが多く、 brew 内のグローバルに入ってしまう状態が続いていた。

確かに権限的にはグローバルでも問題ないが、brew ディレクトリの中はクリーンであって欲しい。brew を使っていてもデフォルトで --user にしたい場合は、pip の config で設定する。

pip.pypa.io

pip の config ではコマンドのオプションのデフォルトを環境変数や config ファイル経由で設定することができる。

今回は config ファイル(~/.config/pip/pip.conf)で以下のように設定した。

[install]
user = true

こうして、環境によらず、また多少古い pip でもデフォルトで --user で入るようになった。

GKE の Preemptible VM を Spot VM にした

プライベートで GKE を Preemptible VM ベースで運用していた。だいぶ昔に Preemptible VM が Spot VM に変わるよという通知が GCP から来ていて 値段変わらないし 放置していたのだが、GKE のバージョンを上げるついでに Spot VM に変更した。

cloud.google.com

Preemptible VM は何がなんでも 24 時間以内に終了されるという AWS もびっくりの仕様だったが、 Spot VM ではそれがなくなり、 GCP のインフラ内で必要な場合にのみ終了されるようになった。

料金も変わらないのでユーザーにとってはメリットしかない変更のよう*1

GKE の Node pool を Spot VM にする

Node pool を直接 Spot VM に変更することはコンソールではできなさそうだったので、新しく Spot VM の Node pool を作って、その後 Preemptible VM の Node pool を削除した。

Spot VM の様子

5/4 に Spot VM を立ててから記事執筆時点で一週間弱立っているが、Terminate されることなく生き続けている。どれくらい生きてくれるのか、数ヶ月ぐらい様子を見て記事にできればと思う(希望)。

*1:とはいえ GKE の Node は k8s の特性上いつ壊れてもそれほど問題なく稼働し続けるので上げる理由もなく、コンソールポチポチする腰が上がらなかったのだった

TypeScript 型わくわく日記

TypeScript でオブジェクトのバリデータを作っている。それぞれの値ごとのバリデータは値の特性ごとに様々な関数がある((value: string) => Error[] のような形)。
時折 Optional な値があるものの、バリデータ自体は non-null な値のバリデーションに専念してほしくて、Optional / Required な判定は別の関数に分けたい気持ちがあった。

別の関数に分けないと、バリデータの中で一々 Optional かどうかの引数を取って判定したり、下のように各バリデータに対応して Optional な判定を加えた関数を作る必要があって面倒。

const validateOptional = (value: string | null | undefined, allowAsterisk: boolean): Error[] => {
    if (value == null) {
        return [] // ok
    }
    return validate(value, allowAsterisk)
}

これを、

  1. non-null 用のバリデーション関数を引数とし、null チェックを行う処理を差し込んだ nullable 用の新しいバリデーション関数を返す高階関数を作成する
  2. 様々なバリデーション関数の引数に対応するため、TypeScript の Variadic Tuple Types を使う

ことによってスッキリできた。

例として、non-null な値しか受け取らないバリデータに対して、値を Optional とするための高階関数を下のように作った。

const optional = <U, T extends unknown[]>(validate: (value: U, ...args: [...T]) => Error[]) => (value: (U | null | undefined), ...args: [...T]) => {
    if (value == null) {
        return []; // ok
    }
    return validate(value, ...args);
}

nullable な value を受け取って null チェックを行い、null | undefined であれば ok、non-null であれば validate 関数に再度投げている。それ自体は初めの関数と特に変わらない。

見所は Variadic Tuple Types を使っているところで、 validate 関数の1引数目以外をまとめて [...T] と受け取ることで、1つ目以外の引数(とその型情報)を維持したまま新しい関数を生成できている。
これがなかったら、バリデータ関数のオプションを全て2引数目に Object として押し込む (value: U, options: T) => Error[] のような形)ようにインターフェースを統一することになっていたと思う。

// 例
const validate = (val: string, allowAsterisk: boolean) => {
    const errors = [];
    if (val.length >= 10) {
        errors.push(new Error("value must be shorter than 10 chars"))
    }
    if (!allowAsterisk && val.includes('*')) {
        errors.push(new Error("value must not contain character *"))
    }
    return errors
}

// validate: (val: string, allowAsterisk: boolean) => Error[]
// optional(validate): (val: string | null | undefined, allowAsterisk: boolean) => Error[]

console.log(
    optional(validate)("*", true), // => [value must not contain character *]
    optional(validate)("*", false), // => []
    optional(validate)(undefined, true) // =>[]
)

TypeScript たのし~~

AlertManager: Slack の Incoming webhook URL が Kubernetes の Secret で扱いやすくなっていた

Prometheus の AlertManager は Slack などに通知を送る重要なコンポーネントである。

当然 Slack の Webhook URL といった Credential を設定ファイルに書く必要があるのだけど、AlertManager は強い意志で環境変数や複数設定ファイルに対応していないので、Kubernetes の Secret との相性がとても悪かった。

この状況下で Secret を使うには、Secret にまるまる設定ファイルを突っ込むか、envsubst を initContainer に指定して設定ファイルを動的に生成する必要があった。

Secret は sealed-secret で暗号化した状態でレポジトリに置くようにしているので、AlertManager の設定を変えるたびに暗号化をしなければならないのは面倒すぎる。とはいえ、 envsubst をするのは configmap-reload などと相性が悪い。

AlertManager 0.22

平文でレポジトリに置くしか無いのか・・・? と思っていた矢先、去年にリリースされた AlertManager 0.22 で slack_api_url_file というオプションが生えて、ファイルから Slack Incoming webhook URL を指定できるようになっていることに気づいた。

[ENHANCEMENT] Add support to set the Slack URL from a file. #2534

https://github.com/prometheus/alertmanager/releases/tag/v0.22.0

最高のアップデートじゃん!ということで、Secret に Slack の Webhook URL だけを置くような構成にすることができた。これで柔軟に Config を変更することができる。

Prometheus の Helm Chart だとこんな感じ。

alertmanager:
  extraSecretMounts:
    - name: alertmanager-secrets
      mountPath: /alertmanager-secrets
      secretName: prometheus-alertmanager-secrets
      readOnly: true
alertmanagerFiles:
  alertmanager.yml:
    global:
      slack_api_url_file: '/alertmanager-secrets/slack-api-url'
    receivers:
    - ...