Enjoy Architecting

Twitter: @taisho6339

【Kubernetes】GKEのContainer Native LoadbalancingのPodのTerminationの注意点

概要

最近のGKEはContainer Nativeなロードバランシングを推奨しています。 これは、Alias IP, NEGという仕組みを使って、GCPロードバランサーがPodのIPに直接ルーティングすることができます。 しかし、適切にPodを設定していない場合、クラスタのメンテナンスなどでノードからPodがevictされたときにダウンタイムが発生してしまいます。 この記事ではContainer Native LoadBalancerの仕組みと、Podの適切な設定について説明していきます。

Container Nativeなロードバランシングの仕組み

Container Native LoadBalancingに記載してある通り、

イメージ 引用: Container Native LoadBalancing

GKEのMasterノードにNEG ControllerというCustom Controllerがいて、特定のAnnotationがついたServiceが登録されたときに、GCPにNEGリソースを作成し、Serviceに紐づくPodをNEGにAttachするという仕組みのようです。 また、zonal network endpoint groupという名前の通り、各ゾーンごとにNEGはつくられ、Podは自分が存在するゾーンのNEGに所属する形になります。

Podの退避時に纏る注意点

GKEではクラスタを自動アップグレードしてくれるので、ノードがローリングアップデートされます。するとそのタイミングでスケジュールされていたPodは一旦吐き出されて別のノードに再作成されるため、ライフサイクルに注意しないとダウンタイムが発生してしまいます。

Podが退避されるときのライフサイクル

Podが退避され、一旦NEGから外れてルーティングされなくなる時、下記のようなフローになります。

  1. PodがTerminating状態へ
  2. 下記が同時に走る
    1. ServiceのEndpointから退避されたPodが外れる
    2. PodのpreStop + SIGTERM処理が走る
  3. ServiceからPodが外れたことを検知してNEG ControllerがNEGからPodを外す
  4. GCLBが、退避されたPodにルーティングしなくなる

つまり、ここで意識しないといけないポイントは2点です。

  1. NEGから外れる前にPodが停止しないようにする
  2. NEGから外れても処理中のリクエストだけは処理完了させる

具体的な対応

1に関しては、PodにpreStopを適切に実装しましょう。 基本的なServiceによるルーティングのときと一緒ですね。

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 20"]

2に関しては、GCLBのBackendの設定にコネクションドレインを設定しておきましょう。

コネクション ドレインの有効化

Ingressを使用している場合は、CRDで設定することもできます。 BackendConfig パラメータによる Ingress 機能の構成

そして1のsleepの時間はコネクションドレインの時間より長く設定しておく必要があります。

確認方法

locustやApache Benchなどを使って一定負荷をかけつつ、drainコマンドを使ってノードからPodを退避させてもダウンタイム無しで安全に移動ができてることがわかるかと思います。

サンプル

kubectl drain gke-service-1-service-1-nodes-320d8165-7p7p --ignore-daemonsets --delete-local-data

まとめ

Container Load Balancingはとても強力な仕組みですが、 基本的なServiceのルーティング、LBの設定の考え方を踏襲し、忠実に設定してあげる必要があります。

【Kubernetes】マルチクラスタの最適なヘルスチェックとFailOverについての考察

概要

ここ最近関わっているプロジェクトで、 クラスタを安全にBlue/Greenで更新するためにマルチクラスタ構成にして冗長化する案件を推進していました。 クラスタの安全な更新とクラスタのHealth Check、Fail Overは切っても切れない関係にあります。 そこで得られた、どのような目的で、どのような構成でヘルスチェックするべきか、という1意見を記述してみます。

想定の構成

base_architecture.png

なぜクラスタ冗長化するのか?

クラスタのアップデート、Istioのバージョンアップデート時にダウンタイム無しで安全に更新するために冗長化しています。 InPlaceでUpdateするのではなく、 一度レプリカクラスタをLBから切り離して更新し、もう一度LBに紐づけ、 しばらく様子を見て問題なさそうならメインのクラスタも更新する、という方針をとっています。 またシステムのユーザへの影響を最小にするためにも、クラスタに問題があったときにまだ更新していないクラスタにちゃんとFail Overさせたいので、 ヘルスチェックの定義がとても重要になってきます。

ヘルスチェックを定義する上での課題

本来クラスタ自体の更新といえども、どのサービスにどんな影響を与えるかは各サービス依存です。 例えば、クラスタの中にはバージョン依存のリソースを使っているサービスもあり、アップデートすると一部壊れてしまうかもしれません。

よって、

  1. 各サービスごとに、問題があれば別クラスタにFail Overする
  2. すべてのサービスが正常に稼働していなければ別クラスタにFail Overする

といったような方針で安全を担保することが考えられます。

しかし1は現状構成が複雑になりがちであり、 2に関していえば特定のサービスだけの障害がクラスタ全体に波及してしまうため論外です。

(たとえばあるサービスに障害が出たときに、クラスタ全体でFail Overされてしまい、仮にFail Over先もヘルスチェックに通っていなかったときにLBからみてルーティング先がなくなる)

とはいえ、クラスタ更新起因でサービスに障害が発生したときに検知してFail Overする必要あります...

ではどうすればいいでしょうか?

採用した方針

ニーズに応じてヘルスチェックの内容を変えることにしました。

本来サービスの可用性は、CanaryやBlue/Greeなどを駆使してサービスのレイヤーで担保するべきであり、サービスすべての状態を検知する必要があるのはクラスタの更新中の間だけです。

よって、

  • ヘルスチェック用のサービスを実装

    1. 全サービスのヘルスチェックを行い、すべて健康ならOK、そうじゃないならNGを返すエンドポイントを提供 ※1
    2. 何もせず200を返すエンドポイントを提供
  • クラスタ更新中はGCLBのヘルスチェックは、Istio IngressGatewayを通して1のエンドポイントを見に行く

  • 更新が落ち着いたらFeature Toggleなどで、GCLBが見るヘルスチェックエンドポイントを2に切り替える ※2

という方針を採用しました。

new_health_architecture.png

これにより、クラスタ更新中はかなりシビアに全サービスをチェックし、 それ以外のときは、Istioの疎通だけ死んでいないことだけ担保して、サービスの可用性担保はサービスのレイヤに任せる、 といった方針を実現しています。

補足

  • ※1 実施自体は裏で非同期で行い、エンドポイントに問い合わせが来たら最新のスナップショットから結果を返す
  • ※2 Istio IngressGatewayを通すことでクラスタ全体でIstio起因で疎通が死んでいないことを担保する

まとめ

今回、普段はクラスタはIstioの疎通まで担保できてたら健康とし、サービスごとの可用性はサービスに任せるという思想で、 見るべきヘルスチェックエンドポイントを切り替えるという方針を取りました。 アーキテクチャの良し悪しは運用してはじめて明らかになるので、しばらく運用して所感を別の機会に振り返りたいと思います。

Istioはいかにしてサービス間通信のセキュリティを担保しているのか?

この記事について

この記事では、Istioがどのような考え方でサービス間通信のセキュリティを担保し、 どのように担保しているかを概観レベルで整理する。

サービス間通信で担保したいものと従来のセキュリティモデル

サービス間通信として担保したいものとして、 通信するアクセス元を制御し、盗聴やなりすまし、改ざんといった攻撃から守ることが挙げられる。 つまり、

  • man in the middle攻撃への防御
  • アクセス制御

がセキュリティ要件として挙げられる。 従来のセキュリティモデルとしては、 プライベートネットワークを構築し、IPベースでアクセス元を制限することでこれらを担保してきた。 (AWSのPrivate VPCとSGがわかりやすい)

Istioではどういうアプローチをとったのか?

昨今のコンテナベースで構築されるシステムでは、IPアドレスは高頻度で動的に変更されるためこのアプローチだと破綻する。 そこでIsitoは各ワークロードに対してIDを定義し、IDベースでアクセス制御を行うことととした。 具体的にはどういうアプローチなのか?

コアコンセプト

ワークロードに割り振られたIDをベースとしてTLS通信することで認証と暗号化通信をし、 認証されたIDに対する認可制御を行うことでアクセス制御を実現している。 そのために下記の仕組みを実装している。

  1. ワークロードに一意のIDを割り振る
  2. IDを認証するための証明書の発行、配布、ローテーション

これらは、SPIFFE(Secure Production Identity Framework For Everyone)と呼ばれる標準仕様で定義されており、IstioはCitadelコンポーネントがこれを踏襲した実装にあたる。 具体的なSPIFEEの仕様、フローはSPIFFEとその実装であるSPIREについてでわかりやすく言及されている。

IstioによるSPIFEE実装

ポイント

つまり、ワークロードのためのID、証明書を発行し、登録しておくための認証局としての役割を担ったisdiod(Citadel)が存在し、各ノードに存在するistio-agentが、このCitadelとistio proxyの間のやり取りを仲介し、証明書配布などを行っている。 (istio proxyのSDSはこのagentが担当している) これによって、Podが大量に存在するような大規模クラスタでも、Pod自体はistio agentによって分割統治されているので、istiodに対しての負荷をかなり抑えることができる。

また、通常想定されるTLSと違い、この証明書の期限は非常に短く設定されていて、頻繁にローテーションされている。 これによって攻撃者は攻撃を継続していくためにはこの証明書をローテーションされるたびに逐一盗み出さなくてはならず、攻撃の難易度を上げている。

まとめ

このようにIstioはワークロードレベルで認証し、それぞれにアクセス制御を設けている。 これによって動いているプラットフォームに左右されず、どのような環境においても安全な通信を実現することができる。

【ArgoCD】最小ではじめるプロジェクトごとのユーザ権限管理

本記事の概要

ArgoCDをクラスタにインストールするとまずadminユーザが作られます。 しかし、adminクラスタは権限が強く(いわゆるsuperuser)、自由にアプリケーションを更新したり削除したりできてしまいます。

マイクロサービスなどによってサービスごとに分割されたチームでは、チームごとに異なる権限を与えることが多いはずで(他チームのリソースへの権限をつけないなど)、adminユーザを共有するわけにはいきません。

とはいえ、個別にユーザを発行して権限を管理していくのも運用負荷がそれなりにかかります。 そこで本記事ではsmall teamのための最小ではじめるユーザの権限を管理を提案してみます。

ArgoCDのユーザと権限管理

ArgoCDでは、バージョン1.5以降からユーザのRBAC機能が実装されています。 これによって、ユーザに対して細かいリソースアクセスの権限設定ができます。

ドキュメントに記載している通り、ArgoCDでは直接ユーザを定義していく方法とSSOにより、GoogleGitHubのアカウント認証を通してユーザを定義していく2通りの方法を提供しています。 SSOを使えばユーザのグルーピングなど、複雑な機能を扱えたりするのですが、小規模な運用チームでは少々過剰なので、直接ユーザを定義していくLocal usersを作成していく方針でいくことにします。

想定する環境

今回は、クラスタ内にいろんなチームのサービスが混在する環境ということで、 マルチテナントなクラスタを想定します。 当然TeamAの人々はTeamBのサービス群に対して権限を持ちたくないハズです。 f:id:taisho6339:20200730183026p:plain

提案する構成

f:id:taisho6339:20200730183051p:plain 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
  • argocd-cmに定義することでLocal Userの追加を行うことができます。
  • loginはGUIへログインして使う用途、apiKeyはCLI用にトークンを発行して使う場合に設定します。

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しようとした結果 f:id:taisho6339:20200730183113p:plain

分散システムにおける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から対応するサーバを解決して名前解決を行います。

f:id:taisho6339:20200722162239p:plain (引用: https://vitalflux.com/wtf-consistent-hashing-databases/)

Consistent Hashの拡張、Chord Protocol

前述のConsistent Hashは完璧ではなく、欠点があります。 それは、ノードの離脱、ノードの参画がいつ起こったとしても、 ただしい結果を返し続けるためには一つのノードは、参加している他のすべてのノードの状態を把握している必要があることです。 (そうでないとノードが増えたり減ったりした時点で正しく結果を返すことができなくなる) これは大規模な分散システムになればなるほど、大きな課題になりえます。 大規模なノードの集合では各ノードは他のノードの情報を保持するために大規模なリソースを要求し、また、ノードの参加、離脱時の情報更新などの計算コストも高くつくことになります。 これを解決するべく生まれたのがChord Protocolになります。

基本的なアルゴリズムは下記の資料がとてもわかり易くまとめられています。

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を使って試しに3ノードたててみます。

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に対して簡単な負荷試験を行いながら、 負荷試験における基本的な観点を整理していく。

負荷試験の主な目的

負荷試験は何のために行うのだろうか? 主な観点としては下記のような項目が挙げられる。

  • 負荷に耐えうる構成か?
    • システムが想定した負荷に耐えられることを確認する
    • 長時間負荷をかけ続けた場合にメモリリークなどの潜在的なバグがないかを確認する
  • 要件に対して最適な構成になっているか?
    • 突然スパイクしたときにも自動でスケールし耐えられる構成になっているか?
    • 通常時に必要以上にインフラリソースを確保していないか?
  • スケール特性を把握する
    • まずどこにボトルネックが来て、どこをチューニングすればシステムがスケールするかを確認する

負荷試験の大事なルール

  • 必ずどこかがボトルネックになっている状態を作ること

  • スコープを絞ってステップごとに区切って実施すること

    • 問題の原因特定を効率的に行うことができるため、少しずつ負荷のかけ方を変えながら実施する
  • ネットワーク的に近いところから攻撃する

    • クラウドであれば同じVPC内から攻撃するのが望ましい
    • ネットワーク環境は千差万別であり、コントロールできないため、純粋にシステムの負荷を可視化したいのであればネットワーク起因の問題は排除した上で試験するべきである
  • 最初は最小の構成 & 自動スケールしないようにして実施する

    • ボトルネックがどこにあるかがわからない状態でオートスケールする構成で実施してしまうと、ボトルネックの特定が困難になる。よって、スケール特性を把握するフェーズで初めてオートスケールするようにする。

負荷試験をする上で参考にする性能指標

負荷試験実施の際は、下記指標を確認していき、問題があった場合はCPU使用率やメモリ使用率、コネクション数などの細かい指標を追っていくことになる。

  • スループット

    • 単位時間あたりどのくらいの処理をさばくことができるのか
  • レイテンシ

    • 処理をリクエストして、結果がクライアントに返ってくるまでどのくらいの時間がかかったか

実際の負荷試験のステップ例

  • 攻撃サーバのセットアップ

    • 最大攻撃性能を把握することで攻撃のパフォーマンスが十分に発揮できているかを確認する。
  • フレームワークの素の性能の把握

  • DBへの参照を含めた性能の把握

    • DBとの接続方法、クエリの実行方法などが適切に設定、実装されているかを確認する。
    • 前工程と比較してDB参照に大幅な劣化がある場合は、下記観点で確認する。
      • Web, DB, 攻撃サーバで負荷が正しくかかっているかを確認する
      • バッファプールにデータが乗り切っているか、キャッシュヒット率はどのくらいか
      • コネクションを永続化できているか確認する(コネクションプーリングの設定)
      • アプリケーション側でN+1やスロークエリなどが出ているか
  • DBへの更新を含めた性能の把握

    • DBとの接続方法、クエリの実行方法などが適切に設定、実装されているかを確認する。
    • 前工程と比較してDB参照に大幅な劣化がある場合は、下記観点で確認する。
      • Web, DB, 攻撃サーバで負荷が正しくかかっているかを確認する
      • 必要以上に偏ったユーザデータのシナリオになっていないか? ※例えばテストユーザを一人だけにして更新テストを行う場合、ロックの傾向が偏ってしまい、本番のシナリオと剥離してしまう。
      • 不必要なロック、トランザクション分離レベルなどを見直しつつ調整
  • 外部サービスとの通信を含めた性能の把握

    • 外部サービスとネットワークを経由して通信しているケースの負荷シナリオ
    • 自サービスの管轄外の場合、ここがボトルネックになりやすい
    • 負荷をかける場合は、意図せず外部サービスに過負荷をかけてしまわないよう気をつける
  • 実際の使用を想定したシナリオでの性能の把握

    • 実際のユーザのシナリオを想定し、負荷をかける
    • このシナリオでの負荷試験でしっかり想定どおりのパフォーマンスがでるかどうかを検証する
  • スケール特性試験

    • リソースが想定より余裕がある場合はスケールイン、スケールダウンして試してみることで必要以上にコストがかかっていないかを検証する

    • 負荷をあげていったときにどこがボトルネックになり、実際にそのボトルネックをスケールアウト、スケールアップ、パラメータチューニングにより解消したときに、システムが想定どおりスケールすることを確認する

実践してみよう!

環境

この環境で出せる攻撃時スループットの上限値を知る

現時点で、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を用い、下記の方法を考えた。

  • Cookieがないリクエストに関しては、設定した割合でそれぞれのバージョンに割り振る
  • Cookieを持っているリクエストに関しては、その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テストなど、ユーザには一貫した結果を出したい場合に活用したい。