【ArgoCD】最小ではじめるプロジェクトごとのユーザ権限管理
本記事の概要
ArgoCDをクラスタにインストールするとまずadminユーザが作られます。 しかし、adminクラスタは権限が強く(いわゆるsuperuser)、自由にアプリケーションを更新したり削除したりできてしまいます。
マイクロサービスなどによってサービスごとに分割されたチームでは、チームごとに異なる権限を与えることが多いはずで(他チームのリソースへの権限をつけないなど)、adminユーザを共有するわけにはいきません。
とはいえ、個別にユーザを発行して権限を管理していくのも運用負荷がそれなりにかかります。 そこで本記事ではsmall teamのための最小ではじめるユーザの権限を管理を提案してみます。
ArgoCDのユーザと権限管理
ArgoCDでは、バージョン1.5以降からユーザのRBAC機能が実装されています。 これによって、ユーザに対して細かいリソースアクセスの権限設定ができます。
ドキュメントに記載している通り、ArgoCDでは直接ユーザを定義していく方法とSSOにより、GoogleやGitHubのアカウント認証を通してユーザを定義していく2通りの方法を提供しています。 SSOを使えばユーザのグルーピングなど、複雑な機能を扱えたりするのですが、小規模な運用チームでは少々過剰なので、直接ユーザを定義していくLocal usersを作成していく方針でいくことにします。
想定する環境
今回は、クラスタ内にいろんなチームのサービスが混在する環境ということで、 マルチテナントなクラスタを想定します。 当然TeamAの人々はTeamBのサービス群に対して権限を持ちたくないハズです。
提案する構成
ArgoCDのProjectリソースを論理的なサービスの区分けごとに定義し、 このProjectに対してのみ権限を持つユーザを定義します。
Project & Role定義
apiVersion: argoproj.io/v1alpha1 kind: AppProject metadata: name: projectA namespace: argocd spec: description: projectA project sourceRepos: - '*' destinations: - namespace: '*' server: '*' clusterResourceWhitelist: - group: '*' kind: '*' roles: - name: viewer description: Read-only privileges to projectA policies: - p, proj:projectA:viewer, applications, get, projectA/*, allow - name: editor description: Edit privileges to projectA applications policies: - p, proj:projectA:editor, applications, create, projectA/*, allow - p, proj:projectA:editor, applications, update, projectA/*, allow - p, proj:projectA:editor, applications, delete, projectA/*, allow - p, proj:projectA:editor, applications, sync, projectA/*, allow - g, proj:projectA:editor, proj:projectA:viewer
- ここでは参照用ロールと編集用ロールを定義しています。
- pがpolicy、gがおそらくgrantです。pでどのロール、ユーザに対して、どのような操作を拒否/許可するかというルールを定義し、gで実際にロール、ユーザに対してロールをアサインできます。(ロールに対してロールをアサインできるので、上記ではeditorにviewerのポリシーも付与しています)
Account
apiVersion: v1 kind: ConfigMap metadata: name: argocd-cm data: accounts.projectA-viewer: login,apiKey accounts.projectA-editor: login
Roleのアサイン
apiVersion: v1 kind: ConfigMap metadata: name: argocd-rbac-cm data: policy.csv: |- g, projectA-viewer, proj:projectA:viewer g, projectA-editor, proj:projectA:editor
実際にロールをアサインするにはargocd-rbac-cmに記載します。 projectファイルに記載しても動作しますが、こちらに集約したほうが一見してユーザと権限の割当がわかるのでおすすめです。
ユーザのパスワード更新
仕上げに作成したユーザがログインできるようにパスワードを更新しておきます。
argocd account update-password \ --account <name> \ --current-password <current-admin> \ --new-password <new-user-password>
また、匿名ユーザや、デフォルトのロールアサインを無効にしておくとより安全だと思います。
結果
- 管理画面にログインしてみると、read権限がないほかのプロジェクトのサービスは見えないようになっています
- また、viewerでsyncしようとした結果
分散システムにおけるScalableな名前付けアルゴリズム「Chord Protocol」を実装してみた
概要
分散システムを学術的に学びたくて、 Chord Protocolというアルゴリズムが面白かったので実際に論文を読んで実装してみました。 この記事では、分散システムにおける名前付けの概念と、Chord Protocolの紹介、簡単な検証について言及していこうと思います。
実際に作ったサーバ |
分散システムにおける名前付けとは?
分散システムの分野には「名前」、「名前付け」、「アドレス」と呼ばれる概念があります。 それぞれどのような意味を持っているのでしょうか?
名前付けと名前
分散システムはネットワークを通じてそれぞれのサーバ、プロセスが協調して動作しています。 この中で、各サーバ、プロセスはやり取りをする相手の「名前」を知らなければやり取りを行うことができません。 この名前の解決を行うことを「名前付け」と呼んでいます。 そして、あるリソース(特定のプロセス、サーバなど)を一意に特定するための文字列を「名前」呼んでいます。
アドレス
名前が分かっていても実際にはそれだけではやり取りできず、実際には「アドレス」と呼ばれるリソースの住所に対してアクセスすることでやり取りが成立します。 つまり、リソースに対する実質的なアクセスポイントをアドレスと呼んでいます。 名前との違いは、名前は永続的にリソースを一意に特定するもの、 アドレスはアクセスするために必要な実際のリソースの住所、つまり位置情報を持っているということです。 名前は永続的に同じリソースを指し続けるのに対し、アドレスはそのときリソースが存在する位置によって変化します。
具体例
ドメイン名(example.com)が「名前」、example.comに紐づくAレコードのIPが「アドレス」に当たります。 ドメイン名はずっと変わりませんが、IPアドレスはサーバを移管したりすることで変化することがありえます。 永久に同じリソースへのポインタになるのでドメインは「名前」として成立するわけです。
Naming Serverとは?
名前からアドレスを解決したり、特定のリソースに名前をつけてくれるサービスのことをNaming Serviceと呼びます。 一番わかり易い例としては、DNSが挙げられます。 DNSは、ドメインという名前からIPというアドレスを解決し、またIPに対してドメイン名をつける名前付けを行うことで成立しています。
代表的なアルゴリズム ~Consistent Hash~
名前付け、名前解決のためのアルゴリズムとしてメジャーなものにConsistent Hashがあります。 Consistent Hashは、 サーバの集合(以降各要素をノードと呼ぶ)に対してハッシュ関数で一意のID(たいていはホスト名などのハッシュ)を振り、 各ノードをID順に円状に並べ、 あるIDがどのノードに配置されるべきかを決定するアルゴリズムです。 与えられたID以上であり、値が一番近いノードに解決され、 円状になっているので、 仮にそのノードがいなくなったとしても次のノードに回されるといったように、ノード障害に強いアルゴリズムになっています。
詳しくは、こちらがわかりやすいです。 コンシステントハッシュ法
ハッシュによって名前付けを行い、 ハッシュによるIDから対応するサーバを解決して名前解決を行います。
(引用: https://vitalflux.com/wtf-consistent-hashing-databases/)
Consistent Hashの拡張、Chord Protocol
前述のConsistent Hashは完璧ではなく、欠点があります。 それは、ノードの離脱、ノードの参画がいつ起こったとしても、 ただしい結果を返し続けるためには一つのノードは、参加している他のすべてのノードの状態を把握している必要があることです。 (そうでないとノードが増えたり減ったりした時点で正しく結果を返すことができなくなる) これは大規模な分散システムになればなるほど、大きな課題になりえます。 大規模なノードの集合では各ノードは他のノードの情報を保持するために大規模なリソースを要求し、また、ノードの参加、離脱時の情報更新などの計算コストも高くつくことになります。 これを解決するべく生まれたのがChord Protocolになります。
基本的なアルゴリズムは下記の資料がとてもわかり易くまとめられています。
Chord Protocolは何が優れているのか?
基本的なことは上の資料がとてもわかり易いので、ここではかいつまんで何が優れているポイントなのかを説明しておきます。 乱暴にいってしまえば、
- 経路表による高速な検索
- ノード参画、離脱時に各ノードが独立して経路表を更新する仕組み
- 経路表が最新化されていない場合のためのFallback手段としてのSuccessorList
が主なポイントになります。 これらのポイントにより、耐障害性を担保しつつスケーラビリティを向上させることに成功しています。 順に追っていきましょう。
経路表による高速な検索
各ノードは、全てのノードの状態を維持する代わりに、一定サイズの経路表をメンテナンスします。 例えば、32ビット長のハッシュをIDとする場合、32個の行数を持つ経路表を持ち、 この表を元にして、他ノードにルーティングして検索します。 計算量はO(logN)になり(Nはノード数)、これによって大幅にスケーラビリティを担保できるようになります。
ノード参画、離脱時に各ノードが独立して経路表を更新する仕組み
Chord Protocolでは、ノードの参画、離脱時に各ノードがそれぞれ安定化させるためのルールを規定しています。 このルールに従った非同期プロセス、ないしスレッドが一定間隔で動作していて、経路表のメンテナンスを行います。 このメンテナンスは、ノードの離脱、参画に対して、メッセージ数がO(log2N)程度で、そこそこ高速に実行されます。
経路表が最新化されていない場合のためのFallback手段としてのSuccessorList
ノードが参加した直後などは当然経路表は最新化されていません。 よって一時的に正しい結果を返せない可能性もあります。 そこで、この資料のスライド131ページあたりに記載されているように、 Successor Listというリストも保持しておき、経路表による検索に失敗した場合はこのリストを使ってFallbackすることができます。
実際に実装してみた
ここまでがNaming Service及び、Chord Protocolの説明でした。 今回は、これをもとに実際にNaming ServiceをGoで実装してみました。 Goで書いたChordの参照実装なので名前はgordです。(安直)
https://github.com/taisho6339/gord
gordの概要
これはどういう実装かというと、 GordがChord Protocolのノード郡を管理するためのプロセスとして常駐し、 別途ストレージを管理するソフトウェアがgRPCでGordに対してアクセスし、 特定のキーがどのノードに存在するかを検索したり、 自分たちが管理するべきキーを判別したりする、といった機能を提供します。 k8sであればサイドカーとして各Podにくっついているイメージになります。 この構成は実際に論文でもサンプルとして提案されている構成になります。
論文で提案されている構成 |
実際に落とし込んだ構成 |
実装に対する検証
実際に実装に落とし込んだところで、ちゃんと実装できているのかを検証したいと思います。 機能的な観点は、単体テストにて実装しているので今回はパフォーマンス、耐障害性について検証してみます。 ただし、ローカルマシンでの検証につき、プロセッサ数に限度があるため、 お遊び程度の検証しかできないのはあしからず...
パフォーマンスに関する検証
探索のパフォーマンスを測定してみます。
ノード数を1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024と増やしていったときに特定キーの検索にかかる時間を計算してみましょう。
[ベンチマークテストの実装は雑ですがこの辺です。] (https://github.com/taisho6339/gord/blob/master/chord/bench_search_test.go)
すでにStabilizationを完了したノードを指定数用意し、検索のパフォーマンスの違いを見ていきます。
実験にした私のマシンは下記のスペックになります。
OS: MacOS Catalina 10.15.5 プロセッサ: 2.4 GHz 8コア/16スレッド Intel Core i9 メモリ: 64 GB 2667 MHz DDR4
結果はこんな感じになりました。
ノード数 | 検索にかかった時間 |
---|---|
1 | 20.1 ns/op |
2 | 4440 ns/op |
4 | 4450 ns/op |
8 | 4644 ns/op |
16 | 4644 ns/op |
32 | 6473 ns/op |
64 | 19778 ns/op |
128 | 25808 ns/op |
256 | 35940 ns/op |
512 | 35825 ns/op |
1024 | 36940 ns/op |
ローカルマシンでの検証なので32ノード以上になってくると、 ノードが実行している大量のゴルーチンを限られたプロセッサで共有せざるを得なくなってくるため、アルゴリズム以外の要因で遅くなっていそうです。 少なくともノード数によって線形に増加している傾向は見られませんでした。
耐障害性に関する検証
docker-compose build && docker-compose up gord1_1 | time="2020-07-22T06:59:08Z" level=info msg="Running Gord server..." gord1_1 | time="2020-07-22T06:59:08Z" level=info msg="Gord is listening on gord1:26041" gord1_1 | time="2020-07-22T06:59:08Z" level=info msg="Running Chord server..." gord1_1 | time="2020-07-22T06:59:08Z" level=info msg="Chord listening on gord1:26040" gord1_1 | time="2020-07-22T06:59:09Z" level=info msg="Host[gord1] updated its successor." gord2_1 | time="2020-07-22T06:59:09Z" level=info msg="Running Gord server..." gord2_1 | time="2020-07-22T06:59:09Z" level=info msg="Gord is listening on gord2:26041" gord3_1 | time="2020-07-22T06:59:09Z" level=info msg="Running Gord server..." gord3_1 | time="2020-07-22T06:59:09Z" level=info msg="Gord is listening on gord3:26041" gord2_1 | time="2020-07-22T06:59:09Z" level=info msg="Running Chord server..." gord2_1 | time="2020-07-22T06:59:09Z" level=info msg="Chord listening on gord2:26040" gord3_1 | time="2020-07-22T06:59:09Z" level=info msg="Running Chord server..." gord3_1 | time="2020-07-22T06:59:09Z" level=info msg="Chord listening on gord3:26040" gord2_1 | time="2020-07-22T06:59:09Z" level=info msg="Host[gord2] updated its successor."
すぐにStabilizerがノード間連携をし、経路表を更新することでどのノードも一貫した結果を返しています。
grpcurl -plaintext -d '{"key": "gord1"}' localhost:26041 server.ExternalService/FindHostForKey \ && grpcurl -plaintext -d '{"key": "gord1"}' localhost:36041 server.ExternalService/FindHostForKey \ && grpcurl -plaintext -d '{"key": "gord1"}' localhost:46041 server.ExternalService/FindHostForKey { "host": "gord1" } { "host": "gord1" } { "host": "gord1" }
つぎに試しに1ノード退場させてみます。
docker stop 1a3bf85b693b gord3_1 | time="2020-07-22T07:04:43Z" level=error msg="successor stabilizer failed. err = &errors.errorString{s:\"NodeUnavailable\"}" gord2_1 | time="2020-07-22T07:04:43Z" level=warning msg="Host:[gord1] is dead." gord3_1 | time="2020-07-22T07:04:43Z" level=warning msg="Host:[gord1] is dead." gord_gord1_1 exited with code 0
すぐにノードのダウンを検知し、更新に入っています。
grpcurl -plaintext -d '{"key": "gord1"}' localhost:36041 server.ExternalService/FindHostForKey \ ✘ 1 && grpcurl -plaintext -d '{"key": "gord1"}' localhost:46041 server.ExternalService/FindHostForKey { "host": "gord2" } { "host": "gord2" }
ノード1がダウンしてもすぐに経路表が更新され、さらにノード1に本来属していた"gord1"というキーが別のノードに移っていることがわかります。
まとめ
実際にChord Protocolを実装することで、 実装としては、未熟な点やバグなど残っていると思いますが、何が優れていて、既存の手法とは何が違うのかを実際に理解することができました。 また付加価値として256ビットにもなる大きな数値をGolangでどう計算するのか、 Golangでの並行処理についても勉強することができました。 こういったことが直接お仕事の役に立つケースは多くはないかもしれませんが、実装力のレベルを一段引き上げてくれる良い機会になり、楽しむことができました。 今度はもっと身近なOSSなどで利用されているアルゴリズムなども実装してみたいと思います。
これだけは知っておこう負荷試験 ~その1 基本とお試し試験~
記事の概要
負荷試験はシステム運用において避けて通れないタスクであるが難易度が高いタスクでもある。
本記事ではまず、実際にWordpressに対して簡単な負荷試験を行いながら、 負荷試験における基本的な観点を整理していく。
負荷試験の主な目的
負荷試験は何のために行うのだろうか? 主な観点としては下記のような項目が挙げられる。
- 負荷に耐えうる構成か?
- 要件に対して最適な構成になっているか?
- 突然スパイクしたときにも自動でスケールし耐えられる構成になっているか?
- 通常時に必要以上にインフラリソースを確保していないか?
- スケール特性を把握する
- まずどこにボトルネックが来て、どこをチューニングすればシステムがスケールするかを確認する
負荷試験の大事なルール
必ずどこかがボトルネックになっている状態を作ること
スコープを絞ってステップごとに区切って実施すること
- 問題の原因特定を効率的に行うことができるため、少しずつ負荷のかけ方を変えながら実施する
ネットワーク的に近いところから攻撃する
最初は最小の構成 & 自動スケールしないようにして実施する
負荷試験をする上で参考にする性能指標
負荷試験実施の際は、下記指標を確認していき、問題があった場合はCPU使用率やメモリ使用率、コネクション数などの細かい指標を追っていくことになる。
実際の負荷試験のステップ例
攻撃サーバのセットアップ
- 最大攻撃性能を把握することで攻撃のパフォーマンスが十分に発揮できているかを確認する。
フレームワークの素の性能の把握
DBへの参照を含めた性能の把握
- DBとの接続方法、クエリの実行方法などが適切に設定、実装されているかを確認する。
- 前工程と比較してDB参照に大幅な劣化がある場合は、下記観点で確認する。
- Web, DB, 攻撃サーバで負荷が正しくかかっているかを確認する
- バッファプールにデータが乗り切っているか、キャッシュヒット率はどのくらいか
- コネクションを永続化できているか確認する(コネクションプーリングの設定)
- アプリケーション側でN+1やスロークエリなどが出ているか
DBへの更新を含めた性能の把握
- DBとの接続方法、クエリの実行方法などが適切に設定、実装されているかを確認する。
- 前工程と比較してDB参照に大幅な劣化がある場合は、下記観点で確認する。
- Web, DB, 攻撃サーバで負荷が正しくかかっているかを確認する
- 必要以上に偏ったユーザデータのシナリオになっていないか? ※例えばテストユーザを一人だけにして更新テストを行う場合、ロックの傾向が偏ってしまい、本番のシナリオと剥離してしまう。
- 不必要なロック、トランザクション分離レベルなどを見直しつつ調整
外部サービスとの通信を含めた性能の把握
- 外部サービスとネットワークを経由して通信しているケースの負荷シナリオ
- 自サービスの管轄外の場合、ここがボトルネックになりやすい
- 負荷をかける場合は、意図せず外部サービスに過負荷をかけてしまわないよう気をつける
実際の使用を想定したシナリオでの性能の把握
- 実際のユーザのシナリオを想定し、負荷をかける
- このシナリオでの負荷試験でしっかり想定どおりのパフォーマンスがでるかどうかを検証する
スケール特性試験
実践してみよう!
- このWordPressリソースを用いて試しに負荷試験の流れを学ぶ https://kubernetes.io/docs/tutorials/stateful-application/mysql-wordpress-persistent-volume/
環境
この環境で出せる攻撃時スループットの上限値を知る
現時点で、MAXでどのくらいのスループットがでるのかを検証するため、 一番処理の軽い静的ファイルを返すエンドポイントに対し、 Apache Benchを使って負荷をかけてみる。 ネットワーク的な要因を排除した純粋なスループットを計測したいのでホストから実行する。 ネットワークはスループットをかなり落としてしまう要因になるので、実際の負荷試験においてもなるべくネットワーク的に近いところから試験することはかなり重要になってくる。
ab -n 20000 -c 200 -k http://10.108.252.89/readme.html
大体この数値がこの環境における攻撃時に出せる最大のスループットということになる。
Requests per second: 7711.03 [#/sec] (mean)
検証の妥当性
この結果が妥当であることをどのように判別すればよいだろうか? まず最初に見るべき観点は以下になる。
対象システムのCPU使用率が100%近くまで上がっているか?
- ※DBサーバへの負荷の場合はもう少し下がることが多い
攻撃サーバ側のCPU使用率は100%近くまで上がっているか?
- 余裕がある場合は、同時接続クライアント数が足りていない
極端に同時接続クライアントが多すぎる場合スループットには出ないが、レイテンシが極端に落ちる。(リクエストが実行されるまでの待ち時間も含まれてしまうため)
こうなると問題の切り分けが大変になってしまうので、同時接続クライアント数は多すぎず、少なすぎない状態で実施するのがベスト。
(十分に負荷がかかっているのに必要以上にクライアント数をあげない)
今回は静的ファイルのため、Webサーバ単体への負荷となり、WebサーバはCPU使用率が90%以上で張り付いていたため、 十分に負荷をかけられていると判断した。
Wordpressの実際のスループット
今度は静的ページではなく、MySQLへのアクセスを伴う実際のページにアクセスしてみる。
ab -n 20000 -c 200 -k http://10.108.252.89
スループットは下記まで落ち込んだ。
Requests per second: 30.93 [#/sec] (mean)
また、DBもWebもCPU使用率90%以上で張り付いているので負荷はしっかりかかっていると見て良さそうである。
DB側がボトルネックになっていそうなので、DBのPodをスケールアップしてみる。
DBをスケールアップしてみた後
もともとが1コアを割り当てていたので、2コア割り当てるようにしてみた。
kubectl describe node Namespace Name CPU Requests CPU Limits Memory Requests Memory Limits AGE --------- ---- ------------ ---------- --------------- ------------- --- default wordpress-5bbd7fd785-gswtm 0 (0%) 0 (0%) 0 (0%) 0 (0%) 22h default wordpress-mysql-88898b8ff-jhj9g 2 (50%) 2 (50%) 0 (0%) 0 (0%) 22h
ab -n 20000 -c 200 -k 10.108.109.63
両者ともにCPU使用率は90%以上で、スループットが純粋に向上した。
Requests per second: 70.14 [#/sec] (mean)
ただ、またもやDBもWeb側もCPUが90%以上になっているため、 まずDB側をスケールアップしないとスループットは上がらなさそうである。 今回はアプローチを変えてDBを一定時間キャッシュさせ、DBアクセスをしないようにしてみる。
DBキャッシュ適用後
DBキャッシュを有効にした結果、スループットが劇的に改善している。
Requests per second: 3120.29 [#/sec] (mean)
DB側のCPU使用率も一瞬はねたが、一気に下がった。 一方Web側は変わらずCPU使用率が90%超えであり、 ボトルネックがDBからWebに移ったのが分かる。
まとめ
ボトルネックがWeb側に移動したのでWeb側をスケールしたいところだが、ローカルの貧弱な環境だと限界なのでローカルでの試験は一旦ここまでとする。
基本的な流れをこのさきのステップも同じで、
を繰り返していくことになる。
次回はKubernetesにアプリケーションをデプロイし、複数ノード & 攻撃サーバも負荷対象システムもスケールできる環境で実際にやってみる。
~ TO BE CONTINUED ~
Istioで割合でTraffic Managementするときにユーザごとにセッションを固定する
解決したい課題
Istioでweightによってサービスのバージョンを切り替えているとき、 リクエストの割合ベースで切り替えているだけなので、 同一ユーザでも異なるバージョンが表示されてしまう。 ユーザには少なくとも一定期間は同じバージョンを見せたいケースが多いと思うので今回はその方法を検証してみた。
Istioに用意されているSession Affinity機能
IstioではDestinationRuleで、ユーザごとに一定期間バージョンを固定化してルーティングするよう宣言できる。 https://istio.io/docs/reference/config/networking/destination-rule/#LoadBalancerSettings-ConsistentHashLB だがこれはConsistent Hashアルゴリズムによって実装されているため、PodがHPAでスケールアウト、スケールインしたときにルーティングされる向き先が変わってしまう。 また、この機能はweightの指定によってルーティングを設定しているときには併用してつかうことができない。 この機能はIstioというよりは実際にはEnvoyが担っていて、Envoy側のIssueを見る限りまだ解決されている様子はない。 https://github.com/envoyproxy/envoy/issues/8167
ではどうすればよいのか?
今回はWorkAround的な手法として、Cookieを用い、下記の方法を考えた。
あるバージョンに割り振られたトラフィックに対して、レスポンスにSetCookieヘッダーを付与し、 そこにサービスのバージョン情報を付与する、という方針を考えた。
具体的な実装
apiVersion: networking.istio.io/v1alpha3 kind: Gateway metadata: name: sample-service-gateway spec: selector: istio: ingressgateway # use istio default controller servers: - port: number: 80 name: http protocol: HTTP hosts: - "*" --- apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: name: sample-service-gateway-vs spec: hosts: - "*" gateways: - sample-service-gateway http: - match: - headers: Cookie: exact: sample-service-version=v1 route: - destination: host: sample-service subset: v1 port: number: 8080 - match: - headers: Cookie: exact: sample-service-version=v2 route: - destination: host: sample-service subset: v2 port: number: 8080 - route: - destination: host: sample-service subset: v1 port: number: 8080 weight: 50 headers: response: add: "Set-Cookie": sample-service-version=v1; Max-Age=2592000 - destination: host: sample-service subset: v2 port: number: 8080 weight: 50 headers: response: add: "Set-Cookie": sample-service-version=v2; Max-Age=2592000 --- apiVersion: networking.istio.io/v1alpha3 kind: DestinationRule metadata: name: sample-service spec: host: sample-service subsets: - name: v1 labels: version: v1 - name: v2 labels: version: v2
これで実際にアクセスしてみると、最初は50%の確率でどちらかに割り振られ、それ以降のリクエストは同じバージョンへルーティングされる。
まとめ
今回はIstioでCookieを用いることで、バージョンを固定したルーティングを行った。 A/Bテストなど、ユーザには一貫した結果を出したい場合に活用したい。
Istio + Argo Rolloutとカナリアリリース
本記事について
Argo Rolloutを導入すると、様々なデプロイ戦略を実践することができる。 ただ少し実際の運用イメージが見えづらかったので整理してみる。
Argo Rolloutについての概要
Argo Rolloutとは通常のk8sのDeploymentに加え、カナリアリリースなどの様々なアップデート戦略をサポートするためのコンポーネント。 Rolloutという、Deploymentに対し、様々なPodのアップデート戦略をつけ加えたCRDを用いる。 Deployment同様に内部ではReplicaSetを管理していて、 アップデートのたびに新しいReplicaSetを作り、設定の通りにPodを移行させる。
カナリアリリース
一部のリクエストを新しいバージョンに流し、なにかあればロールバック、何も無ければ徐々にトラフィックを増やし、最終的に100%に切り替える。 ArgoRolloutでは、Rolloutリソースにどのように移行していくかをステップごとに宣言する。 設定できるのは下記のような項目。
- setWeightでカナリアバージョンの比重を設定する
- pauseでその状態でどのくらい待機するかを定義する
- analysisで同期的な新バージョンのヘルスチェックを挟む
ヘルスチェックについて
B/Gやカナリアの場合、定期的にヘルスチェックを行い、失敗した場合にロールバックすることができる。 実際にはAnalysisTemplateに記述し、Rolloutで指定することで、実行時にAnalysisRunリソースが作られ、 指定したヘルスチェックを実行する。 AnalysisTemplateでは、下記の実行形式をサポートしている。
Prometheusサーバからのメトリクス取得
- 一定時間内の200エラーが95%以上など
- 一定時間内の5xx系エラーが1%以下など
Job Metrics
WebHook
ちなみにAnalysisを定義しない場合はロールバックは自動で行わない。
結局Argo Rolloutを使って担保されるものはなに?
- 自動的な比重の変更と自動的なモニタリング + ロールバック
カナリアリリースの想定実行フロー
- 新バージョンデプロイ => 数%のトラフィックを新バージョンへ流す => 一定時間経って問題なければ自動的に新バージョンをStableバージョンを切り替え
- 新バージョンデプロイ => 数%のトラフィックを新バージョンへ流す => manual approveで新バージョンをStableバージョンへ切り替え
やらないといけないこと
- DeploymentはRolloutに置き換える
- カナリアリリース用のServiceを作成する
- VirtualServiceでカナリアリリースサービスとStableリリース用サービスで比重を0%, 100%に設定しておく
- ヘルスチェック用のリソース定義(AnalysisTemplate)
- Rolloutでstable用Service, canary用Service, virtualService, AnalysisTemplateを指定しておく
挙動FAQ
Podが存在しないときはどうなる?
- Deploymentと同様で、UpdatePolicyによらず、新規のReplicaSetおよびPodが作られる
どのようにWeightをコントロールしている?
Istioと連携するとどうなる?
- ルーティングがリクエストの割合ベースで分散できる
- WeightはArgoRolloutのPodがVirtualServiceの設定を随時書き換えている
カナリア用のServiceの向き先はStableと同じでいいの?
- 良い。Argo Rolloutが随時Serviceのセレクタに条件を付け足している
How to Use
- https://argoproj.github.io/argo-rollouts/getting-started/
- https://argoproj.github.io/argo-rollouts/features/canary/#canaryservice
- https://argoproj.github.io/argo-rollouts/features/analysis/#job-metrics
- https://argoproj.github.io/argo-rollouts/features/traffic-management/istio/
設定例
- stable = v1, canary = v2のケース
- AnalysisTemplateを使って、500エラーが一定期間内に3回でたらNGという定義を行っている
- istioが持つprometheusに対してクエリを投げている
- この設定では、比重を10%にし10分待ち、以降徐々に10分毎に20%ずつ増やす。50%になったら、そのまま待ち手動によるResumeを待つ。
apiVersion: v1 kind: Service metadata: name: reviews labels: app: reviews service: reviews spec: ports: - port: 9080 name: http selector: app: reviews --- apiVersion: v1 kind: Service metadata: name: reviews-canary labels: app: reviews service: reviews spec: ports: - port: 9080 name: http selector: app: reviews --- apiVersion: argoproj.io/v1alpha1 kind: Rollout metadata: name: reviews labels: app: reviews spec: replicas: 1 selector: matchLabels: app: reviews template: metadata: labels: app: reviews version: v2 spec: serviceAccountName: bookinfo-reviews containers: - name: reviews image: docker.io/istio/examples-bookinfo-reviews-v2:1.15.0 volumes: - name: wlp-output emptyDir: {} - name: tmp emptyDir: {} strategy: canary: analysis: templates: - templateName: failure-count args: - name: service-name value: reviews-canary.default.svc.cluster.local stableService: reviews canaryService: reviews-canary trafficRouting: istio: virtualService: name: reviews routes: - primary steps: - setWeight: 10 - pause: { 10m } - setWeight: 30 - pause: { 10m } - setWeight: 50 - pause: { } --- apiVersion: argoproj.io/v1alpha1 kind: AnalysisTemplate metadata: name: failure-count spec: args: - name: service-name metrics: - name: failure-count interval: 30s failureCondition: result >= 3 failureLimit: 3 provider: prometheus: address: "http://prometheus.istio-system.svc.cluster.local:9090" query: | sum(irate( istio_requests_total{reporter="source",destination_service=~"{{args.service-name}}",response_code!~"5.*"}[30s] ))
GitOpsとの兼ね合い
カナリアリリースが完了し、100%新バージョンに移行したあとに同期されたらどうなる?
- 結論: 何も変わらない
- ArgoRolloutがいじった結果とリソースが一緒になる
- StableとCanaryのServiceの向き先が両方共新Podになっていて、VirtualServiceもStableに100%向ける設定になっている
- 結論: 何も変わらない
カナリアリリース実行中に同期されたらどうなる?
- 結論: 一度ルーティングの割合がStable 100%, Canary 0%にリセットされた後、Rolloutによって同期される直前の状態に戻される
カナリアリリース時と通常リリース時でのフローの違い
デバッグ方法
- RolloutとAnalysisRunリソースを見るとデプロイの挙動が分かる
kd rollouts.argoproj.io kd analysisruns.argoproj.io
Analysisの設定は実運用上どうすれば良い?
IstioでPrometheusを有効にしてインストールし、Istioのメトリクスによって判別する
- --set values.prometheus.enabled=trueをつけてistioctl manifest applyすればprometheusが有効になるので、これをつかってメトリクスを取得することはできる
- ただ、素直に入れても可用性は担保されていないので、別途冗長化するなどの可用性対応が必要になってくる
Stackdriverなどにメトリクスを集約している場合、Goなどでデータを引っ張ってきた上で判定する
カナリアリリースした結果失敗してロールバックしたよね、というのはどのように検知すればよいか?
- Argo Rolloutの機能では通知できないので、自前の検証プログラムを使ってAnalysisRunを動かす場合は、プログラムの中にエラーケースでアラートするように記載しておく
- 例えばStackdriverならCloud Monitoringを活用して、ロールバック条件と同じ条件でアラートが飛ぶようにポリシー設定しておく
【GCP Anthos】 Fail Overの挙動から最適なマルチクラスタ構成を考える
記事の目的
前回の記事でIngress For Anthosを用いてRegionにまたがったKubernetsのマルチクラスタを構築した。 今回は1段深く掘り下げて、クラスタ内に複数のフロントサービスを持つケースを考える。 このケースでAnthosで構築したマルチクラスタのFail Overの挙動を詳しく検証し、 どんなときに、どんな条件でFail Overするのかを整理する。 また、それによって最適なマルチクラスタ構成を考える。 今回はIstioはマルチコントロールプレーン構成にしないので、クラスタ内のサービス間通信のFail Overについてはとりあげない。 【GCP Anthos】 Regionに跨って冗長化したKubernetsのマルチクラスタをロードバランシングする
題材となるサービス
今回は少し実運用を考慮し、クラスタ内に2つのフロントサービス(外部から直接ルーティングされてくるサービス)を作り、それぞれへはIngressGatewayでルーティングすることとする。
GCLB + NEGsのFailOverの検証
Anthosはクラスタ間のロードバランシングにGCPのCloud Loadbalancer(以下GCLB)とクラスタ内の任意のPodに紐付いたNEGを使う。 よってまずはGCLB + NEGの構成のFailOverの挙動をまずは整理する。
事前準備
- 東京クラスタ
- GKE 1.15
- Anthosへのメンバー登録済み
- istioインストール済み(v1.5.1)
- 台湾クラスタ
- GKE 1.15
- Anthosへのメンバー登録済み
- istioインストール済み(v1.5.1)
アプリケーションのデプロイ
ホストヘッダーによってbookinfoとzoneprinterへのルーティングの向き先を変更するようにしている。
apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: name: zoneprinter spec: hosts: # - "*" - "www.zoneprinter.com" gateways: - single-gateway http: - match: - uri: exact: / - uri: exact: /ping - uri: exact: /location route: - destination: host: zoneprinter port: number: 80 --- apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: name: bookinfo spec: hosts: - "*" # - "www.bookinfo.com" gateways: - single-gateway http: - match: - uri: exact: / - uri: exact: /productpage - uri: prefix: /static - uri: exact: /login - uri: exact: /logout - uri: prefix: /api/v1/products fault: abort: httpStatus: 503 percentage: value: 100.0 route: - destination: host: productpage port: number: 9080
MCI, MCSリソースのデプロイ
一つのMCSを一つのIngress GatewayのPodに割り当てている。
FailOverの検証方法
MultiClusterServiceが一つなのでIngress GatewayのPodに紐付いた単一のNEGが作られる。 よってこのままだと、ヘルスチェックに使用できるパスが一つのフロントサービスへのルーティングのみになってしまうが、一旦このまま検証する。
- ヘルスチェック対象のサービス = zoneprinter, ヘルスチェック非対象のサービス = bookinfo
- ホストヘッダーがwww.bookinfo.comのものはbookinfoへ、それ以外はzoneprinterへルーティング
- ヘルスチェックは、
/
のパスへ - ダウンさせるサービスは、IstioのFault Injectionを利用し、エラーが常に返却されるように
- サンプルコードはこちら
GCLB + NEGのFail Overの挙動結果まとめ
ヘルスチェック対象でないサービスがダウン
ヘルスチェック対象のサービスがダウン
Fail Over条件のカスタマイズは不可能
つまり、ヘルスチェックによってのみFail Overするかどうかが決まる。 よって、今の構成だと、ヘルスチェック対象のサービスがUnhealthyの場合、 引きずられてHealthyなサービスまでも、冗長化された別クラスタにFail Overされてしまう。
理想的な挙動はなにか?
フロントサービスに対して個別にヘルスチェックを実施し、 一つのフロントサービスのヘルスチェックステータスが他のサービスのルーティングに影響しないようにしたい。 つまり、今回ならzoneprinterがUnhealthyだからといって、bookinfoまで別クラスタにルーティングしないでほしい。
どういう構成にするべきか
ようは各フロントサービスごとにヘルスチェックしてほしい => 各サービスごとにNEGが作られて、それらがGCLBのバックエンドとして設定されてほしいということになる、 よってフロントサービスの数だけMultiClusterServiceを作成し、単一のIngressGatewayに紐付けることにした。
補足
ちなみに、各フロントサービスに対して直接NEGを紐付ければいいのでは?と思うかもしれないが、 そうなるとIngressGatewayのEnvoyを通過せずに直接リクエスト先サービスのPodにルーティングされてしまうので、 VirtualServiceやDestinationRuleの設定が無視されてしまう。 この辺の挙動のイメージは、こちらにまとめた。 また、IngressGatewayのPodへのNEGを作るので、IngressGatewayにアタッチされているL4のGCLBは不要になる。 よってIstio Operatorなどを使ってNodePortに変更する。
実際のコード
apiVersion: cloud.google.com/v1beta1 kind: BackendConfig metadata: name: multi-gateway-bookinfo-cfg namespace: istio-system spec: healthCheck: checkIntervalSec: 15 timeoutSec: 10 healthyThreshold: 1 unhealthyThreshold: 2 type: HTTP port: 80 requestPath: / hostHeader: "www.bookinfo.com" --- apiVersion: networking.gke.io/v1 kind: MultiClusterService metadata: name: multi-gateway-bookinfo namespace: istio-system annotations: beta.cloud.google.com/backend-config: '{"ports": {"80":"multi-gateway-bookinfo-cfg"}}' spec: template: spec: selector: app: istio-ingressgateway ports: - name: multi-gateway-bookinfo protocol: TCP port: 80 targetPort: 80 --- apiVersion: cloud.google.com/v1beta1 kind: BackendConfig metadata: name: multi-gateway-zoneprinter-cfg namespace: istio-system spec: healthCheck: checkIntervalSec: 15 timeoutSec: 10 healthyThreshold: 1 unhealthyThreshold: 2 type: HTTP port: 80 requestPath: / hostHeader: "www.zoneprinter.com" --- apiVersion: networking.gke.io/v1 kind: MultiClusterService metadata: name: multi-gateway-zoneprinter namespace: istio-system annotations: beta.cloud.google.com/backend-config: '{"ports": {"80":"multi-gateway-zoneprinter-cfg"}}' spec: template: spec: selector: app: istio-ingressgateway ports: - name: multi-gateway-zoneprinter protocol: TCP port: 80 targetPort: 80
apiVersion: networking.gke.io/v1 kind: MultiClusterIngress metadata: name: multi-gateway namespace: istio-system spec: template: spec: rules: - host: "www.bookinfo.com" http: paths: - path: / backend: serviceName: multi-gateway-bookinfo servicePort: 80 - host: "www.zoneprinter.com" http: paths: - path: / backend: serviceName: multi-gateway-zoneprinter servicePort: 80 backend: serviceName: multi-gateway-zoneprinter servicePort: 80
確認
この状態でbookinfoのヘルスチェックエンドポイントに対してFaultInjectionし、 zoneprinterにcurlすると、
❯❯❯ curl -H "Host:www.zoneprinter.com" http://35.241.18.9/location <!DOCTYPE html> <h4>Welcome from Google Cloud datacenters at:</h4> <h1>Tokyo, Japan</h1> <h3>You are now connected to "asia-northeast1-a"</h3> <img src="https://upload.wikimedia.org/wikipedia/en/9/9e/Flag_of_Japan.svg" style="width: 640px; height: auto; border: 1px solid black"/>
となり、引きずられてFailOverしていないことが分かる。
また、逆にzoneprinterにFaultInjectionし、 bookinfoにアクセスすると、 台湾クラスタの方のログにアクセスが来たことが分かる。
まとめ
- GCLBはヘルスチェックによってのみFailOverする
- MCSに対して一つずつNEGが作られるので、複数のNEGを作ることで複数のフロントサービスに対してヘルスチェックを行うことができ、サービスごとにFailOverできる
- 今回のコード
IstioのTraffic Managementの動作イメージを掴もう
本記事について
Istioを使う上で、Traffic Managementを司るVirtual Service, DestinationRuleについて、 どう作用しているのかという点がわかりにくかったため、本記事にて整理し実際に動かしながら検証する。 前提として、サービスメッシュやEnvoyの基本的な設定についてはざっくりと理解している読者を想定している。
Istioとは?
Istioは、サービスメッシュの機能を統括的に導入、管理を行うためのOSSである。 内部ではEnvoyを使用していて、IstioとしてはとしてはこのEnvoyのコントロールプレーンとしての機能も担っている。 What is Istio?
主に、
Traffic Management
- 割合やヘッダーによるルーティングのコントロールやロードバランシングなどを行うための機能
- Envoyの機能であるような、サーキットブレーカー、Fault Injectionなどの機能も基本的に使える
Observability
- Promheusなどメトリクスを集計するためのソフトウェアにデータを転送する機能
Security
- mTLS通信による認証された通信や、JWTを活用した認可処理をプロキシとして挟んだりすることができる
- mTLSに用いられる証明書はIstioが独自証明書を発行してくれ、特に意識せずともmTLSをサービス間通信に導入することができる
といった機能を提供しており、Kubernetsなどのプラットフォーム連携が充実している。
Traffic Managementについて
Istioは、k8sにデプロイされたPodに自動的にサイドカーとしてEnvoyベースのProxyをInjectする機能が備わっている。 基本的には、リクエスト元サービス -> Envoy -> Envoy -> リクエスト先サービスという流れで通信することになる。 そして、このEnvoyが実際にルーティングの制御を行っている。 このEnvoyに設定情報を提供するのがk8sのCRDである下記3種類のリソースである。
- Virtual Service
- DestinationRule
各リソースについての概念説明などはなどは、 公式ドキュメント に記載されている通りである。 このさきは、minikubeにIstioと、Istioのデモアプリである、Bookinfo をデプロイして挙動を確認していく。
事前準備
- minikube v1.9.2
- Istio v1.5.2
- Kubernets v1.18.0
Ingress Gatewayの実態を見てみよう
初期状態
Ingress Gatewayを有効にしてIstioをk8sのクラスタにインストールすると、
- Service(Load Balancer Type)
- Deployment
のリソースがそれぞれ作られていることが分かる。
❯❯❯ kubectl get service -n istio-system istio-ingressgateway LoadBalancer 10.103.255.236 10.103.255.236 15020:31097/TCP,80:31587/TCP,443:32153/TCP,15029:30056/TCP,15030:32475/TCP,15031:32379/TCP,15032:32322/TCP,31400:32143/TCP,15443:32376/TCP 3d15h
❯❯❯ kubectl get deployments.apps -n istio-system NAME READY UP-TO-DATE AVAILABLE AGE istio-ingressgateway 1/1 1 1 3d15h
そして、このDeploymentによって管理されているPodの定義を見てみると、
❯❯❯ kubectl get pod -n istio-system istio-ingressgateway-6489d9556d-sp8f2 -o yaml | grep image: image: docker.io/istio/proxyv2:1.5.2
のようになっているのが分かる。 このproxyvというのが中でEnvoyが動いてる。 この状態でRoutingの設定を覗いてみると、まだなにも定義していないためすべて404になるようになっている。
❯❯❯ istioctl -n istio-system proxy-config route istio-ingressgateway-6489d9556d-sp8f2 -o json | yq -y '.' - - name: http.80 virtualHosts: - name: blackhole:80 domains: - '*' routes: - name: default match: prefix: / directResponse: status: 404 validateClusters: false
GatewayリソースとGatewayリソースに紐付けたVirtualServiceの作成
では次に下記リソースをapplyし、IngressGatewayにAttachしたGatewayリソースと、VirtualServiceを作ってみる。
❯❯❯ kubectl apply -f samples/bookinfo/networking/bookinfo-gateway.yaml
そうすると、下記のようなルーティングルールが作成されている。
❯❯❯ istioctl -n istio-system proxy-config route istio-ingressgateway-6489d9556d-sp8f2 -o json | yq -y '.' - - name: http.80 virtualHosts: - name: '*:80' domains: - '*' - '*:80' routes: ... - match: path: /productpage caseSensitive: true route: cluster: outbound|9080||productpage.default.svc.cluster.local timeout: 0s retryPolicy: retryOn: connect-failure,refused-stream,unavailable,cancelled,resource-exhausted,retriable-status-codes numRetries: 2 retryHostPredicate: - name: envoy.retry_host_predicates.previous_hosts hostSelectionRetryMaxAttempts: '5' retriableStatusCodes: - 503 maxGrpcTimeout: 0s metadata: filterMetadata: istio: config: /apis/networking.istio.io/v1alpha3/namespaces/default/virtual-service/bookinfo decorator: operation: productpage.default.svc.cluster.local:9080/productpage ...
このように、IngressGatewayにAttachしたGatewayを作成し、さらにそのGateway指定したVirtual Serviceを作ると、 L4/LBから直接ルーティングされてくるEnvoyにルーティングルールが追加される。 また、向き先としてはPodのIPが解決されている。
❯❯❯ istioctl -n istio-system proxy-config endpoint istio-ingressgateway-6489d9556d-sp8f2 -o json | yq -y '.' - | grep "outbound|9080||productpage.default.svc.cluster.local" -A 30 - name: outbound|9080||productpage.default.svc.cluster.local addedViaApi: true hostStatuses: - address: socketAddress: address: 172.17.0.12 portValue: 9080 weight: 1
Gatewayに紐付けないVirtualService
次に下記リソースをapplyし、reviewsサービスにはv2に50%、v3に50%の割合でルーティングされるようにしてみる。
❯❯❯ kubectl apply -f samples/bookinfo/networking/destination-rule-reviews.yaml ❯❯❯ kubectl apply -f samples/bookinfo/networking/virtual-service-reviews-v2-v3.yaml
まずIngress Gateway側のEnvoyの設定を確認する。
❯❯❯ istioctl -n istio-system proxy-config route istio-ingressgateway-6489d9556d-sp8f2 -o json | yq -y '.' -
今回はGatewayにAttachしていないVirtualServiceを作成したので、特にルーティング設定に変化がないことが分かる。
ではアプリケーション側のPodに配置されたEnvoyの設定はどうだろうか?
❯❯❯ istioctl proxy-config route productpage-v1-7f44c4d57c-nfnjd -o json | yq -y '.' - - name: reviews.default.svc.cluster.local:80 domains: - reviews.default.svc.cluster.local - reviews.default.svc.cluster.local:80 routes: - match: prefix: / route: weightedClusters: clusters: - name: outbound|80|v2|reviews.default.svc.cluster.local weight: 50 - name: outbound|80|v3|reviews.default.svc.cluster.local weight: 50 timeout: 0s retryPolicy: retryOn: connect-failure,refused-stream,unavailable,cancelled,resource-exhausted,retriable-status-codes numRetries: 2 retryHostPredicate: - name: envoy.retry_host_predicates.previous_hosts hostSelectionRetryMaxAttempts: '5' retriableStatusCodes: - 503 maxGrpcTimeout: 0s metadata: filterMetadata: istio: config: /apis/networking.istio.io/v1alpha3/namespaces/default/virtual-service/reviews decorator: operation: reviews:80/*
長いので省略しているが、reviewsへのトラフィックのときに下記2つの向き先へ50%の割合でルーティングしているのが分かる。
endpointの設定を見てみると、それぞれv3のPodのIP、v2のPodのIPを指していることが読み取れる。
❯❯❯ istioctl proxy-config endpoint productpage-v1-7f44c4d57c-nfnjd -o json | yq -y '.' - | grep reviews -A 30 - name: outbound|9080|v3|reviews.default.svc.cluster.local addedViaApi: true hostStatuses: - address: socketAddress: address: 172.17.0.10 portValue: 9080 - name: outbound|9080|v2|reviews.default.svc.cluster.local addedViaApi: true hostStatuses: - address: socketAddress: address: 172.17.0.11 portValue: 9080
ちなみにこのルーティング設定は、Bookinfoアプリケーション内のそれぞれのサービスのサイドカーEnvoyの設定に組み込まれている。 (正確にはIstio Pilotの仕組みを使って設定が動的にInjectされている) つまり、リクエストを送る側、つまりクライアント側のプロキシ(Envoy)でロードバランシング、ルーティング制御を行い、Podにトラフィックを送っているのである。
DestinationRuleを削除してみる
最後に上記までの状態からDestinationRuleだけ削除してみる。
❯❯❯ kubectl apply -f samples/bookinfo/networking/destination-rule-reviews.yaml
ルーティングルールとしては変化していないように見える。
❯❯❯ istioctl proxy-config route productpage-v1-7f44c4d57c-nfnjd -o json | yq -y '.' - clusters: - name: outbound|80|v2|reviews.default.svc.cluster.local weight: 50 - name: outbound|80|v3|reviews.default.svc.cluster.local weight: 50
だが、エンドポイント定義としてはv2, v3が消えてしまっているのが分かる。
❯❯❯ istioctl proxy-config endpoint productpage-v1-7f44c4d57c-nfnjd -o json | yq -y '.' - | grep reviews - name: outbound|9080||reviews.default.svc.cluster.local