iptables から理解する Istio 1.10 から変更された Inbound Forwarding

こんにちは! Microservices Platform Network チーム の hatappi です。

メルカリでは Service Mesh の実装として Istio を採用しています。 私達 Microservices Platform Network チーム (以下 Network チーム) の役割の1つとしてこの Istio の管理があり、 Istio のバージョンアップや各マイクロサービスの Istio の導入支援や仕組みづくりを行っています。 メルカリが Istio を採用した背景が知りたい方は Adopting Istio for a multi-tenant Kubernetes cluster in Production に書かれているのでそちらをご覧ください。

Istio は執筆時点では最新のバージョンが 1.11 となっており、 Istio 1.9 が 10月、Istio 1.10 が11月に End of Life を迎えます。 そのため Istio を管理されている方の中には 1.10 へのアップグレードを検討されている方もいるのではないでしょうか。

メルカリでは現在 Istio 1.10 が本番で稼動しています。Istio 1.10 へは Istio 1.9 からアップグレードしたのですが、アップグレードの際に1.10での変更点の1つの Inbound Fowarding の変更によって問題が発生したので、この記事では、その問題を共有し、それがなぜ起きたのかの詳細を書いています。

Inbound Fowarding の変更について

Istio 1.10 の Release announcement の Inbound Fowarding の変更について書かれた箇所は下記になります。 https://istio.io/latest/news/releases/1.10.x/announcing-1.10/#sidecar-networking-changes

Istio を使用しない場合、通常 Pod 上で起動するアプリケーションは loeth0 の2つのネットワークインターフェースをもっており、 lo には 127.0.0.1, eth0 には Pod の IP アドレスが割り当てられています。 そして Pod へのリクエストは eth0 を通してアプリケーションに到達します。

Istio を使用した場合、Istio 1.9 以前はサイドカーとして起動している istio-proxy である Envoy はリクエストを lo にリダイレクトしていました。 これが Istio 1.10 の Inbound Fowarding の変更によって eth0 にリダイレクトするようになりました。 ※ Istio 1.10 時点では PILOT_ENABLE_INBOUND_PASSTHROUGH=false を設定することで今まで通り lo にリダイレクトすることが可能です。

この違いを図で示したのが下記になります。

Istio 1.9 以前 Istio 1.10 以降 
Istio 1.9 までの inbound forwarding Istio 1.10 からの inbound forwarding

そもそもなぜこの変更が必要なのでしょうか?

例えば下記のような Go で書かれた HTTP Server があったとします。

package main

import (
    "io"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/hello", func(w http.ResponseWriter, req *http.Request) {
        io.WriteString(w, "Hello, world!\n")
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}

http.ListenAndServe の第1引数に :8080 が指定されておりワイルドカードに bind されるため アプリケーションは lo, eth0 の両方にきたリクエストを受け取ることができます。 これが 127.0.0.1:8080 の場合は lo から、 [pod ip]:8080 の場合は eth0 からのみリクエストを受け付けるようになります。

Istio がない場合の Pod では通常 eth0 を通してアプリケーションにリクエストをわたすため、 lo にのみ bind しているアプリケーションに他の Pod からのリクエストは到達しません。 しかし Istio 1.9 以前ではリクエストは lo にリダイレクトされるため lo にのみ bind しているアプリケーションに他の Pod からのリクエストが到達できました。

これによって起きる問題は例えばアプリケーションが管理用API を Pod でのみ展開するために意図して lo にbind している場合でも Istio 1.9 以前は他の Pod からリクエストができてしまいます。

ここまでの話を他の Pod からリクエストが可能かどうかでテーブルにまとめました。

Istio なし Istio 1.9 以前 Istio 1.10 以降
ワイルドカード
lo
eth0

表を見ていただくと分かるように今回の変更によって Istio 1.10 以降は Istio なしの場合と挙動も変わらないため、認知負荷も減り今までよりも Istio を採用しやくなったのではないでしょうか。

ここまで Istio 1.10 から変更になった Inbound Fowarding の内容を見てきました。 次にこの変更によって私達のサービスに起きた問題を共有していきます。

起きた問題

今回の変更によってアプリケーションが動かなくなるケースとしては、アプリケーションが lo にのみ bind している場合です。

Istio ではこれを事前にチェックするためのコマンドを用意しており istioctl experimental precheck を実行します。 該当する Pod がある場合には IST0143 という message code が出力されます。 該当する Pod があった場合の解決方法は公式のドキュメントに記載されていますのでそちらをご覧ください。

メルカリではこのコマンドの実行で該当する Pod はありませんでした。 そのため開発環境で 1.10 にアップグレードしたのですが、一部のアプリケーションで問題が発生しました。

下記は実際のコードとは全く異なるものですが、問題を再現するコードです。

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    host, _, _ := net.SplitHostPort(r.RemoteAddr)
    ip := net.ParseIP(host)
    _, ipnet, _ := net.ParseCIDR("127.0.0.1/32")
    if ipnet.Contains(ip) {
    // something
    }
})

同じ Pod 内にいる Envoy がリクエストをアプリケーションにリダイレクトするため fowarded IP は 127.0.0.1 になります。 そのため上記のコードでは // something が処理されます。

しかし Istio 1.10 では // something は処理されなくなりました。 アプリケーションのログを見たところ fowarded IP が 127.0.0.1 から 127.0.0.6 になっていることがわかりました。

解決策

RFC では 127.0.0.0/8 は loopback address になります。 https://datatracker.ietf.org/doc/html/rfc6890#page-6

そのため net.ParseCIDR("127.0.0.1/32") となっている部分を net.ParseCIDR("127.0.0.0/8") と変更するのは理にかなっていそうです。

これで問題は解決となるのですが、1つ疑問が残ります。 なぜ fowarded IP が 127.0.0.1 から 127.0.0.6 に変わったのでしょうか?

fowarded IP が 127.0.0.1 から 127.0.0.6 に変更された理由

まずは Envoy の設定が Istio 1.9 と Istio 1.10 でどのように変わったのかを確認します。

Istio 1.9 と 1.10 で istioctl proxy-config cluster --output json [pod name] を実行した結果の差分を下記に記載しています。 また今回は 8080 port に bind したアプリケーションを使って検証したので、 8080 port に関連する設定のみを抜粋しています。

➜ diff <(cat prior-istio1.10 | jq --sort-keys) <(cat istio1.10 | jq --sort-keys)
14a15
>     "cleanup_interval": "60s",
16,34c17
<     "load_assignment": {
<       "cluster_name": "inbound|8080||",
<       "endpoints": [
<         {
<           "lb_endpoints": [
<             {
<               "endpoint": {
<                 "address": {
<                   "socket_address": {
<                     "address": "127.0.0.1",
<                     "port_value": 8080
<                   }
<                 }
<               }
<             }
<           ]
<         }
<       ]
<     },
---
>     "lb_policy": "CLUSTER_PROVIDED",
49c32,38
<     "type": "STATIC"
---
>     "type": "ORIGINAL_DST",
>     "upstream_bind_config": {
>       "source_address": {
>         "address": "127.0.0.6",
>         "port_value": 0
>       }
>     }

まず注目するのが type が STATIC から ORIGINAL_DST に変更になった点です。 eth0 に bind されていた Envoy は STATIC の場合はリクエストを load_assignment の設定に従って lo にリダイレクトしていましたが、ORIGINAL_DST によって eth0 にそのままリダイレクトするようになりました。

そして今回 fowarded IP が 127.0.0.6 になった理由を知るために重要なのが Istio 1.10 で追加された upstream_bind_config です。 この設定によって Envoy が upstream と接続を確立するときに 127.0.0.6 が使用されるようになります。

これでなぜ fowarded IP が 127.0.0.6 に変わったのかを設定の差分から確認することができました。 しかし肝心のなぜこの変更をする必要があったのかは分かっていません。

さらに調査を行い、この機能が実装された時の Design Doc を発見しました。 これには冒頭で解説したような Inbound Fowarding を変更する背景や設計、移行計画などが記載されています。 https://docs.google.com/document/d/1j-5_XpeMTnT9mV_8dbSOeU7rfH-5YNtN_JJFZ2mmQ_w/

この中に今回 127.0.0.6 にした理由が記載されていました。

Inbound clusters will be changed from STATIC to ORIGINAL_DST. Additionally, the UpstreamBindConfig will be modified to the "magic" 127.0.0.6 address. This is to avoid iptables loops; we have some special logic that will allow calls from Envoy to go back to themselves so that for calls from app -> app, we hit outbound and inbound paths. The 127.0.0.6 address allows short-circuiting this by returning early if the source IP is 127.0.0.6. https://docs.google.com/document/d/1j-5_XpeMTnT9mV_8dbSOeU7rfH-5YNtN_JJFZ2mmQ_w/edit#heading=h.b4lo2pn1czhy

Envoy には自分自身を呼び出すロジックがあり、仮に 127.0.0.1 を使ってしまうと iptables でループが起きます。それを防ぐために 127.0.0.6 という IP address を使用したようです。

これでなぜ 127.0.0.6 に変更する必要があったのか分かりました。

記事としてはこれで終わりでも良いのですが、なぜ 127.0.0.1 を使うと iptables でループが起きるのかを知ることができると、同時にどのように Pod 内でリクエストがアプリケーションへ到達するのかを理解することができると思ったので、 iptables を見て今回の問題を理解することにしました。

iptables から理解する Inbound Fowarding の変更

iptables を見ていく前に現状のポイントをおさらいします。

  • Istio 1.10 から Envoy は lo から eth0 にリクエストをリダイレクトするようになった
  • Envoy が fowarding する際の IP address が 127.0.0.1 から 127.0.0.6 になった

下記の出力は Istio を使用している Pod 内の iptables で nat table の各 Chain とそのルールを表示したものです。

Chain PREROUTING (policy ACCEPT 49 packets, 2940 bytes)
 pkts bytes target     prot opt in     out     source               destination         
 1876  113K ISTIO_INBOUND  tcp  --  any    any     anywhere             anywhere            

Chain INPUT (policy ACCEPT 1876 packets, 113K bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 1908 packets, 115K bytes)
 pkts bytes target     prot opt in     out     source               destination         
 1846  111K ISTIO_OUTPUT  tcp  --  any    any     anywhere             anywhere            

Chain POSTROUTING (policy ACCEPT 1908 packets, 115K bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain ISTIO_INBOUND (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 RETURN     tcp  --  any    any     anywhere             anywhere             tcp dpt:15008
    0     0 RETURN     tcp  --  any    any     anywhere             anywhere             tcp dpt:ssh
    5   300 RETURN     tcp  --  any    any     anywhere             anywhere             tcp dpt:15090
   43  2580 RETURN     tcp  --  any    any     anywhere             anywhere             tcp dpt:15021
    1    60 RETURN     tcp  --  any    any     anywhere             anywhere             tcp dpt:15020
 1827  110K ISTIO_IN_REDIRECT  tcp  --  any    any     anywhere             anywhere            

Chain ISTIO_IN_REDIRECT (3 references)
 pkts bytes target     prot opt in     out     source               destination         
 1827  110K REDIRECT   tcp  --  any    any     anywhere             anywhere             redir ports 15006

Chain ISTIO_OUTPUT (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 RETURN     all  --  any    lo      127.0.0.6            anywhere            
    0     0 ISTIO_IN_REDIRECT  all  --  any    lo      anywhere            !localhost            owner UID match 1337
    0     0 RETURN     all  --  any    lo      anywhere             anywhere             ! owner UID match 1337
 1846  111K RETURN     all  --  any    any     anywhere             anywhere             owner UID match 1337
    0     0 ISTIO_IN_REDIRECT  all  --  any    lo      anywhere            !localhost            owner GID match 1337
    0     0 RETURN     all  --  any    lo      anywhere             anywhere             ! owner GID match 1337
    0     0 RETURN     all  --  any    any     anywhere             anywhere             owner GID match 1337
    0     0 RETURN     all  --  any    any     anywhere             localhost           
    0     0 ISTIO_REDIRECT  all  --  any    any     anywhere             10.33.0.0/16        
    0     0 ISTIO_REDIRECT  all  --  any    any     anywhere             10.32.128.0/20      
    0     0 RETURN     all  --  any    any     anywhere             anywhere            

Chain ISTIO_REDIRECT (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 REDIRECT   tcp  --  any    any     anywhere             anywhere             redir ports 15001

Istio はサイドカーとして起動するコンテナだけでなく Init Containers で ISTIO_INBOUND など Istio 固有の Chain を iptables に設定するコンテナも追加します。 これらが追加されることによってアプリケーションの変更なしに inbound, outbound の両方を Istio 経由にすることができます。

この一覧を見ても自分はなぜループしてしまうのか理解できなかったので図を書いて整理してみました。

ここからは下記のケースにおいて、他の Pod からリクエストされた時に iptables のどのルールが評価されていって最終的にアプリケーションにリクエストが到達するかを見ていきます。

  • Istio 1.9
  • Istio 1.10 (fowarded IP: 127.0.0.6)
  • Istio 1.10 (fowarded IP: 127.0.0.1)

Istio 1.9

リクエストはまず PREROUTING -> ISTIO_INBOUND -> ISTIO_IN_REDIRECT と順番に通り Envoy へとリダイレクトされます。

ISTIO_INBOUND と ISTIO_IN_REDIRECT で port が 150 からはじまるルールが記載されていますが、これらは Envoy が listen している port です。 それぞれの用途は公式ドキュメントをご覧ください。 https://istio.io/latest/docs/ops/deployment/requirements/#ports-used-by-istio

Envoy にリクエストが到達後 Envoy はそれを 127.0.0.1 へとリダイレクトします。 OUTPUT -> ISTIO_OUTPUT -> POSTROUTING とルールが評価されて最終的にアプリケーションへとリクエストが到達します。

ここで重要なのが ISTIO_OUTPUT です。 owner UID match 1337 と書かれていますが、 これは UID が 1337 の時に一致するルールになっています。 UID 1337 は istio-proxy というユーザーです。

Envoy は 127.0.0.1 へとリダイレクトするので destination は localhost のため ISTIO_OUTPUT の4つ目のルールが一致するため RETURN され、アプリケーションへとリクエストが到達します。

Istio 1.10 (fowarded IP: 127.0.0.6)

Istio 1.10 から変更になった Inbound Forwarding を見ていきます。

Envoy に到達するまでは Istio 1.9 と変わりません。

そこから先の Envoy が eth0 にリダイレクトし fowarded IP が 127.0.0.6 になる部分が Istio 1.9 と異なります。 この変更によって ISTIO_OUTPUT で一致するルールが変わります。 ルール一覧を見ていただくと唯一 source が 127.0.0.6 の時に一致するルールが存在します。 これに Istio 1.10 では該当するため RETURN され、アプリケーションへとリクエストが到達します。

Istio 1.9 の時と比べると評価されるルールは異なりますが、 1.9 と 1.10 で通る chain の数は変わらないことがわかります。

Istio 1.10 (fowarded IP: 127.0.0.1)

最後に Istio 1.10 で fowarded IP が 127.0.0.1 の場合になぜループが起きるのかを見ていきます。

今回は Envoy が 127.0.0.6 ではなく 127.0.0.1 を使うので先程一致した ISTIO_OUTPUT の1番最初のルールには一致しません。 その代わりに2番目のルールに一致します。

その結果 ISTIO_OUTPUT では RETURN されずに ISTIO_IN_REDIRECT が評価され再び Envoy へとリクエストが戻ります。 これが Design Doc の中で触れられていた iptables のループだと思われます。

まとめ

今回は Istio 1.10 から変更された Inbound Forwarding をメルカリ内で起きた問題の調査をしながら理解し最終的には iptables を確認しました。

今回のように私達 Network チームはメルカリで Istio を管理する立場として新しい機能や変更が入った時に、ただリリースノートを見て内容を把握するだけでなくサービスにどう影響があるのか想像していく必要があります。 これは簡単なことではないですが、楽しいことだと個人的には思っています!

もし間違っている箇所などを発見された方は hatappi まで DM していただけると私の勉強にもなるので、ご気軽にご連絡いただけると嬉しいです!

最後まで読んでいただきありがとうございました!