Unyablog.

のにれんのブログ

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 せずに勝ちたい!

サポート外のリソースを含んだ 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 を使ってそのリソースをなんとかするべき