💡

大規模サービスのBFFサーバーをKubernetesに移行した記事で書いていないこと

2022/06/14に公開

【追記情報】

  • 2022/06/15 09:00 誤字修正 + cdkk8sについて言及

先日、ハンドブックを公開しました。

https://dwango.github.io/nicolive-kubernetes-migration-handbook-2022/

ここではKubernetes上で稼働させた実績値としての記録が紹介してあります。が、逆に紹介していないものもたくさんあります。検証が済んでいないもの、時間的制約から導入できなかったものなど、不完全な情報を紹介しようと思います。

したがって、何も保証するための裏付けはないので「そういうことも検討してたんだなぁ」ぐらいで読んでもらえると嬉しいです。元記事もぜひ読んでみてください。

nodejsのDocker Imageの軽量化

Docker Imageを作る際、多くの場合Image内にnode_modulesを含んだ状態でイメージを作成しています。Nodejsのベースイメージが60〜100MBくらいあるのに対して、ビルド後のイメージサイズは200MB〜1GBくらいまで膨れることがあります。
これは非常に無駄の多いDockerイメージで、nextjsではexperimental機能でStandaloneモードが提供され始めています。

https://nextjs.org/docs/advanced-features/output-file-tracing#automatically-copying-traced-files-experimental

nextjsを使っていない場合だと、自前のwebpack設定を使って圧縮するなどすると、10MB程度まで圧縮することができます。

KubernetesではDockerイメージの容量はいろいろなところに影響を及ぼします。
運用時に一番わかり易いのは、デプロイ時間と起動時間です。デプロイ時はDockerイメージをRegistryからDownloadするため、容量が大きいと単純にPullする時間がかかります。次に起動時間はnodejsが実行してメモリにnode_modulesを読み込むまでの時間が変わってきます。1〜10くらいのreplicasであればそこまで気になりませんが、50〜100といった単位でりりーする際は5〜10分は変わってきます。
また、Docker イメージのサイズの大きいと、Pull時にKubernetesのノード(node.jsではない)の帯域を喰います。つまり、更新時に下りの帯域をある程度専有するためそれによる障害を発生する可能性があります。ほとんどのケースでは気にするレベルではありませんが、モニタリングしていると明らかにパルスとなって見えるため一抹の不安があります。

webpackでStandalone化する方法はたいてい成功しますが、多少テクニックが必要なケースもあり、一概に大丈夫とは言えませんが、nextjsが発表する1年前から大きなプロジェクトでこっそりやってもはや2年以上経ってい何も起きていないので多分大丈夫でしょう(適当)。

IngressGatewayではnginxを捨ててenvoyに統一したかった

元記事ではそこそこいびつな構造のアクセスログ収集を実施しています。

https://dwango.github.io/nicolive-kubernetes-migration-handbook-2022/docs/service-mesh/access-log/

見る人が見たら、envoyでaccess logを収集すればいいじゃないか、という話になると思います。その通りです。

https://www.envoyproxy.io/docs/envoy/latest/configuration/observability/access_log/usage

元記事でも色々と理由は書いていますが、envoyではnginxで実現している機能を完全に移植するための時間が足りないと判断したため使っていません。で、何が足りなかったのかを紹介します。

Envoyでもアクセスログ自体は非常に単純に出力することができます。しかしながら、一部のHeader情報を加工してアクセスログとして出力している箇所があり、これをなんとかしようとするとよくわからない挙動がたくさん発生し、全く安定しませんでした。

1つ目はluaを利用してログ情報を加工していました。

https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/lua_filter

ただ、luaは書きなれていないので試験してみるとかなりバグだらけな実装になりました。
TypeScript to Luaで書いてみるものの、これもまぁうまくいくことはなかったです。便利そうではあるんですが。

https://typescripttolua.github.io/

External Authorizationも試しました。

https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ext_authz/v3/ext_authz.proto

ここでHeader情報を編集できるので、goで書いて試してみたんですが、負債になりそうなので辞めました。あと責務も違うし。

External Processingも試しました。

https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_proc_filter

https://github.com/Himenon/envoy-ext-proc-sample

今は追えてないですが、当時は完全な状態ではなかったため、いくつの実装バグを踏んで挙動が安定しなかったため、諦めました。普通にproxyのコード書いたほうが速いかも、と思ったぐらいあります。

あと、envoyでの加工を諦めて、fluent-bitにやらせれば良いじゃないか、という話になります。
これもパットできるのは結局の所luaになるので、前述でenvoyのluaで諦めた理由と同じで断念しています。

https://docs.fluentbit.io/manual/pipeline/filters/lua

今思えば、envoyとfluent-bitの間にParseするためのコンテナを突っ込んで、unix socket経由で渡していけばスマートだったんだろうなとも思ってます。

EnvoyのLocal RateLimitを使いたかった

https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/local_rate_limit_filter

元記事ではここの章。

https://dwango.github.io/nicolive-kubernetes-migration-handbook-2022/docs/rate-limit/local-ratelimit/

EnvoyのLocalRateLimitが不採用になっている理由は元記事と同じです。
これは本当にServer Side Renderingも兼ねているBFFサーバーと相性が悪い。
バーストリクエストが発生するような場合、Token Bucket形式のRate Limitを使用した場合、Tokenの最大値までリクエストがPodに到達します。nodejsのSSRは非常にCPUの使用量を要するため、rpsとしては場合によっては一桁台でなければ安定しないケースもあります。SSRの規模にもよりますが、50rpsとか出ていれば相当優秀です。こういった状況下でBurstリクエストを即時Upstream側のPodに流すのは障害を誘発する可能性が高いので採用できませんでした。

じゃあどうすればよいか、という話ですが、そもそもSSRが本当にそこまで必要なのか見直したほうが良いです。ブラウザの画面に写っていない部分までSSRしているのは本当に必要なのか?とか、最初に見せたいもの以外本当に必要なのか?みたいなことをちゃんと考えて、CSRでも良いじゃないか、という判断を下したほうが良いでしょう。さもなくば、インフラのリソースを無限に食うだけなので、それだったらSSRとか辞めてMPA(Multi Page App)構成で作ったほうがインフラ的なコスパ良いんじゃないのという話になると思います。なので、Kubernetesに乗っける以前からあった問題はKubernetesに乗せた結果と顕になった形です。

Manifest管理でKustomize/Helm/Jsonnet/cdkk8sを利用しなかった理由

https://dwango.github.io/nicolive-kubernetes-migration-handbook-2022/docs/manifest/manifest-management/

してもよかった。けど、しなくてもよかった。です。今回TypeScriptを使用して記述していますが、チームのスキルセットや、レベル感によってはまた別のツールセットが選択肢となるはずです。

ただ1つ言えることは、「YAMLを書き続けるのはしんどい」ということは共通して言えるかと思います。

Kubernetesに携わっている開発者ならおそらく誰しも同じことを一度は感じているはずで、以下のようなブログでもYAMLを書かないための次のステップアップが紹介されています。

https://matduggan.com/tips-for-making-kubernetes-yaml-less-annoying/

TypeScript以外で記述するならほしい機能としては

  1. Manifestの差分がPull Requestでわかること
  2. リソースの変化がPull Requestでわかること
  3. Manifestを記述する際に完全な補完機能が利用できること
  4. コメントが書けること
  5. CIで単体テストができること

といった感じだと思います。これらが満たされていれば、Pull Request単位でいろいろな議論がしやくなります。

TypeScriptで書いた場合のサンプルはコレ。

https://github.com/Himenon/kubernetes-template

【追記】

cdkk8sも検討しました。

https://cdk8s.io/

実はcdkk8sと大本は同じSchemaを向いているので、今回採用した方法と俯瞰的な構成は同じなため、Interface互換になっています。したがって、どっちでも良かったです。単純に慣れている方を選択しています。

Blue/Greenデプロイはキャパシティプランニングが難しい

Blue/Greenデプロイはとてもわかり易いリリース方法ですが、切り替えの前後で倍のリソースを要求します。Server Side Renderingしているようなサーバーは前述したように、パフォーマンスは良いとはいえません。そのため負荷分散するためにreplicasを多めに設定するなど必要ですが、全体としてみたときにCPU/MEMの要求量が多く、クラスター全体として見たときに使用率の大半を埋め尽くす可能性があります。それをBlue/Greenデプロイしようものなら更新時はもうそのPodのためにクラスターがある、と言っても過言ではないくらい喰います。もちろん予算が許すならばノードを増やせばよいですが、そんなうまい話はなかなかないです。

あとは、Nodejsのサーバーをスタンドアロンな状態にしていない場合、起動が遅いなどあります。コレはつまり、デプロイ時にリソースの専有時間がとても長くなり、他のデプロイの妨げになることを意味しています。だいぶ迷惑なので、やりたくない。

ということで、ローリングアップデートかCanaryデプロイという選択肢が消去法的に残りました。

Manifestを各マイクロサービスのリポジトリに配置せず、1つのリポジトリに集約した理由

Argo CDのBest Practiceですでに説明されています。

https://argo-cd.readthedocs.io/en/stable/user-guide/best_practices/

改めて紹介すると、各マイクロサービスのリポジトリはそれぞれのライフサイクルを持っているため、インフラの実装が入っているとライフサイクルの不整合が発生するため良いことはほとんどありません。
そのため外側に切り出すのはベスプラまでいわずとも、アンチパターンではないと判断できます。

そして、今回は各マイクロサービスManifestをモノレポで管理するようにしました。
ディレクトリ設計で各マイクロサービスのマニフェストを分割統治する設計にしています。

https://dwango.github.io/nicolive-kubernetes-migration-handbook-2022/docs/manifest/kubernetes-manifest-generator-architecture/

良い点みたいな話は元記事に書いています。手っ取り早い話、モノレポのほうが変更の速度が速いので少人数でもこの程度の規模なら簡単に保守できます。デプロイまで変更からリリースまで数分のオーダーで実施可能で、これは変更の内容の規模に関わらず同じです。どういうことかというと、「あるマイクロサービスに対してLocal Ratelimit用のnginxをSidecarとして追加したい。」みたいな要求を数分で実現できるという話です。検証の済んだ構成パターンがストックされているのでフラグを有効化するだけで必要なmanifestを生成し、リリースするだけなので、いざ必要というときはそのスピードでリリースできるとう話です。無論、検証が終わっていない構成パターンは検証が必要なためそのリリーススピードは出ませんが、「他のマイクロサービスでやっていることを、別のマイクロサービスでも同じようにやることはとても簡単にできる」という大義名分で検証の時間を割くことは工数を割くための交渉材料として非常に有用でしょう。

GitOpsをSlack Botにやらせた理由

https://dwango.github.io/nicolive-kubernetes-migration-handbook-2022/docs/ci/slack-bot/

マニフェストの管理はGitHub/GitLabなどで実施するかと思います。それらの作業を各マイクロサービスの担当者がいちいちいpullしてcommitしてpushしてPull Requestを出してやるのはとても面d臭い話です。ましてやリリースノートなんて書きたくないですよね。

これらの工程をSlack経由で実施することで、SlackのBot Serverに全部実行させています。
commitメッセージはSemantic Versionで実施するのは当然として、その後のバージョン計算、gitのtag、releaseの作成まで一貫して自動化しています。
また、git commitするために以下のようなライブラリを使ってcommitを作成することで、Bot Server内でもgit cloneせずにcommitを作成しています。

https://github.com/Himenon/github-api-create-commit

マージの処理もBotに実行させ、すべてSquash Mergeさせることによってコミット履歴を理路整然とした状態を構築しています。そのため、リリースバージョンの差分を取り事により、リリースノートの出力がConventional Commitsに従ったPull Requestとして列挙され、問題があった場合でもコミット履歴が局所的な変更を表現しているためトラブル・シューティングの時間を短くしてくれます。

終わりに

だいぶ書き散らしてしまいました。多分まだあるのですが、長くなってもしょうがないのでここで一旦打ち切っておきます。今回リリースした以下の記事もぜひ読んでみてください。

https://dwango.github.io/nicolive-kubernetes-migration-handbook-2022/

雑にDiscussionsを作っているので、深ぼってみたい話とかあればコメントしてください。修正はPull Requestまでお願いします。

https://github.com/dwango/nicolive-kubernetes-migration-handbook-2022/discussions

Discussion