はじめに
SREの須恵です。 弊社では、昨秋からサービスメッシュの導入を開始していたのですが、なかなかアウトプットできていなかったので書くことにしました。
マイクロサービス化とgRPC
弊社で進行中の技術テーマの1つに「マイクロサービス化」があります。
ビジネスの成長拡大に伴い開発組織も拡大し続けており、どんどん人数が増えています。この増加していく開発者の力を、可能な限り効果的に活かして開発するため、マイクロサービス化に舵を切ることになりました。
(マイクロサービスの動機と目指す結果の詳細はいずれ誰かが記事化することに期待)
今年の1月に弊社初のマイクロサービスとしてリリースされた2つのサービスがあり、それらはgRPCで通信することが決定されていました。また今後も、マイクロサービス間の同期通信にはgRPCの採用が広がる見込みです。
なぜgRPCか
モノリスをマイクロサービス化する、あるいは以前であればモノリスとして作っていたかもしれないものをマイクロサービスとして作るということは、今までin-processだったものがinter-processになると捉えなおすことができ、その点では単純にパフォーマンス面で不利な状況となります。これを補うためにはパフォーマンスに優れたプロトコルを選択したいところです。
また、マイクロサービスチームはそれぞれ組織的にも疎結合で独立して行動可能である必要がありますが、かといって一度公開したAPIを利用者(呼び出し元)に構わず破壊するようなことは行うべきではありません。gRPCでは、必ず最初にインターフェースを型付きで定義するため、比較的そのような破壊活動がしにくいのも良い点ではないかと思います。
gRPCリクエストの負荷分散
「弊社初の2つのマイクロサービス」は、いずれもEKSでコンテナとして実行される予定でしたが、Kubernetesクラスターの内部でgRPCを使って通信をしたいとなると、問題が浮上します。
Kubernetesは、Serviceリソースを通じたコネクションレベルの負荷分散を提供しますが、リクエストレベルの負荷分散は行えません。
一方で、gRPCはHTTP/2上に構築されており、HTTP/2は長寿命なコネクションを利用し続けます。
コネクションレベルの負荷分散では、コネクションの確立時にのみ分散が考慮され、確立されたコネクション内ではリクエストは同じバックエンドにのみ送られ続けます。
たとえば、3つのバックエンドがあり、それぞれに1つずつコネクションが張られているとすると、「コネクションレベル」では、完璧に分散されていることになります。
ここで3つのコネクションにおけるリクエストの勢いが下記のようであるとします。
- R1は秒間100リクエスト
- R2は秒間50リクエスト
- R3は秒間10リクエスト
これでは、「負荷」が「分散」されているとは言い難いです。「リクエストレベル」で負荷分散を行う必要があります。
いくつか方法があります。
1) マイクロサービスに負荷分散を実装する
gRPCサーバーのIPアドレスを取得し、負荷を考慮して宛先を決定する処理をgRPCクライアント側となるマイクロサービスに実装します。
KubernetesではPodは頻繁に再作成されIPアドレスも頻繁に入れ替わるため、APIサーバーに問い合わせることで最新のPodのIPアドレス一覧を更新し続ける必要があります。
(プラットフォーム管理者としては、個々のマイクロサービスコードにKubernetes APIへの依存が生まれるのは、嬉しいことではありません)
2) L7プロキシを設定して使う
EnvoyやNGINXのような、すでに「負荷を考慮して宛先を決定する処理」が機能として実装されたソフトウェアを利用します。
プロキシがgRPCサーバーのIPアドレスを取得するための設定を書き、PodをKubernetesにデプロイします。
あるいは、弊社ではEKSを使用しているのでALBを利用することも選択肢に入ります。この場合は「Ingressを定義する」ことが「設定を書く」ことになります。
3) サービスメッシュを使う
これは第3の道であると同時に、ある意味これは「マイクロサービスに負荷分散を実装する」と「L7 proxyを設定して使う」のハイブリッドであるとも考えられます。
サービスメッシュとは
サービスPodのサイドカーコンテナとして実行されるL7プロキシと、プロキシを「管理」するプロセス群です。
サイドカープロキシはサービスメッシュの「データプレーン」と呼ばれ、管理プロセスは「コントロールプレーン」と呼ばれます。
データプレーン(プロキシ)は、2番目の方法同様、負荷分散の仕事を請け負いますが、マイクロサービスのサイドカーとして展開されることで、Podの外からはマイクロサービス自体に負荷分散が実装されたように見えます。
コントロールプレーンは、大雑把に言えば2番目の方法における「設定を書く」部分を自動化します。つまり、Kubernetes APIとやりとりし、通信相手のPodのIPアドレスをデータプレーンに教えてやる仕事をします(他にもいろいろと働いてくれますが、負荷分散に限っていえばそのような役割になります)。
なぜサービスメッシュを選択するのか
ここまで負荷分散に注目してきましたが、サービスメッシュがもつ機能は負荷分散に限られません。サービスメッシュが採用しているプロキシによって実現可能な、さまざまなネットワーク関連機能の恩恵を受けることができます。
「さまざまなネットワーク関連機能」は次の3つに分類できます。
- 信頼性(Reliability)
- リトライやタイムアウト、トラフィック分割など
- 可観測性(Observability)
- サクセスレートやレイテンシ、リクエスト量の計測、分散トレーシングの補助など
- 安全性(Security)
- Mutual TLS(相互TLS)によるサービス間の接続など
これらのネットワーク関連機能は、マイクロサービスが取り扱うビジネスロジックの内容にかかわらず関心を払わねばならない「横断的関心事(Cross-Cutting Concerns)」です。
これをマイクロサービスに直接実装すると、マイクロサービスの数が増えるにつれ組織内で同じことの繰り返しが(場合によっては複数~多数の言語で)頻繁に発生することになります。
その結果、実装の内容や水準がマイクロサービスによって食い違っていたり、仮に共通ライブラリ化したとしても複数言語サポートの手間や設定・バージョンの差異までは解消しきれない、などの問題が生まれます。
サービスメッシュを採用することの価値は、マイクロサービスがどのような言語・フレームワークで実装されているかに関係なく、横断的関心事をアプリケーションコードから切り離しながら、プラットフォーム全体で均一に提供可能であるという点にあります。
プラットフォーム所有者の目標は、サービス所有者がビジネスロジックを実行するための内部プラットフォームを構築することです。サービスメッシュは、これを実現するために「さまざまなネットワーク関連機能」をただ提供するだけでなく、サービス所有者に依存しない形で提供します。
サービス所有者の目標は、ビジネスロジックを可能な限り生産的にビルドからリリースまで持っていくことです。サービスメッシュが「さまざまなネットワーク関連機能」を担ってくれることにより、この目標に近づくことができます。
サービス所有者はアプリケーションをサービスメッシュとは独立して変更でき、プラットフォーム所有者はサービスメッシュをアプリケーションとは独立して変更できます。独立して動けることは、速く動けることにつながります。
このような考えのもと、弊社ではマイクロサービス間のリクエストレベル負荷分散を筆頭に、横断的関心事をサービスから分離させ独立に管理する目的でサービスメッシュを導入することにしました。
サイドカーの自動注入
ちょっと待ってください。
「L7プロキシをサービスPodのサイドカーコンテナとして実行」と言いました。サービスのKubernetesマニフェストにサイドカーを追加するのは誰がやるのでしょうか?サービス所有者がやるにしろ、プラットフォーム所有者がやるにしろ、これは面倒な作業です。
「アプリケーションコードから切り離し」と言いながら、設定・バージョン差異の問題が残っているように見えます。「サービス所有者とプラットフォーム所有者が独立して動ける」という主張にも疑問が生じます。
結論としては、サービスのKubernetesマニフェストに誰か(人間)がサイドカーを追加する必要はありません。
サービスメッシュのコントロールプレーンはKubernetesのadmission webhookを実装したコンポーネントを持つため、「マニフェストがAPIサーバーに届いて」から「APIサーバーがetcdにオブジェクトを保存する」までの間に「Podにサイドカーを追加する」という変更を自動的に加えることが可能なためです。
なぜLinkerdか?序論
ここからはサービスメッシュの中からLinkerdを選んだ理由について説明します。
前提1:必須の機能要件は「リクエストレベルの負荷分散」
これができないサービスメッシュはないので、機能要件もほぼないに等しいです。機能の多寡は決め手にはなりません。多いほうが嬉しいかもしれませんが、多いからという理由で飛びつくのは尚早です。
また、「クラスター内でリクエストレベルの負荷分散を行う」のが動機なので、Kubernetes以外での動作やKubernetes以外の環境との相互運用性は、特に必要としていません。
※大まかに機能比較するにはservicemesh.esの表が見やすいです
前提2:アプリケーションエンジニア(社内の職種呼称ではSWE)に対してSREは人数がかなり少ない
さらにいえば、SREも全員がKubernetes関連をメインに注力しているわけではありません。機能が多いことは、逆に間接的なデメリットになる可能性があります。
現実的に運用可能なものを選ぶ必要があります。
前提3:プラットフォーム・サービス間の分離・独立
そもそも、サービスメッシュを選択する目的がこの分離と独立でした。なので、より分離度が高くなるものが好ましいです。
候補
候補は次の3つでした。
- Linkerd
- Istio
- AWS App Mesh
インストール~使用開始までの手順のシンプルさ
Linkerd
linkerd.io ※検討時点のバージョンのドキュメントへリンクしています
CLIまたはHelmチャートを使います。
まず、CLIのlinkerd install
コマンドですが、標準出力にマニフェストを出すだけのシンプルなものです。そのままkubectl apply -f -
にパイプすれば、簡単にインストールできます(Getting Startedでも簡単のためか、その手順になってます)が、ファイルにリダイレクトしてリポジトリに保存し、デプロイパイプラインにインストールさせるアプローチも可能です(マニフェストにはSecretが含まれるので、sopsで暗号化するなど何かしら対処する必要があります)。
弊社では、社内外で開発されたアプリケーションのマニフェスト管理には既にHelmを使用していたので、こちらも馴染みやすそうです。
アノテーションを付けたNamespaceにマイクロサービスの新しいバージョンをデプロイするか、すでにデプロイされたものをkubectl rollout restart
することでサイドカーが注入され、負荷分散が有効になります。
Istio
istio.io ※検討時点のバージョンのドキュメントへリンクしています
CLIまたはOperatorを使います。
Linkerd CLIとの違いは、istioctl install
コマンドはクラスターに直接操作を加えるという点です。また、[プロファイル]を事前に選択しておくことが必要です。
Operatorを使うアプローチは、「コントローラーの管理のためにコントローラーをインストールする」ことなので、慣れるまで時間がかかりそうです。運用対象も単純に増えます。
私が試してみた時点(v1.7)では、Helmチャートによるインストールは提供されていませんでした。
どうやら、
v1.4以前はサポートしていた→v1.4~v1.5では非推奨になった→v1.6でなくなった→v1.8で復活(ただしアルファ扱い、v1.9時点でも)
ということらしいです。
ラベルを付けたNamespaceにマイクロサービスの新しいバージョンをデプロイするか、すでにデプロイされたものをkubectl rollout restart
することでサイドカーが注入され、負荷分散が有効になります。
AWS App Mesh
docs.aws.amazon.com ※過去バージョンへのリンク方法が不明だったのでlatestへのリンクです
まず、App Meshのコントロールプレーンの中心は「Kubernetesクラスター外に存在するAWSリソースである」ところが他とは違います。
Kubernetes内では、
- App Meshリソースを表現するカスタムリソースを、実物のApp Meshリソースと同期する
- サイドカーの注入を行う
ためのコントローラーを動かします。
なので、AWS App Meshのインストールは、①コントローラーをクラスターにインストールして、②カスタムリソースを定義する(そしてApp Meshリソースが作成される)までがセットになります。
カスタムコントローラーにはHelmチャートが用意されています。カスタムコントローラーはAWSリソースを操作するため、IAM Roles for Service Accountsのセットアップが必要になります。
ラベルを付けたNamespaceにマイクロサービスの新しいバージョンをデプロイするか、すでにデプロイされたものをkubectl rollout restart
することでサイドカーが注入され、負荷分散が有効になります。
更新手順のシンプルさと、更新に伴うマイクロサービスのダウン
入れたら終わりではなく、使い続ける限り更新していかなければなりません。「更新が大変でどんどんバージョンが遅れていく」といったことは避けたいです。
Linkerd
In placeな更新方法のみです。linkerd upgrade
またはhelm upgrade
を実行します。
linkerd upgrade
はlinkerd install
と同様に、標準出力に更新されたマニフェストが表示されるのみです。kubectl apply -f -
にパイプするなり、ファイルにリダイレクトするなりします。
Linkerdのデータプレーンは次のマイナーバージョンのコントロールプレーンと互換性があるため、更新を行ってもマイクロサービスにダウンタイムはありません(と、公式ドキュメントに書いています)。だからIn placeな更新しかないのだと思います。
また、例外的にダウンタイムが生じるケースについても、ドキュメントに書いてありました。
Istio
CanaryとIn placeがあり、Canaryのほうが「ずっと安全で推奨される」と言われています。
つまりIn placeは「ずっと安全でない」ということでしょうか?
→In placeではトラフィックの中断が起こりえる、と書いてありました。
AWS App Mesh
ユーザーガイドでもGitHub Pagesでもリポジトリ内のmdファイルでも、更新時の考慮事項について触れられた箇所を見つけられませんでした。しかし、見つからなかったから考慮事項なく更新可能と捉えていいのか確信が持てません。
全体的にAWS App Meshのドキュメントは荒削りな印象で、第一に頼る情報源として不安に思いました。
なぜLinkerdか?結論
これまでに見てきたポイントから、以下の理由によりLinkerdを導入することに決めました。
- インストール・アップグレードプロセスがシンプルで慣れやすい、またドキュメントに信頼感が持てた
- →SREが少ない状況でも運用していくことができそう
- アップグレードでマイクロサービスがダウンしない
- →プラットフォーム・サービス間の調整不要で更新を実施できる
この判断を補強するものとして、Linkerdの設計原則を紹介します。
Linkerd Design Principles
ここでLinkerdが掲げる設計原則を紹介しておきます。
※筆者の訳になります。原文と詳細はリンク先をご覧ください linkerd.io
- Keep it simple
- Linkerdは、認知的オーバーヘッドが低く、運用上シンプルである必要があります。運用者はLinkerdのコンポーネントが明確で、動作が理解可能かつ予測可能であることを最小限の魔法で感じる必要があります。
- Minimize resource requirements
- Linkerdは、特にデータプレーン層で、パフォーマンスとリソースのコストを可能な限り最小限に抑える必要があります。
- Just work
- Linkerdは、既存のアプリケーションを壊してはならず、開始したり、簡単なことをしたりするために複雑な設定を必要としてはなりません。
実際にインストール・アップグレードのプロセスを確認してみたうえで納得できる原則になっていると思いました。
リソースに関しては(恥ずかしながら)評価を行っておりません。
代わりに…とはいえませんが、Istioとのパフォーマンス比較結果について下記記事を参照しました。
要約すると、レイテンシ・メモリ消費・CPU消費について比較を行いCPU消費については匹敵、レイテンシとメモリについては大きな優位性があるということでした。
※ただし、現在からするとやや古いバージョン同士での比較になります
リソース消費にはプロキシのパフォーマンスが影響する部分が大きいですが、Linkerdは複数のサービスメッシュでプロキシのポジションに採用されているEnvoyではなく、独自に開発したLinkerd2-proxyを使用しています。
その理由は以下の記事にて語られています。
簡単にまとめると「汎用のプロキシ(Envoy)を転用してサイドカーのポジションに収める」のではなく「サイドカーとして働くためだけの、要件・設定量・リソース消費量が絞られたプロキシを開発する」ことが、設計原則を守るためには必要と判断されたということのようです。
更新実績と予告
環境(時期) | Linkerdのバージョン |
---|---|
開発環境(導入時点) | stable-2.9.0 |
本番環境(導入時点) | stable-2.9.1 ※本番導入までの間に新バージョンがリリースされた |
開発環境(現在) | stable-2.10.1 |
本番環境(現在) | stable-2.10.1 |
実際に使っているLinkerdの管理手順は、近いうちに別の記事としてまとめようと思います。
おわりに
最後まで読んでくださったあなたに、思い出していただきたいことがあります。
そうです、SREは人数が少ないのです。
ご応募お待ちしております。