Unyablog.

のにれんのブログ

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 を使う超雑ソリューションをしてしまった…。作ったダッシュボードとかエクスポートするまでこのインスタンス落とせないじゃん…。本選までには PV 使うようにしたい。

Filebeat

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

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

discuss.elastic.co

ダッシュボード

今回 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 を叩くにはそのままだとできなかったので、ingress-nginx で set_proxy_header を行い Filebeat から来たリクエストの Basic auth credential を elastic ユーザーのものに上書きする処理を行っている。 …書いてて気づいたけど Basic 認証の Authorization Header を ingress-nginx で剥がすだけでいけたのでは?

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:
    - ...

Git でブランチがリモートリポジトリの HEAD から派生しているかどうかを確認する

Git でトピックブランチを切るときに、元ブランチで git pull し忘れて古いコミットから生やしてしまうときがある。

最新の変更を認識できなかったり、後々コンフリクトが起きたりと不便なことがままあるので、 Commit 前に HEAD がリモートリポジトリの HEAD から派生しているかを確認するスクリプトを書いてみた。

Git には Commit 前に pre-commit hook を実行することができる。そこで non-zero code で exit すれば Commit 処理が中止されるので、これで防ぐようにする。

コード

これを .git/hooks/pre-commit 内に置く。

git fetch origin
if ! git merge-base --is-ancestor origin/HEAD HEAD; then
  echo "ERROR: HEAD is not based on upstream HEAD"
  exit 1
fi

解説

まず git fetch origin でリモートリポジトリを fetch する。

その後、「ブランチが origin の HEAD から派生しているかどうかを確認する」ために git merge-base を使う。今回は HEAD の祖先に origin/HEAD がいるかどうかを確認しており、祖先にいなければ派生元のコミットが古いということで exit 1 し、祖先にいれば Commit を続行するようにしている。

git merge-base --is-ancestor が便利、というお話でした。