サービスメッシュは急速に、現代のクラウドネイティブスタックにおける重要部分になりつつあります。サービス間通信のメカニズム(データセンタ用語では"East-Westトラフィック")をアプリケーションコードからプラットフォームレイヤに移行し、この通信の測定と操作を中心としたツールを提供することによって、オペレータやプラットフォームのオーナは、必要性の極めて高い可視化レイヤと、アプリケーションコードにほぼ依存しないコントロールを手に入れることができます。
"サービスメッシュ"という用語が業界用語集に登場したのはつい最近のことですが、その背景にある概念は新しいものではありません。これらの概念は、Twitter、Netflix、Googleなどの企業において、一般的にはFinagleやHystrix、Stubbyといった"ファットクライアント"ライブラリという形で、10年以上にわたって運用されてきたものです。技術的な観点から見れば、最新のサービスメッシュで採用されている並列デプロイ(co-deploy)によるプロキシアプローチは、DockerやKubernetesといったコンテナやオーケストレータの急速な普及によって実現した、これらのアイデアのライブラリからプロキシ形式へというパッケージの変更である、とも表現できます。
サービスメッシュの興隆は、 最初のサービスメッシュであり、この用語を生み出したプロジェクトであるLinkerdから始まりました。2016年に初めてリリースされたLinkerdは、現在は2つの並行する開発ラインを備えています。Scala、Finagle、Netty、JVMという"Twitterスタック"上に構築されたオリジナルの1.xブランチと、RustとGo言語で一から再構築された2.xブランチです。
Linkerd 2.0のローンチは、基盤となる実装が変更されただけではありません。長年の運用経験から学んだ豊富な教訓に基づいて、アプローチも大幅に変更されています。今回の記事では、これらの教訓と、それがどのような形でLinkerd 2.0の哲学、設計、実装の基礎になっているかを説明します。
Linkerdとは何か、なぜ重要なのか?
Linkerdはオープンソースのサービスメッシュで、Cloud Native Computing Foundationのメンバープロジェクトです。2016年に初めてローンチされ、現在はStravaやPlanet Labsなどのスタートアップから、Comcast、Expedia、Ask、Chase Bankといった大企業まで、世界中の企業の運用システムのアーキテクチャを支えています。
Linkerdは、マイクロサービスアプリケーションに観測可能性(observability)、信頼性(reliabilityt)、およびセキュリティ機能を提供します。重要なのは、この機能をプラットフォームレイヤで提供していることです。つまり、Linkerdの機能は、実装やデプロイメントに関係なく、すべてのサービスで一様に利用であり、開発者チームのロードマップや技術的な選択とはほとんど無関係に、プラットフォームのオーナに提供されるのです。これによって、例えば、各サービスの開発者チームのロードマップにTLS関連の作業を書き加えなくても、サービス間接続へのTLSの追加や、プラットフォームのオーナによる証明書の生成、共有、検証方法の設定が可能になります。
Linkerdは、オペレータがメッシュ化することを選択したサービスの周囲に、透過的なレイヤ5 /レイヤ7のTCPプロキシを挿入することによって機能します。挿入されたプロキシはLinkerdのデータプレーンを形成し、対象サービスのすべての送受信トラフィックを処理します。データプレーンは、Linkerdのコントロールプレーンによって管理されます。コントロールプレーンは、サービスを通過するトラフィックを監視および操作するための単一のポイントをオペレータに提供する、一連のプロセスで構成されています。
Linkerdは、ひとつの基本的認識に基づいています — すなわち、マイクロサービスアプリケーションを流れる要求トラフィックは、アプリケーションそれ自体のコードと同じように、アプリケーションの運用表面(operational surface)の一部である、というものです。このようにして、Linkerdは、特定のマイクロサービスの内部を詳しく調べることができなくても、成功率、スループット、応答レイテンシを監視することで、最高水準のヘルスメトリック(health metrics)を報告できるのです。同じように、Linkerdは、アプリケーションのエラー処理ロジックを変更することはできませんが、障害の発生した、あるいは処理の遅延したインスタンスに対して、自動的に要求を再試行することで、サービスの健全性を向上させることができるのです。さらにLinkerdは、接続の暗号化、暗号化によってセキュリティ保護されたサービスIDの提供、トラフィック率の移動によるカナリアおよびブルー/グリーンデプロイメントなどを実行する機能を持っています。
Linkerd 1.x
LinkerdはTwitterにおいて、世界で最初かつ最大のマイクロサービスアプリケーションのひとつを運用した経験から生まれました。Twitterが、それまでの3レイヤRuby on Railsアプリケーションから、MesosとJVMをベースとした先駆的クラウドネイティブアーキテクチャに移行した時に、計装、再試行、サービス検出などをすべてのサービスに提供するライブラリとして、 Finagleが開発されました。Finagleの導入は、Twitterが大規模なマイクロサービスの採用を実現するために不可欠な部分だったのです。
2016年にローンチされたLinkerd 1.xは、Finagle、Scala、Netty、JVMで構成された、運用実績のあるTwitterスタック上に直接構築されていました。当初の目的は、Finagleの強力なセマンティクスをできるだけ広く提供することにありました。非同期RPC呼び出し用Scalaライブラリのユーザは限られていることが分かっていたので、私たちはFinagleをプロキシ形式でバンドルして、任意の言語で書かれたアプリケーションコードで使用できるようにしました。幸運なことに、同じ時期にコンテナとオーケストレータが登場したため、サービスインスタンスと一緒にプロキシをデプロイするための運用コストを大幅に削減することができました。このような理由から、Linkerdは、DockerやKubernetesなどのテクノロジが急速に普及していたクラウドネイティブコミュニティにおいて、特に大きな勢いを得ることができたのです。
このようにひっそりと始まったLinkerdとサービスメッシュモデルですが、その後、急成長を遂げることになりました。Linkerd 1.xブランチは現在、世界中の企業で積極的に使用されており、開発も活発に続けられています。
Linkerdから学んだもの
Linkerdが成功したもかかわらず、多くの組織が、実運用環境への導入を望みませんでした。あるいは、望んではいたのですが、必要な投資額の大きさから、実行に移すことができずにいました。
このようなフリクションを発生させた要因はいくつかあります。まず、一部の組織では、JVMを運用環境に導入することに二の足を踏んでいました。JVMは特に複雑な運用面を持っており、一部の運用チームには、是非は別として、JVMベースのソフトウェアをスタックに導入することを回避する傾向がありました。Linkerdのようにミッションクリティカルな役割を果たすものについては、特にそれが顕著でした。
ある組織では、Linkerdが必要とするシステムリソースの割り当てに消極的でした。一般論として、Linkerd 1.xは、スケールアップの面では非常に優れていました — 十分なメモリとCPUがあれば、ひとつのインスタンスで1秒間に数万件の要求を処理できました — が、スケールダウンには不向きで、ひとつのインスタンスのフットプリントを150 MBのRSS以下にすることは困難でした。加えて、Scala、Netty、Finagleが、リソースが豊富な環境でスループットを最大化するように、つまりメモリを犠牲にするように設計されていることが、問題をさらに悪化させていました。
ひとつの組織で数百から数千のLinkerdプロキシを展開する可能性があるため、このフットプリントは重要だったのです。対策として私たちが推奨したのは、プロセス毎ではなく、ホスト単位でデータプレーンを展開する方法でした。これにより、リソース消費をより適切に償却することが可能になりますが、運用が複雑になり、サービス単位のTLS証明書など、Linkerdの提供する機能の一部が制限されるという欠点がありました。
(最近のJVMでは、これらの数値が大幅に改善されています。Linkerd 1.xのリソースフットプリントとテールレイテンシは、IBMのOpenJ9の下で大幅に削減されており、OracleのGraalVMではさらに削減されるとされています。)
最後に残ったのは、複雑性の問題でした。Finagleは大規模な機能セットを備えたリッチなライブラリで、その機能の多くは設定ファイル経由でユーザに直接公開されていました。その結果としてLinkerd 1.xは、カスタマイズ可能で柔軟性に優れる反面、学習曲線の勾配は大きくなっていました。特に大きな設計ミスは、基本的な構成プリミティブとしてデリゲートテーブル(dtabs)、つまりFinagleで使用されている、バックトラックと階層構造を持った、サフィックスを保持するルーティング言語を使用したことです。Linkerdの動作をカスタマイズしようとしたユーザは皆、すぐにdtabsの壁に突き当りました。その先に進むためには、相当の精神的投資をしなければならなかったのです。
新たな出発
Linkerdの採用レベルは上昇していましたが、2017年末になると、私たちは、自分たちのアプローチを再検討する必要があると確信していました。Linkerdの提案する価値が正しいことは明らかでしたが、運用チームに課している要件は不要なものでした。組織へのLinkerd導入を支援してきた経験を振り返って、私たちは、Linkerdが今後どのようになるべきかについて、いくつかの重要な原則を決定しました。
- 最小限のリソース要件。特にプロキシレイヤにおいて、パフォーマンスコストとリソースコストを可能な限り小さくする必要がある。
- ちゃんと動く(Just works)。Linkerdは既存のアプリケーションを棄損してはならない。また、単に起動するために複雑な設定を必要とすべきではない。
- シンプル。認知上のオーバーヘッドが少なく、操作が簡単であること。構成要素が明確で、ユーザが振る舞いを理解できる必要がある。
これらの要件は、それぞれが独自の課題をもたらします。システムリソースの要件を最小限に抑える上で、JVMを離れる必要があることは明らかでした。また、"ちゃんと動く"ためには、ネットワークプロトコル検出などの複雑な手法に対する投資が必要になります。そして最後に、シンプルにする — これが最も困難な要件です — ためには、あらゆる点において、ミニマリズム、増分性(incrementality)、内観性(introspection)を明示的に優先することが必要です。
リライトを前に、私たちは、具体的な初期ユースケースに焦点を合わせる必要がある、という認識を持ちました。その出発点として、純粋なKubernetes環境、一般的なプロトコルとしてHTTP、HTTP/2、gRPCを選択しました — ただし、これらの制限は、将来的に撤廃する必要があることも理解していました。
目標1: 最小限のリソース要件
Linkerd 1.xでは、コントロールプレーンとデータプレーンが、いずれも同じプラットフォーム(JVM)で書かれていました。しかしながら、これら2つのプロダクトは、実際には要件がまったく違います。すべてのサービスのすべてのインスタンスと共にデプロイされ、そのサービスとの間で交わされる全トラフィックを処理するデータプレーンは、可能な限り小さく、高速であることが求められます。それにも増して必要なのは安全性です — ユーザはLinkerdを信頼して、PCIやHIPAAコンプライアンス規制の対象となるデータを含む、極めて機密性の高い情報を扱っているからです。
一方のコントロールプレーンは、要求のクリティカルパスではなく周辺にデプロイされるものであるため、速度とリソースに関する要件は比較的緩やかで、拡張性とイテレーションの容易さに注目することが、より重要になります。
Go言語がコントロールプレーンに適した言語であることは、早い段階から分かっていました。Go言語にもJVMと同様のマネージドランタイムとガベージコレクタがありますが、最新のネットワークサービス用に調整されているため、JVMの場合のようなコストに関わる軋轢はまったくありません。さらにGo言語は、JVMに比較すると桁違いに運用が簡単な上に、静的バイナリである、メモリフットプリントや起動時間が改善される、といったメリットもあります。ベンチマークの結果は、ネイティブコンパイルされた言語には及ばないものの、コントロールプレーン用としては十分な速度でした。そして最後に、Go言語の優れたライブラリエコシステムによって、Kubernetes関連の豊富な既存機能にアクセスできるようになりました。加えて、この言語の参入障壁の低さと相対的な人気の高さから、オープンソースコントリビューションが促進されるであろう、という考えもありました。
データプレーンにはGo言語やC++も検討したのですが、Rustこそが私たちの要件を満たす唯一の言語であることは、最初から明らかでした。Rustの安全性、中でも安全なメモリプラクティスをコンパイル時に強制する強力なボローチェッカ(borrow checker)は、あらゆるクラスのメモリ関連のセキュリティ脆弱性を回避可能にすることによって、RustをC++よりもはるかに魅力的なものにしています。ネイティブコードへのコンパイルが可能であることと、メモリ管理のきめ細かい制御により、Go言語よりもパフォーマンス面で大幅に凌駕すると同時に、メモリフットプリントの制御性も向上しています。リッチで表現力の豊かなRust言語は、私たちScalaプログラマにとって大変魅力的でしたし、ゼロコスト抽象化のそのモデルは、(Scalaとは違って)安全性やパフォーマンスを犠牲にすることなく、その表現力を利用可能であることを示していました。
2017年当時のRustには、ひとつの大きな欠点がありました。ライブラリのエコシステムが、他の言語よりかなり劣っていたのです。Rustを選択すれば、ネットワークライブラリへの多額の投資が同時に必要になることも、当時の私たちは理解していました。
目標2: ちゃんと動く(Just works)
基本的な技術が選択されたので、次の設計目標である、"ちゃんと動く"という要件を満足するための作業に移りました。Kubernetesアプリケーションの場合、これは、機能している既存のアプリケーションにLinkerdを追加した場合でも、アプリケーションが機能しなかったり、最小限以上の設定作業が必要になることがない、という意味になります。
この目標を達成するために、いくつかの設計上の選択を行いました。私たちはLinkerdのプロキシを、プロトコル検出が可能なように設計しました。TCPトラフィックをプロキシすると同時に、使用されているレイヤ7プロトコルを自動的に検出することができるのです。pod作成時のリワイヤリングと組み合わせることで、任意のTCP接続を作成するアプリケーションコードによるコネクションを、ローカルのLinkerdインスタンスを通して透過的にプロキシ処理します。その接続がHTTP、HTTP/2、gRPCのいずれかを使用している場合、Linkerdは自動的に動作を変更し、成功率の報告、べき等要求の再試行、要求レベルでのロードバランシングなどによるレイヤ7セマンティクスを実現します。これらすべてが、ユーザの設定を必要とせずに実施されるのです。
さらに、アウト・オブ・ボックスでできる限り多くの機能を提供することにも尽力しています。Linkerd 1.xでは、プロキシ毎に豊富なメトリクスを提供していましたが、これらメトリクスの集計とレポート作成はユーザに任されていました。Linkerd 2.0では、期限の設定された小さなPrometheusのインスタンスをコントロールプレーンの一部としてバンドルすることで、アウト・オブ・ボックスの状態で、集約されたメトリックをGrafanaダッシュボード形式で提供できるようにしています。さらに、同じメトリクスをUNIXスタイルのコマンドでも使用可能にすることで、オペレータがコマンドラインから、運用中のサービスの動作を監視できるようにしました。プロトコル検出と組み合わせることで、プラットフォームオペレータは、設定や複雑な設定をすることなく、Linkerdから豊富なサービスレベルのメトリックをすぐに取得することができます。
図1: アプリケーションインスタンスとのインバウンドおよびアウトバウンドのTCP接続は、自動的にLinkerdデータプレーン( "Linkerd-proxy")を経由するようにルーティングされ、Linkerdコントロールペインによって監視および管理される
目標3: シンプル
使いやすさに関する目標とは相反しますが、これが最も重要な目標でした。(この問題に関する私たちの考えを明確にする上では、シンプルさ(simplicity)と容易さ(easiness)を対比したRich Hickey氏の講演が参考になりました。)Linkerdはオペレータ向けの製品である、ということを、私たちは知っていました。つまり、クラウドプロバイダが運用してくれるサービスメッシュとは対照的に、Linkerdはユーザ自身による運用が前提なのです。これはすなわち、Linkerdにおいては、運用表面積(operational surface area)を最小限に抑えることが最優先事項である、ということを意味しています。幸いにも、Linkerd 1.xを導入する企業の支援を長年続けてきた経験から、この件に関しては、具体的なアイデアを得ることができました。
- 動作内容を隠蔽したり、過度に神秘的に思われてはならない。
- 内部状態は検査可能でなければならない。
- コンポーネントは明確に定義され、分離され、明確な境界を持たなければならない。
この目的にかなう設計の選択肢を、私たちはいくつか作り上げました。コントロールプレーンをひとつのモノリシックなプロセスに統合する代わりに、WebサービスがWeb UIを駆動し、プロキシAPIサービスがデータプレーンとの通信を処理するというように、コントロールプレーンを自然な境界で分割しました。これらのコンポーネントをLinkerdのダッシュボードで直接ユーザに公開して、より大きなKubernetesエコシステムのイディオムや期待に合うように、ダッシュボードとCLI UXを設計しました。例えばlinkerd
install
コマンドは、KubernetesマニフェストをYAML形式で出力して、kubectl applyを通じてクラスタに適用する、Linkerdのダッシュボードは、ルック・アンド・フィールをKubernetesのダッシュボードに合わせる、といった具合です。
節度を保つことによって、複雑さも回避しました。"サービス"とは何かについて、独自の定義を導入するのではなく、デプロイメントやPodといった、Kubernetesの中核をなす名詞を流用しました。また、シークレットやアドミッションコントローラなど、可能な限り既存のKubernetes機能を基盤に使用しています。クラスタの複雑性に大きく影響することが分かっていたので、カスタムリソース定義の使用は最小限にしました。等々。
最後に、広範な診断機能を追加することで、オペレータがLinkerdの内部を検査し、期待する状態であることを検証できるようにしました。コントロールプレーンをメッシュ化(すなわち、各コントロールプレーンポッドに、送受信されるすべてのトラフィックをプロキシするデータプレーンサイドカーを配置)することで、Linkerdの豊富なテレメトリを使用して、自分たちのアプリケーションと同じように、Linkerdの状態を理解し、監視することを可能にしました。Linkerdの内部的なサービスディスカバリ情報をダンプするlinkerd
endpoints
、KubernetesクラスタとLinkerdインストールがすべての面で期待通りに動作していることを検証するlinkerd
check
、といったコマンドも用意しています。
つまり、Linkerdを簡単で不思議なものではなく、明確で観察可能にするために、私たちはできる限りのことをしたのです。
図2: Linkerd 2.0のダッシュボードは、視覚的処理からナビゲーションまで、Kubernetesダッシュボードのルック・アンド・フィールに倣っている
Linkerd 2.0の現在
社内での開発に着手してから約1年後、2018年9月に、Linkerd 2.0をローンチしました。提供価値(バリュープロポジション)は基本的に同じですが、使いやすさ、操作の単純さ、リソース要件の最小化といった部分に重点を置いた結果、1.xとはかなり異なる製品形状になっています。アプローチの成果は、6ヶ月を待たずに現れました。それまで1.xブランチの採用が叶わなかった多くのユーザが2.xを採用したのです。
Rustを選択したことは、大きな関心を集めました。もともとギャンブルのようなものでした(実際、初期のバージョンは、Linkerdブランドを傷つける不安から、"Conduit"という名称でリリースしていました)が、その賭けに勝ったことは、今となっては疑いありません。2017年以降は、Tokio、Tower、HyperといったコアRustネットワークライブラリに多くの時間を割いてきました。リクエスト終了時に割り当てられたメモリを効率的に解放するように、Linkerd 2.0のプロキシ("linkerd2-proxy"という、そのものの名前で呼ばれています)をチューンして、リクエストフローにおけるメモリのアロケーションがデアロケーションで償却されるようにしました。これにより、レイテンシ分布が非常にシャープなものになりました。Linkerdのプロキシは、1ミリ秒未満のp99レイテンシと、10MBをはるかに下回るメモリフットプリントを特徴としています。これはLinker 1.xよりも1桁小さい値です。
Linkerdには現在、ユーザとコントリビュータによる活気あるコミュニティがあり、プロジェクトの将来は有望です。2.xブランチに関わる50人以上のコントリビュータ、週毎のエッジリリース、活発でフレンドリなコミュニティSlackチャンネルを持ったことで、私たちは、これまでの努力を誇りに思うと同時に、単純さ、使いやすさ、リソース要件の最小化という設計思想を維持しつつ、ユーザの抱える現実的な課題を解決し続けたいと願っています。
著者について
William Morgan氏は、クラウドネイティブ環境向けのオープンソースのサービスメッシュ技術の構築に注目するスタートアップである、Buoyantの共同創設者兼CEOです。Buoyantの前は、Twitterのインフラストラクチャエンジニアとして、失敗に終わったモノリシックなRuby on Railsアプリから、高度に分散化されたフォールトトレラントなマイクロサービスアーキテクチャへの、Twitterの移行に尽力しました。その後はPowerset、Microsoft、Adap.tvのソフトウェアエンジニアを経て、MITREの研究科学者となり、スタンフォード大学でコンピュータサイエンスの修士号を取得しています。