How Linkerd uses iptables to transparently route Kubernetes traffic
このブログ投稿では、Linkerdがiptablesを使用してKubernetesポッドとの間のTCPトラフィックをインターセプトし、アプリケーションが知らないうちに「サイドカー」プロキシを介してルーティングする方法について説明します。 トラフィックを透過的にルーティングするこの機能は、ゴールデンメトリックからmTLS、リクエストの再試行からgRPC負荷分散まで、Linkerdの機能セット全体の鍵となります。 そして、iptablesはネットワーキングスタックの特に厄介な部分ですが、強力で柔軟性があります。 それで、iptablesが何であるか、Kubernetesの世界でどのように機能するか、Linkerdのようなサービスメッシュがどのように使用するかなど、iptablesの謎のいくつかを明らかにするときに読んでください。
Introduction
Linkerdの待望の2.11リリースに先立ち、同僚と私は、ポッド内のアプリケーションプロセスにトラフィックを転送するときにLinkerd2-proxyがどのように動作するかを作り直す機会がありました。 インバウンドトラフィックは、実際のインバウンドアドレスに関係なく、常にローカルホストにバインドされたポートに転送されました。 これは、パブリックインターフェイスにバインドされていないポートをクラスター内の他のポッドに公開するという意図しない結果をもたらしました。 セキュリティを強化し、今後のポリシー機能との統合を強化するために、トラフィックを元のアドレスに転送するようにプロキシを変更しました。 テスト中に、変更によって奇妙なループ動作が発生することに気付きました。リクエストはプロキシのインバウンド側で処理を開始しますが、アプリケーションプロセスに転送される代わりに、プロキシを離れてすぐに戻ってきます。
偶然にも、ほぼ同時に、私はCNIと恐ろしいiptablesにますます興味を持つようになりました。 この奇妙なループ動作に気がつくとすぐに、好奇心を満たし、netfilterの魔法の力を深く掘り下げる必要があることがわかりました。 そして魔法のように、それ(奇妙なループ動作)はnetfilterによるもの、より正確には、そのパケットフィルタリングカーネルモジュールであるiptablesが、Kubernetesクラスターでのプロキシを容易にするためです。 ほとんどのサービスメッシュは、トラフィック転送ルールを構成するためにこれに依存しており、その観点から、Linkerdも例外ではありません。 この記事は、私たち全員が知っていて嫌いなiptablesモジュールに焦点を当てていますが、Linkerdはiptablesインターフェースのみを使用していることを指摘する価値があります。 コードを変更しなくても、カーネルで有効になっている限り、バックエンドをBPFの代替手段(つまり、bpfilter)に透過的に置き換えることができます。
当然のことながら、多くの人が言うように、iptablesは、デバッグや調査が面白くなく、不可欠です。 そのため、私は、この過小評価されているコアコンポーネント、サイドカープロキシとの間のトラフィックルーティングを構成するファイアウォールを文書化することにしました。 iptablesやLinuxネットワーキングについては詳しく説明しません。 これは、このトピックに関する既存の記事のいくつかに任せたほうがよいでしょう(以下にリストされている推奨読本)。
Why do we need iptables?
iptablesがLinkerdに対して何をするかについて話す前に、Linkerdがあなたのために何をするかについて話すのは自然なことです。 サービスメッシュは、可観測性、セキュリティ、および信頼性の機能をアプリケーションに追加しますが、プラットフォームレイヤーで追加します。 メッシュは、アプリケーション間に配置されたネットワークプロキシのグループで表されるデータプレーンと、メッシュを操作する人間にインターフェイスを提供するコントロールプレーンで構成されます。 Linkerdは、セキュリティとパフォーマンスの理由から、データプレーンに独自の超軽量のRustベースの「マイクロプロキシ」を使用します。メッシュに参加するアプリケーションの各インスタンスは、その横でLinkerd2プロキシを実行します。 プロキシは「サイドカー」として実行されると言います。これは、アプリケーションのポッド内の単なる別のコンテナーです。
https://scrapbox.io/files/61580605f53d2900209f1fb1.png
Fig 1.1: Linkerd architecture
図1.1では、サービスメッシュアーキテクチャを見ることができます。 コントロールプレーンはクラスタに配置され、プロキシが意思決定プロセスを行うのに役立ちます。 メッシュ内の各ポッドは、サイドカー展開モデルを使用します。 プロキシは、アプリケーションとの間のトラフィック「コンジット」になります。
この図では、ポッドに追加のコンテナlinkerd-initがあることに気付いたかもしれません。 これがiptablesへの旅の始まりだと推測した場合、あなたは最も正しいです。
Init Containers and pod networking
Kubernetesネットワークモデルでは、各ポッドにIPアドレスがあります。 ポッドは、割り当てられたIPアドレスを使用して相互に通信します。 各ポッドは、そのネットワーク名前空間でも実行されます。 「Kubernetesネットワークモデルのガイド」に記載されているように、これにより、名前空間内のすべてのプロセスに独自の新しいネットワークスタックがポッドに提供されます。 ルート、ネットワークデバイス、ファイアウォールルールがあります。 これらの考慮事項は、特にトラフィックのプロキシのコンテキストで、私たちにとって重要です。 理想的には、プロキシはポッドに選択的に注入されます。 アプリケーションはメッシュへの参加を強制されるべきではなく(オプトインである必要があります)、ポッドごとにネットワーク名前空間を持つことで、構成の境界が明確に定義されます。 次に、ポッドごとに個別のネットワークデバイスとファイアウォールルールを使用すると、ルーティングを簡単に構成できます。結局のところ、iptablesはこれまでトラフィックのルーティングに使用されてきたため、すべてのトラフィックをプロキシ経由でルーティングしたいと考えています。 要約すると、プロキシを介してトラフィックをルーティングするようにポッドを構成できます。構成はポッド自体にのみ適用されます。 足りないのは、これを構成する方法だけです。 initコンテナを入力します。
このトピックに関するKubernetesの公式ドキュメントでは、initコンテナーが最もよく説明されています。ポッド内で複数のコンテナーが実行されている場合がありますが、アプリコンテナーが開始される前に実行される1つ以上のinitコンテナーを持つこともできます。 つまり、initコンテナーはJobに似ています。つまり、メインアプリケーションが開始する前に完了するまで一度実行され、失敗するとポッド全体が失敗します。 Linkerdは、initコンテナーを使用して、アプリケーションが起動する前にトラフィックルールを設定します。ご想像のとおり、iptablesを使用します。
“Sysadmins hate them! Ten simple rules to live by if you want to proxy traffic”
私たちはようやく良い部分に到達しています。 initコンテナーを使用する理由と、ポッドごとにiptablesをセットアップする方法を確立しましたが、実際のセットアップについてはどうでしょうか。 カーネル内のプロセス間でトラフィックをルーティングするには、何行のコードが必要ですか? それほど多くはありません。 これを実現するための10のルールがあります。 iptablesを理解するために、他の無料で入手できる資料に完全に依存した場合、私は非常に罪悪感を感じるので、数文で私自身の理解を示します。
パケットがネットワークインターフェイスから新しく到着すると、カーネルは決定プロセスを経て、パケットをどう処理するかを決定します。 パケットは、別のホップに転送、拒否、またはローカルで処理される場合があります。 この意思決定プロセスにおける重要な権威者は、カーネルのパケットフィルタリングサブシステムであるNetfilterです。 フィルタリングルールはiptablesを介して構成され、次のようになります。テーブルは複数のチェーンで構成され、各チェーンには1つ以上のルールがあります。 これらのルールはパケットと一致します。 パケットが一致すると、アクション(またはiptables lingoのターゲット)が実行されます。
テーブル、チェーン、およびルールがパケットに対してトラバースされ、順序が重要になります。 いくつかの組み込みテーブルがあり、各テーブルには一連のデフォルトチェーンがあります。 Linkerdの場合、主にnat(ネットワークアドレス変換)テーブルに関心があります。 natテーブルを使用すると、パケットの送信元や宛先を書き換えることができます。これは、トラフィックをプロキシするときに探しているものです。 パケットが到着したら、アプリケーションプロセスではなく、宛先をプロキシに書き換えます。 パケットが離れるとき、単にネットワークインターフェイスに残すのではなく、プロキシを通過するようにします。 natテーブルには、この動作を構成できる2つのデフォルトチェーンがあります。ネットワークインターフェイスからパケットが到着したときにトラバースされるPREROUTINGチェーンと、ローカルプロセスがパケットを生成したときにトラバースされるOUTPUTチェーンです。
この古風な知識を武器に、プロキシのインバウンド側でパケットがどのように処理されるかを確認する準備が整いました。 プロキシ間の通信は、(不透明なトランスポートが使用されていない限り)HTTP / 2に日和見的にアップグレードされ、日和見的にmTLSされます。 つまり、プロキシは最初にネットワーク名前空間に入ってくるパケットを処理する必要があります。そうしないと、意味をなさないアプリケーションにペイロードを送信するリスクがあります。
https://scrapbox.io/files/6158073a696adb001d75e6a7.png
Fig 2.1: inbound side of iptables
図2.1は、インバウンド側のパケットの流れを示しています。 最初に新しいチェーンを作成し、それをNATテーブルにアタッチします。 トラフィックは最初に事前ルーティングチェーンを通過するため、新しく作成したチェーンにパケットを送信するルールを添付します。 チェーンに「ジャンプ」した後、パケットはさらに2つのルールを通過します。
宛先ポートを無視する必要があるかどうかを確認します(Linkerd 2.9以降、特定のポートがスキップされる場合があります)。 ポートがスキップされた場合、パケットはアプリケーションに直接送信されます。
パケットがどの宛先ポートとも一致しない場合は、プロキシのインバウンドポートに転送されるだけです。 内部的には、iptablesはパケットのヘッダーを書き換え、IPアドレスを「localhost」に変更し、ポートを4143に変更します。カーネルはスマートです。プロキシがアクセスできるパケットの元の宛先を保存します。 SO_ORIGINAL_DSTソケットオプション。 処理後、プロキシはアプリケーションプロセスに転送される新しいパケットを生成します。 プロキシによって生成されたパケットは出力チェーンに送られます(混乱しますよね、わかります)。 幸い、1秒で説明するルールにより、パケットはそれ以上の処理をスキップして、宛先に直接移動できます。
https://scrapbox.io/files/615807b52180b70022010d12.png
Fig 2.2: outbound side of iptables
アウトバウンドのカウンターパートは少し理解しにくいですが、同様のフローに従います。 図2.2は、同じNATテーブルをトラバースすることにより、パケットがネットワーク名前空間からどのように抜け出すかを示しています。 もう一度、新しいチェーンを作成し、いくつかのルールを追加します。 以前は、事前ルーティングチェーンから「ジャンプ」していました。 アウトバウンドでは、出力チェーンから「ジャンプ」します。これは、パケットが最初に通過するときに通過します。
プロキシによって生成されたパケット(ハードコードされたユーザーIDを使用)は、自動的に処理から無視されます。 インバウンド側では、これにより、アプリケーションにパケットを送信できます。 アウトバウンド側では、パケットをネットワークカードに残すことができます。
ループバックインターフェイスを介して送信されたパケットはすべて自動的に無視されます。 たとえば、同じポッド内の2つのコンテナが互いに直接通信する場合、それらはプロキシを介して送信されません(これについては後で詳しく説明します)。
インバウンドの対応物と同様に、ポート無視ルールはパケットの処理をスキップして直接送信します。 これは、プロキシが処理してはならない特定のタイプのトラフィックに役立ちます。
最後に、チェーンのこれまでのところまで到達した場合、パケットはアプリケーションプロセスによって作成され、サーバー宛てであることがわかります(おそらく)。この場合、プロキシがパケットをインターセプトする必要があります。
But wait… there’s more!
特に、1つのルールは場違いに見えるかもしれません。 「なぜループバックトラフィックを無視する必要があるのか」と疑問に思われるかもしれません。 アウトバウンドトラフィックにはいくつかの例外があります。 アプリケーションがそれ自体(またはポッド内の別のコンテナー)と通信する場合、いくつかの理由でトラフィックのプロキシをスキップする必要があります。 最も明白なものは、ローカルホストにバインドされたポートでサービス検出を実行できないことです。 とにかく、選択するエンドポイントがたくさんあるわけではありません。 ループバックインターフェイスを循環するパケットも、事前ルーティングチェーンの通過を免除されます。 これは私たちが選択したものではなく、カーネルが独自に行う最適化にすぎません。
ただし、アプリケーションは、clusterIPサービスを介して自身と通信する場合があります。 この場合、パケットはプロキシによって処理されます。 プロキシが発信元ポッドをエンドポイントとして選択した場合でも、パケットはループバックインターフェイスを介して転送されます。 カーネルはパケットの宛先がローカルであることを認識しているため、このルートをインテリジェントに保存し、ループバックに関連付けます。 このような場合、事前ルーティングチェーンを介してパケットを再ルーティングするか、プロキシを完全にスキップする必要があります。そうしないと、プロセスは暗号化されたパケットを受信します。 これが、この記事の前半で説明したループ動作の原因です。 Linkerdでは、単純さの哲学に沿って、パケットがローカルのままである場合はmTLSをスキップすることにしました。 決まり文句のように聞こえますが、図は千の言葉の価値があります。 図2.3と図2.4は、上記のシナリオを示しています。
Wrapping it up
うまくいけば、私はあなたをKubernetes iptablesのエキスパートに変えることができました。 強力なiptablesは、低レベルでパケットフィルタリングを処理します。 サービスメッシュ(Linkerdを含む)は、アプリケーションコンテナとの間でトラフィックをプロキシする方法を提供するためにそれに依存しています。 私は、linkerd-initコンテナーを介して設定したルールのいくつかをわかりやすく説明したいと思います。 世界最速で最軽量のサービスメッシュを運用することは重要ではありませんが、特にトラフィックのプロキシは魔法のように見えることが多いため、知っておくと便利です。
https://scrapbox.io/files/61580854c7cf9c001db32b85.png
Fig 2.3: outbound localhost edge case
https://scrapbox.io/files/615808670ca3c7002089cc6a.png
Fig 2.4: outbound non-loopback local edge case
Reading List
これらの記事を読んで、iptablesの概念と、コンテナーネットワーキングがどのように機能するかをより探求することをお勧めします。
(全然読めてない)