Unyablog.

のにれんのブログ

検索システムのフロントを 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 せずに勝ちたい!

サポート外のリソースを含んだ Helm release を無理やり削除した

久々に helmfile sync を行った結果、PodSecurityPolicy リソースなんか知らないという理由で Upgrade に失敗した。

$ helmfile dep && helmfile sync
...
  Error: UPGRADE FAILED: resource mapping not found for name: "descheduler" namespace: "" from "": no matches for kind "PodSecurityPolicy" in version "policy/v1beta1"
  ensure CRDs are installed first

PSP? なにそれ CRD か何か?」と言われる有様。そういえば、k8s クラスタを最近アップグレードしたときに不要な PSP を消していた気がする。

一旦手動で helm uninstall をしようとするも、同じようなエラーが出て uninstall できない。

$ helm uninstall descheduler -n kube-system --debug
uninstall.go:97: [debug] uninstall: Deleting descheduler
uninstall.go:119: [debug] uninstall: Failed to delete release: [unable to build kubernetes objects for delete: resource mapping not found for name: "descheduler" namespace: "" from "": no matches for kind "PodSecurityPolicy" in version "policy/v1beta1"
ensure CRDs are installed first]
Error: failed to delete release: descheduler
helm.go:84: [debug] failed to delete release: descheduler
helm.sh/helm/v3/pkg/action.(*Uninstall).Run
        helm.sh/helm/v3/pkg/action/uninstall.go:120
main.newUninstallCmd.func2
        helm.sh/helm/v3/cmd/helm/uninstall.go:60
github.com/spf13/cobra.(*Command).execute
        github.com/spf13/cobra@v1.6.1/command.go:916
github.com/spf13/cobra.(*Command).ExecuteC
        github.com/spf13/cobra@v1.6.1/command.go:1044
github.com/spf13/cobra.(*Command).Execute
        github.com/spf13/cobra@v1.6.1/command.go:968
main.main
        helm.sh/helm/v3/cmd/helm/helm.go:83
runtime.main
        runtime/proc.go:250
runtime.goexit
        runtime/asm_arm64.s:1172

どうやら、以前この Chart を install したときには PSP があったので、今回 uninstall するにあったっても PSP を消そうとするが、API の対応がないので落ちているらしい。

ここで、helm はインストールしたときの情報を secrets に保存している。この内容をデコードすると、インストール時の YAML がそのまま格納されている。

$ kubectl describe secret -n kube-system sh.helm.release.v1.descheduler.v15
Name:         sh.helm.release.v1.descheduler.v15
Namespace:    kube-system
Labels:       modifiedAt=1687875686
              name=descheduler
              owner=helm
              status=uninstalling
              version=15
Annotations:  <none>

Type:  helm.sh/release.v1

Data
====
release:  13804 bytes

$ kubectl get secret -n kube-system sh.helm.release.v1.descheduler.v15 -o json | jq .data.release -r | base64 -d | base64 -d | gunzip | jq
{
  "name": "descheduler",
  "info": {
    "first_deployed": "2021-05-25T22:28:45.6228274+09:00",
    ...
  },
  ...
  "manifest": "---\n# Source: descheduler/templates/podsecuritypolicy.yaml\napiVersion: policy/v1beta1\nkind: PodSecurityPolicy\nmetadata:\n  name: descheduler\n  annotations:\n    seccomp.security.alpha.kubernetes.io/allowedProfileNames: 'docker/default,runtime/default'\n    seccomp.security.alpha.kubernetes.io/defaultProfileName:  'runtime/de...(続く)"
...
}

この manifest を使って uninstall しているのでは?と考えて、manifest から PodSecurityPolicy の部分を削って secrets の値を変更してみたところ、ビンゴだった。

$ gzip new_release.json --stdout | base64 -w 0 | base64 -w 0 | pbcopy
$ kubectl edit secret -n kube-system sh.helm.release.v1.descheduler.v15
(コピーした内容を release に貼り付ける)

これで無事 PSP を除いた uninstall を行うことができた。

$ helm uninstall descheduler -n kube-system --debug
uninstall.go:97: [debug] uninstall: Deleting descheduler
client.go:478: [debug] Starting delete for "descheduler" CronJob
client.go:478: [debug] Starting delete for "descheduler" ClusterRoleBinding
client.go:478: [debug] Starting delete for "descheduler" ClusterRole
client.go:478: [debug] Starting delete for "descheduler" ConfigMap
client.go:478: [debug] Starting delete for "descheduler" ServiceAccount
uninstall.go:150: [debug] purge requested for descheduler
release "descheduler" uninstalled

全く同じ内容がドキュメントにあることにその後気づいた。事故らなくてよかったですね…。

helm.sh

今回の事象は Upgrade 時に何も考えずに非互換なリソースを消して済ましたのが原因であり、教訓としては以下になる。

  • 非互換なリソースの変更を伴う Cluster upgrade は、そのリソースを触る前に Helm 配下にないか確認する
    • 配下にあった場合は Helm を使ってそのリソースをなんとかするべき

curl の trace / trace-ascii が便利だった

curl で通信の内容を見ながら詳細なデバッグをしたいとき、 tcpdumpWireshark でパケットキャプチャを行うのが定番だと思う。ただ、通信が多い環境だと実際にデバッグしたい通信を絞り込むのが面倒だし、TLS だと暗号化されていてさらに面倒。

curl のオプションを見ると通信内容のトレースを記録できる --trace--trace-ascii があり、使ってみると便利だった。

everything.curl.dev

$ curl https://www.google.com/ [--trace-time] --trace[-ascii] trace-result.txt

これで curl が行った通信の内容が trace-result.txt に記録される。- にすると stdout に出力される。

また、--trace-time をつけると trace に時刻が付与され、--trace--trace-ascii にすると Hex は保存されず ascii の内容のみ確認することができる。

trace の内容は以下のような感じ(一部省略している)。

== Info:   Trying 2404:6800:4004:80b::2004:443...
== Info: Connected to www.google.com (2404:6800:4004:80b::2004) port 443 (#0)
== Info: ALPN, offering h2
== Info: ALPN, offering http/1.1
== Info: successfully set certificate verify locations:
== Info:  CAfile: /etc/ssl/certs/ca-certificates.crt
== Info:  CApath: /etc/ssl/certs
=> Send SSL data, 5 bytes (0x5)
0000: 16 03 01 02 00                                  .....
== Info: TLSv1.3 (OUT), TLS handshake, Client hello (1):
=> Send SSL data, 512 bytes (0x200)
0000: 01 00 01 fc 03 03 ca 7f 00 66 6c 46 6f a9 2c 8f .......^?.flFo.,.
0010: 97 83 1a b8 c7 80 50 6d 0c c8 f8 73 bc 49 90 ab ......Pm...s.I..
0020: ec 8b bc be 4f 0d 20 a4 f1 17 e2 8c 0d 6f 61 25 ....O. ......oa%
0030: 54 d8 13 27 b6 5d e2 0d 92 de 42 9f 93 2c ce c5 T..'.]....B..,..
0040: 50 bd 3c 75 b9 bd 86 00 3e 13 02 13 03 13 01 c0 P.<u....>.......
0050: 2c c0 30 00 9f cc a9 cc a8 cc aa c0 2b c0 2f 00 ,.0.........+./.
0060: 9e c0 24 c0 28 00 6b c0 23 c0 27 00 67 c0 0a c0 ..$.(.k.#.'.g...
0070: 14 00 39 c0 09 c0 13 00 33 00 9d 00 9c 00 3d 00 ..9.....3.....=.
0080: 3c 00 35 00 2f 00 ff 01 00 01 75 00 00 00 13 00 <.5./.....u.....
0090: 11 00 00 0e 77 77 77 2e 67 6f 6f 67 6c 65 2e 63 ....www.google.c
00a0: 6f 6d 00 0b 00 04 03 00 01 02 00 0a 00 0c 00 0a om..............
...
<= Recv SSL data, 5 bytes (0x5)
0000: 16 03 03 00 7a                                  ....z
== Info: TLSv1.3 (IN), TLS handshake, Server hello (2):
<= Recv SSL data, 122 bytes (0x7a)
0000: 02 00 00 76 03 03 1d 3c 9b 52 99 6f e5 05 b7 9f ...v...<.R.o....
0010: 2b 35 94 0e 11 bd 4f 3a de e3 b8 1d d4 16 a4 45 +5....O:.......E
0020: c4 91 13 b0 18 ef 20 a4 f1 17 e2 8c 0d 6f 61 25 ...... ......oa%
...

TLS で暗号化された内容も、元の HTTP が平文で見れるので便利。

<= Recv header, 13 bytes (0xd)
0000: 48 54 54 50 2f 32 20 32 30 30 20 0d 0a          HTTP/2 200 ..
<= Recv header, 37 bytes (0x25)
0000: 64 61 74 65 3a 20 54 68 75 2c 20 31 35 20 4a 75 date: Thu, 15 Ju
0010: 6e 20 32 30 32 33 20 30 32 3a 33 37 3a 34 33 20 n 2023 02:37:43
0020: 47 4d 54 0d 0a                                  GMT..
<= Recv header, 13 bytes (0xd)
0000: 65 78 70 69 72 65 73 3a 20 2d 31 0d 0a          expires: -1..
<= Recv header, 35 bytes (0x23)
0000: 63 61 63 68 65 2d 63 6f 6e 74 72 6f 6c 3a 20 70 cache-control: p
0010: 72 69 76 61 74 65 2c 20 6d 61 78 2d 61 67 65 3d rivate, max-age=
0020: 30 0d 0a                                        0..
<= Recv header, 45 bytes (0x2d)
0000: 63 6f 6e 74 65 6e 74 2d 74 79 70 65 3a 20 74 65 content-type: te
0010: 78 74 2f 68 74 6d 6c 3b 20 63 68 61 72 73 65 74 xt/html; charset
0020: 3d 49 53 4f 2d 38 38 35 39 2d 31 0d 0a          =ISO-8859-1..
<= Recv header, 245 bytes (0xf5)
0000: 63 6f 6e 74 65 6e 74 2d 73 65 63 75 72 69 74 79 content-security
0010: 2d 70 6f 6c 69 63 79 2d 72 65 70 6f 72 74 2d 6f -policy-report-o
...

eBPF の tc を使ってパケットを触ってみる開発入門資料

eBPF の tc (classifier) を使ってパケットを触ってみる話をしたので、資料を貼ります。

docs.google.com

eBPF は一度慣れるとかなり便利な仕組みで、見るべき場所が分かっていればそれなりに気軽に使うことができる。ただ、ドキュメントがだいぶ整備されてきたとはいえまだまだ最初の一歩の敷居は高いと思う。

この資料は、eBPF 開発の最初の一歩を踏み出したい人向けに作った。 eBPF を書いてデバッグし組み込むには、一体何を行う必要があってどこを見たらいいか?をハマりポイントとともに重点的に書いてみた。

Renovate で Flux 配下の Helm Chart をアップデートする

この記事は KMC アドベントカレンダー 14 日目の記事です。

adventar.org

前回の記事は id:crashrt さんでした。自分も百舌谷さん好きです!

crashrt.hatenablog.com

背景

現在、Kubernetes クラスタに入れる Manifest を Flux (Fluxcd) を使って管理している。

blog.kmc.gr.jp

Flux には Helm を管理できる機能があって、HelmRelease という Flux の CRD で Chart のバージョンや values を指定したらインストールしてくれる。

fluxcd.io

クラスタを運用するにあたっては積極的に Chart のバージョンを上げていきたいものの、いちいち Upstream repository を確認しにいくのはめんどくさい。

調べてみると依存パッケージを自動更新してくれることで有名な Renovate が Helm の更新に対応しているということで、導入してみた*1

Renovate と Flux

Renovate は Flux にしっかり対応していて、HelmRelease ファイルがあれば自動的にバージョン更新の面倒を見てくれる。

docs.renovatebot.com

ただ、更新を確認するためには HelmRelease とそのオブジェクトが参照している HelmRepository (Helm chart に対応するレポジトリの情報) の紐付けを正しく行う必要があるそうで、

  • HelmRelease / HelmRepository ともに namespace が明示的に設定されている必要がある
  • HelmRelease / HelmRepository ともに名前が一意じゃないと多分動かない

という制約がある。

ここで、現在クラスタでは Manifest の構成に Kustomize をいろいろ活用しており、

  • namepsace は上位の Kustomization で上書きするようにしている
  • HelmReelase / HelmRepository の共通パラメータは Component にまとめており、各アプリの Kustomzation でそれを上書きして namePrefix でリソース名を変更している

ため、 各 Kustomization 内の HelmRelease / HelmRepository には namespace の記述はなく、また(`namePrefix` が適用される前なので)リソース名も全て同じになっている。

そのため、 Renovate の Flux 機能をそのまま使うことはできなかった。

自前でパースする

Renovate は "Managers" でパースを行って、 パッケージ情報を "Datasource" として抽出し、その Datasource に対して更新のチェックを行う仕組みになっている。

これまでに書いていた話は Flux Manager がパースを行って Helm Datasource を HelmRelease から抽出するものだった。もし Flux Manager がうまく使えなかったとしても、他の Manager を使って Helm Datasource が抽出できれば Renovate はそれに対して更新チェックを行ってくれる。

そこで Regex Manager を使って正規表現で自前でパースするようにした。

具体的には、以下のような HelmRelease / HelmRepository が書かれたファイルに対して

---
apiVersion: source.toolkit.fluxcd.io/v1beta1
kind: HelmRepository
metadata:
  name: repo
spec:
  url: https://helm.cilium.io/
---
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
  name: release
spec:
  chart:
    spec:
      chart: cilium
      version: 1.12.3
  values:
    ipam:
      mode: kubernetes

以下のような正規表現を設定して Helm Datasource を抽出する*2

{
  ...
  "regexManagers": [
    {
      "fileMatch": ["^k8s/apps/.*/helm-overlay.yaml$"],
      "matchStringsStrategy": "combination",
      "matchStrings": [
        "  url: \"?(?<registryUrl>\\S+)\"?\\s*",
        "      chart: \"?(?<depName>[a-z0-9-]+)\"?\\s*",
        "      version: \"?(?<currentValue>\\S+)\"?\\s*"
      ],
      "datasourceTemplate": "helm"
    }
  ],
  ...
}

設定して数分待つと、ちゃんと Renovate が Helm を認識してくれてアップデートの PR を作ってくれた。

その他

これで Chart の更新が楽になったが、他にも Renovate を運用する上で細々としたポイントがあった。

StabilityDays

新しいバージョンが出てから一週間くらいは様子見したいので、StabilityDays を設定した。

確かに最新のバージョンが Pending になって Push されないようになった一方、1週間経過した(がすでに新しいバージョンが Publish されている)古いバージョンが Stable なものとして Push されるようになった。自分はリリースされてから1週間たっても新しいバージョンがない場合に Stable とみなしたいのであり、古いバージョンが Push されるのは意図した挙動ではない*3

軽減策として "internalChecksFilter": "none" を設定して StabilityDays に関わらず常に新しいバージョンを Push させることで古いバージョンが Suggest されることはなくなった。

StabilityDays を有効にすると GitHub PR に StabilityCheck という Status Check が追加されるようになる。最新バージョンが StabilityDays 分経過するまでこの Status が通らないので、 (Automerge を特に設定しない場合は)定期的に PR を見てステータスが緑色であれば確認してマージ、というフローを行うようになった。

StabilityDays が経過する前に新しいバージョンが出た場合は、その新しいバージョンが Push されるのでまたしばらく StabilityCheck が通らないことになる。Grafana のような高頻度で新バージョンがリリースされる Chart の場合、一生マージできなくなるという課題もある*4

Automerge

Bitnami の Chart などはアプリケーションのバージョンとは関係なく Base image が更新されるだけで新しい Chart がリリースされることがある。そのような些細な変更をいちいち確認するのはめんどくさいので、patch バージョンの変更であれば自動的にマージ(Automerge)するように設定した。

{
...
  "packageRules": [
    {
      "matchUpdateTypes": ["patch"],
      "matchDatasources": ["helm"],
      "matchCurrentVersion": "!/^0/",
      "automerge": true
    }
  ],
...
}

今のところ Renovate のマージを使っているので時間がかかるが、基本的にはうまくマージされている。 遅い問題で困ったら GitHub の Automerge を検討してもいいかもしれない。

うるさい

無限にアップデートがきてダルいという Renovate あるあるな問題もある。 Schedule を設定したら静かになると思いきや、既存の PR には Schedule に関わらず更新を送り続けるので Push 通知がうるさかった。

これに関しては、 "updateNotScheduled": false を設定することで既存ブランチの更新も Schedule 内におさめるようにすることができた。

ただ、そうすると

月曜に 1.2.1 が出る → 土曜日に更新される → (月曜に 1.2.2 が出るがブランチは更新されない) → 火曜に 8日間たって stabilityDays を通過する → 意図せず 1.2.1 がマージされる

といったロジックで古いバージョンがマージされてしまう可能性がある。その対策として、automergeScheduleschedule の直後に設定するようにして Automerge 発動を PR の更新直後に限定することにした。

Recap

最終的な Config は↓のようになった。将来的には minor も自動マージの対象とするかも。

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:base", ":disableDependencyDashboard"
  ],
  "timezone": "Asia/Tokyo",
  // バージョンが出てから8日間は Unstable とみなして Status check を pending にさせる
  "stabilityDays": 8,
  // Fluxcd は色々 flag をつけているので手動でアップデートしたい
  "flux": {
    "enabled": false
  },
  "regexManagers": [
    {
      "fileMatch": ["^k8s/apps/.*/helm-overlay.yaml$"],
      "matchStringsStrategy": "combination",
      "matchStrings": [
        "  url: \"?(?<registryUrl>\\S+)\"?\\s*",
        "      chart: \"?(?<depName>[a-z0-9-]+)\"?\\s*",
        "      version: \"?(?<currentValue>\\S+)\"?\\s*"
      ],
      "datasourceTemplate": "helm"
    }
  ],
  "packageRules": [
    {
      "matchUpdateTypes": ["patch"],
      "matchDatasources": ["helm"],
      "matchCurrentVersion": "!/^0/",
      "automerge": true
    }
  ],
  // StabilityDays に関わらず、最新の package 情報を PR にする。 Automerge で古いバージョンが StabilityDays を満たしてマージされるのを防ぐため
  "internalChecksFilter": "none",
  "schedule": ["before 8am on saturday"],
  "automergeSchedule": ["after 7am and before 11am on saturday"],
  // Rebase やバージョン更新も Schedule 内に行わせる
  "updateNotScheduled": false,
  "prHourlyLimit": 0,
}

Renovate と Helm の組み合わせ、なかなか便利なので使ってみてください!

*1:Dependabot は非対応だった https://github.com/dependabot/dependabot-core/issues/1744

*2:これができたのは HelmRepository と HelmRelease を同じファイルに記述するようにしていたため。別のファイルに書いて場合はどうすればいいのか知らない

*3:これはドキュメントに記載された挙動なので仕方ない

*4:実際になっている

dex と oauth2-proxy と ingress-nginx で OIDC ID token を取得して kubernetes-dashboad にログインする

Kubernetes では kubernetes-dashboard という各種リソースを見るためのダッシュボードが提供されている。

ブラウザ上で色々確認できるので便利なのだが、ログイン時は下のように自身の token か kubeconfig ファイルをアップロードする必要がある。

Kubernetes へのログインで Certificate や Token を使っている場合はそのままアップロードすればいいものの、 OIDC を設定したクラスタで OIDC 経由でログインしている場合は ID token を取得する必要があり、めんどくさい。

そこで、

  • oauth2-proxy で kubernetes 向けの OIDC ID token を dex から取得
  • ingress-nginx で ID token を Authorization header に Bearer token として付与し、 kubernetes-dashboard に送信

することでシームレスに見れるようにする。

dex

kubernetes が使っている client と同じ client id を使うので dex で新しく client を発行する必要はないが、 Callback URL を新たに登録する必要がある。

staticClients:
- id: kubernetes-client
  redirectURIs:
  - ...
  - 'https://{k8s dashboard domain}/oauth2/callback'
  ...

oauth2-proxy

oauth2-proxy は dex から id token を取得し、 ingress-nginx に Authorization Header として渡す役割を果たす。以下のような環境変数を設定すると良い。

      - name: OAUTH2_PROXY_PROVIDER
        value: oidc
      - name: OAUTH2_PROXY_OIDC_ISSUER_URL
        value: {OIDC issuer url}
      - name: OAUTH2_PROXY_COOKIE_DOMAINS
        value: {k8s dashboard domain}
      - name: OAUTH2_PROXY_WHITELIST_DOMAINS
        value:  {k8s dashboard domain}
      # Re-validate OAuth token per 1 hour
      - name: OAUTH2_PROXY_COOKIE_REFRESH
        value: 9m # id token の期限より短くする
      - name: OAUTH2_PROXY_SCOPE
        value: "openid profile groups offline_access email" # offline_access は refresh token の取得に使う。 email は oauth2-proxy の動作に必須
      - name: OAUTH2_PROXY_SET_AUTHORIZATION_HEADER
        value: "true"

ポイントは OAUTH2_PROXY_SET_AUTHORIZATION_HEADER で、これを true にすることで id token を Authorization Header として ingress-nginx に渡すことができる。

ingress-nginx

ingress-nginx は oauth2-proxy にリダイレクト(& reverse proxy)して Authorization Header を取得し、それを付けた上で kubernetes-dashboard に reverse proxy する。

構成としては、oauth2-proxy に reverse proxy するための認証がない Ingress と、kubernetes-dashboard に reverse proxy するための auth_request 付き Ingress を用意することになる。

oauth2-proxy 用 Ingress

oauth2-proxy の認証まわりの endpoint である /oauth2 配下を oauth2-proxy の service にわたす。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress
  annotations:
    kubernetes.io/ingress.class: "nginx"
    nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
  rules:
  - host: {k8s dashboard host}
    http:
      paths:
      - path: /oauth2/
        pathType: Prefix
        backend:
          service:
            name: kubernetes-dashboard-oauth2-proxy
            port:
              number: 80

kubernetes-dashboardIngress

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress
  annotations:
    kubernetes.io/ingress.class: "nginx"
    nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
    nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
    nginx.ingress.kubernetes.io/auth-response-headers: "Authorization"
    nginx.ingress.kubernetes.io/auth-url: "https://{k8s dashboard host}/oauth2/auth"
    nginx.ingress.kubernetes.io/auth-signin: "https://{k8s dashboard host}/oauth2/start?rd=$scheme://$host$escaped_request_uri"
spec:
  rules:
  - host: kubernetes-dashboard.kmc.gr.jp
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: kubernetes-dashboard
            port:
              number: 443

ポイントは nginx.ingress.kubernetes.io/auth-response-headers: "Authorization" を設定することで、これで oauth2-proxy から来た Authorization Header を Request Header に設定して kubernetes-dashboard にリクエストを送ってくれる。

これで、 kuberentes-dashboard へのリクエストに OIDC で認証された kubernetes において valid な Authorization Header が乗るようになり、ユーザーの権限で Kubenetes dashboard が閲覧・編集できるようになった*1

*1:最初の Token を求める画面は出なくなる