ワイルドカード宛先へのエグレス トラフィックのルーティング
ワイルドカード ドメインを含む、制限されたリモート ホスト セットにトラフィックを動的にルーティングできるエグレス ゲートウェイを設定するための汎用的なアプローチ。
メッシュ内部のアプリケーションからメッシュ外部の宛先へのトラフィックを処理するためにIstioを使用している場合、エグレス ゲートウェイの概念はおそらくご存知でしょう。エグレス ゲートウェイは、メッシュ内部のアプリケーションからメッシュ外部の場所へのトラフィックを監視および転送するために使用できます。これは、システムが制限された環境で動作しており、メッシュからパブリック インターネット上のどこにアクセスできるかを制御したい場合に役立つ機能です。
任意のワイルドカード ドメインを処理するようにエグレス ゲートウェイを設定するユースケースは、バージョン 1.13まではIstioの公式ドキュメントに含まれていましたが、その後、文書化されたソリューションが公式にサポートまたは推奨されておらず、Istioの将来のバージョンで破損する可能性があるため削除されました。それでも、古いソリューションは1.20より前のIstioバージョンでは引き続き使用できました。しかし、Istio 1.20では、このアプローチの動作に必要なEnvoyの機能がいくつか削除されました。
この投稿では、Istioのバージョンに依存しないコンポーネントとEnvoyの機能を使用して、個別のNginx SNIプロキシを必要とせずに、同様のアプローチで問題を解決し、ギャップを埋めた方法について説明します。私たちのアプローチにより、古いソリューションのユーザーは、システムがIstio 1.20の破壊的な変更に直面する前に、シームレスに構成を移行できます。
解決すべき問題
現在文書化されているエグレス ゲートウェイのユースケースは、トラフィックのターゲット(ホスト名)がVirtualService
で静的に構成されており、エグレス ゲートウェイ ポッド内のEnvoyに一致するアウトバウンド接続をTCPプロキシする場所を指示するという事実に依存しています。ルーティング条件に一致させるために、複数、さらにはワイルドカードのDNS名を使用できますが、アプリケーション リクエストで指定された正確な場所にトラフィックをルーティングすることはできません。たとえば、ターゲット*.wikipedia.org
のトラフィックを一致させることができますが、トラフィックを単一の最終ターゲット、たとえばen.wikipedia.org
に転送する必要があります。 en.wikipedia.org
と同じサーバーでホストされていない別のサービス(例:anyservice.wikipedia.org
)がある場合、そのホストへのトラフィックは失敗します。これは、HTTPペイロードのTLSハンドシェイクのターゲット ホスト名にanyservice.wikipedia.org
が含まれていても、en.wikipedia.org
サーバーがリクエストを処理できないためです。
この問題に対する高レベルの解決策は、すべての新しいゲートウェイ接続でアプリケーションTLSハンドシェイク(プレーンテキストで送信されるため、TLSターミネーションやその他のman-in-the-middle操作は不要)の元のサーバー名(SNI拡張)を検査し、ゲートウェイを出るトラフィックを動的にTCPプロキシするターゲットとして使用することです。
エグレス ゲートウェイを介してエグレス トラフィックを制限する場合、メッシュ内のクライアントのみが使用できるようにエグレス ゲートウェイをロックダウンする必要があります。これは、アプリケーション サイドカーとゲートウェイの間にISTIO_MUTUAL
(mTLSピア認証)を適用することによって実現されます。つまり、アプリケーションL7ペイロードには2つのTLSレイヤーが存在します。1つは、最終的なリモート ターゲットによって終了されるアプリケーションによって開始されたエンドツーエンドのTLSセッションであり、もう1つはIstio mTLSセッションです。
もう1つ覚えておくべきことは、アプリケーション ポッドの破損の可能性を軽減するために、アプリケーション サイドカーとゲートウェイの両方でホスト名リストのチェックを実行する必要があるということです。このようにして、侵害されたアプリケーション ポッドは、許可されたターゲットにのみアクセスでき、それ以上のアクセスはできません。
低レベルのEnvoyプログラミングによる解決
最近のEnvoyリリースには、接続ごとにSNIヘッダーを使用してアプリケーション リクエストのターゲットを決定する動的TCPフォワード プロキシ ソリューションが含まれています。Istio VirtualService
はこのようなターゲットを構成できませんが、EnvoyFilter
を使用してIstioによって生成されたルーティング命令を変更し、SNIヘッダーを使用してターゲットを決定できます。
すべてを機能させるために、まず、アウトバウンド トラフィックをリスニングするようにカスタム エグレス ゲートウェイを構成します。 DestinationRule
とVirtualService
を使用して、アプリケーション サイドカーにIstio mTLSを使用してトラフィック(選択したホスト名リストの)をそのゲートウェイにルーティングするように指示します。ゲートウェイ ポッド側では、上記のEnvoyFilter
を使用してSNIフォワーダーを構築し、内部Envoyリスナーとクラスターを導入してすべてを機能させます。最後に、ゲートウェイで実装されたTCPプロキシの内部宛先を内部SNIフォワーダーにパッチします。
エンドツーエンドのリクエスト フローを次の図に示します。
この図は、SNIをルーティング キーとして使用したen.wikipedia.org
へのエグレスHTTPSリクエストを示しています。
アプリケーション コンテナ
アプリケーションは、最終的な宛先へのHTTP/TLS接続を開始します。宛先のホスト名をSNIヘッダーに配置します。このTLSセッションはメッシュ内で復号化されません。SNIヘッダーのみが検査されます(クリアテキストであるため)。
サイドカー プロキシ
サイドカーは、アプリケーションによって開始されたTLSセッションからのSNIヘッダーで一致するホスト名へのトラフィックをインターセプトします。 VirtualServiceに基づいて、トラフィックはエグレス ゲートウェイにルーティングされ、元のトラフィックもIstio mTLSにラップされます。外部TLSセッションのSNIヘッダーにはゲートウェイ サービス アドレスが含まれています。
メッシュ リスナー
ゲートウェイには、Istio mTLSトラフィックを相互認証する専用のリスナーが作成されます。外部Istio mTLSターミネーション後、内部TLSトラフィックをTCPプロキシを介して同じゲートウェイ内の他の(内部)リスナーに無条件で送信します。
SNIフォワーダー
SNIフォワーダーを備えた別のリスナーは、元のTLSセッションの新しいTLSヘッダー検査を実行します。内部SNIホスト名が許可されたドメイン名(ワイルドカードを含む)と一致する場合、接続ごとにヘッダーから読み取られた宛先にトラフィックをTCPプロキシします。このリスナーはEnvoyの内部にあるため(内部SNI値を確認するためにトラフィック処理を再開できます)、メッシュ内外のポッドは直接接続できません。このリスナーは、EnvoyFilterを介して100%手動で構成されます。
サンプルをデプロイする
サンプル構成をデプロイするには、まずistio-egress
名前空間を作成し、次のYAMLを使用してエグレス ゲートウェイ、いくつかのRBAC、およびそのService
をデプロイします。この例では、ゲートウェイ インジェクション方式を使用してゲートウェイを作成します。インストール方法によっては、異なる方法でデプロイすることができます(たとえば、IstioOperator
CRを使用するか、Helmを使用する)。
# New k8s cluster service to put egressgateway into the Service Registry,
# so application sidecars can route traffic towards it within the mesh.
apiVersion: v1
kind: Service
metadata:
name: egressgateway
namespace: istio-egress
spec:
type: ClusterIP
selector:
istio: egressgateway
ports:
- port: 443
name: tls-egress
targetPort: 8443
---
# Gateway deployment with injection method
apiVersion: apps/v1
kind: Deployment
metadata:
name: istio-egressgateway
namespace: istio-egress
spec:
selector:
matchLabels:
istio: egressgateway
template:
metadata:
annotations:
inject.istio.io/templates: gateway
labels:
istio: egressgateway
sidecar.istio.io/inject: "true"
spec:
containers:
- name: istio-proxy
image: auto # The image will automatically update each time the pod starts.
securityContext:
capabilities:
drop:
- ALL
runAsUser: 1337
runAsGroup: 1337
---
# Set up roles to allow reading credentials for TLS
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: istio-egressgateway-sds
namespace: istio-egress
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "watch", "list"]
- apiGroups:
- security.openshift.io
resourceNames:
- anyuid
resources:
- securitycontextconstraints
verbs:
- use
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: istio-egressgateway-sds
namespace: istio-egress
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: istio-egressgateway-sds
subjects:
- kind: ServiceAccount
name: default
ゲートウェイ ポッドがistio-egress
名前空間で起動して実行されていることを確認し、次のYAMLを適用してゲートウェイ ルーティングを構成します。
# Define a new listener that enforces Istio mTLS on inbound connections.
# This is where sidecar will route the application traffic, wrapped into
# Istio mTLS.
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
name: egressgateway
namespace: istio-system
spec:
selector:
istio: egressgateway
servers:
- port:
number: 8443
name: tls-egress
protocol: TLS
hosts:
- "*"
tls:
mode: ISTIO_MUTUAL
---
# VirtualService that will instruct sidecars in the mesh to route the outgoing
# traffic to the egress gateway Service if the SNI target hostname matches
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: direct-wildcard-through-egress-gateway
namespace: istio-system
spec:
hosts:
- "*.wikipedia.org"
gateways:
- mesh
- egressgateway
tls:
- match:
- gateways:
- mesh
port: 443
sniHosts:
- "*.wikipedia.org"
route:
- destination:
host: egressgateway.istio-egress.svc.cluster.local
subset: wildcard
# Dummy routing instruction. If omitted, no reference will point to the Gateway
# definition, and istiod will optimise the whole new listener out.
tcp:
- match:
- gateways:
- egressgateway
port: 8443
route:
- destination:
host: "dummy.local"
weight: 100
---
# Instruct sidecars to use Istio mTLS when sending traffic to the egress gateway
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
name: egressgateway
namespace: istio-system
spec:
host: egressgateway.istio-egress.svc.cluster.local
subsets:
- name: wildcard
trafficPolicy:
tls:
mode: ISTIO_MUTUAL
---
# Put the remote targets into the Service Registry
apiVersion: networking.istio.io/v1alpha3
kind: ServiceEntry
metadata:
name: wildcard
namespace: istio-system
spec:
hosts:
- "*.wikipedia.org"
ports:
- number: 443
name: tls
protocol: TLS
---
# Access logging for the gateway
apiVersion: telemetry.istio.io/v1alpha1
kind: Telemetry
metadata:
name: mesh-default
namespace: istio-system
spec:
accessLogging:
- providers:
- name: envoy
---
# And finally, the configuration of the SNI forwarder,
# it's internal listener, and the patch to the original Gateway
# listener to route everything into the SNI forwarder.
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: sni-magic
namespace: istio-system
spec:
configPatches:
- applyTo: CLUSTER
match:
context: GATEWAY
patch:
operation: ADD
value:
name: sni_cluster
load_assignment:
cluster_name: sni_cluster
endpoints:
- lb_endpoints:
- endpoint:
address:
envoy_internal_address:
server_listener_name: sni_listener
- applyTo: CLUSTER
match:
context: GATEWAY
patch:
operation: ADD
value:
name: dynamic_forward_proxy_cluster
lb_policy: CLUSTER_PROVIDED
cluster_type:
name: envoy.clusters.dynamic_forward_proxy
typed_config:
"@type": type.googleapis.com/envoy.extensions.clusters.dynamic_forward_proxy.v3.ClusterConfig
dns_cache_config:
name: dynamic_forward_proxy_cache_config
dns_lookup_family: V4_ONLY
- applyTo: LISTENER
match:
context: GATEWAY
patch:
operation: ADD
value:
name: sni_listener
internal_listener: {}
listener_filters:
- name: envoy.filters.listener.tls_inspector
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector
filter_chains:
- filter_chain_match:
server_names:
- "*.wikipedia.org"
filters:
- name: envoy.filters.network.sni_dynamic_forward_proxy
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.sni_dynamic_forward_proxy.v3.FilterConfig
port_value: 443
dns_cache_config:
name: dynamic_forward_proxy_cache_config
dns_lookup_family: V4_ONLY
- name: envoy.tcp_proxy
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
stat_prefix: tcp
cluster: dynamic_forward_proxy_cluster
access_log:
- name: envoy.access_loggers.file
typed_config:
"@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
path: "/dev/stdout"
log_format:
text_format_source:
inline_string: '[%START_TIME%] "%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%
%PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %RESPONSE_CODE_DETAILS% %CONNECTION_TERMINATION_DETAILS%
"%UPSTREAM_TRANSPORT_FAILURE_REASON%" %BYTES_RECEIVED% %BYTES_SENT% %DURATION%
%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)% "%REQ(X-FORWARDED-FOR)%" "%REQ(USER-AGENT)%"
"%REQ(X-REQUEST-ID)%" "%REQ(:AUTHORITY)%" "%UPSTREAM_HOST%" %UPSTREAM_CLUSTER%
%UPSTREAM_LOCAL_ADDRESS% %DOWNSTREAM_LOCAL_ADDRESS% %DOWNSTREAM_REMOTE_ADDRESS%
%REQUESTED_SERVER_NAME% %ROUTE_NAME%
'
- applyTo: NETWORK_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: "envoy.filters.network.tcp_proxy"
patch:
operation: MERGE
value:
name: envoy.tcp_proxy
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
stat_prefix: tcp
cluster: sni_cluster
エラーや警告がないか、istiod
とゲートウェイのログを確認してください。すべてがうまくいけば、メッシュ サイドカーは*.wikipedia.org
リクエストをゲートウェイ ポッドにルーティングし、ゲートウェイ ポッドはそれらをアプリケーション リクエストで指定された正確なリモート ホストに転送します。
試してみる
他のIstioエグレスの例に従って、リクエストを送信するためのテスト ソースとしてsleepポッドを使用します。デフォルトの名前空間で自動サイドカー インジェクションが有効になっていると仮定して、次のコマンドを使用してテスト アプリをデプロイします。
$ kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.24/samples/sleep/sleep.yaml
sleepポッドとゲートウェイ ポッドを取得する
$ export SOURCE_POD=$(kubectl get pod -l app=sleep -o jsonpath={.items..metadata.name})
$ export GATEWAY_POD=$(kubectl get pod -n istio-egress -l istio=egressgateway -o jsonpath={.items..metadata.name})
次のコマンドを実行して、wikipedia.org
サイトに接続できることを確認します。
$ kubectl exec "$SOURCE_POD" -c sleep -- sh -c 'curl -s https://en.wikipedia.org/wiki/Main_Page | grep -o "<title>.*</title>"; curl -s https://de.wikipedia.org/wiki/Wikipedia:Hauptseite | grep -o "<title>.*</title>"'
<title>Wikipedia, the free encyclopedia</title>
<title>Wikipedia – Die freie Enzyklopädie</title>
英語とドイツ語の両方のwikipedia.org
サブドメインにアクセスできました。素晴らしい!
通常、本番環境では、エグレス ゲートウェイを介してリダイレクトするように構成されていない外部リクエストをブロックしますが、テスト環境ではそうしなかったため、比較のために別の外部サイトにアクセスしてみましょう。
$ kubectl exec "$SOURCE_POD" -c sleep -- sh -c 'curl -s https://cloud.ibm.com/login | grep -o "<title>.*</title>"'
<title>IBM Cloud</title>
グローバルにアクセス ロギングが有効になっているため(マニフェストのTelemetry
CRを使用)、ログを調べて、上記の要求がプロキシによってどのように処理されたかを確認できます。
まず、ゲートウェイ ログを確認します。
$ kubectl logs -n istio-egress $GATEWAY_POD
[...]
[2023-11-24T13:21:52.798Z] "- - -" 0 - - - "-" 813 111152 55 - "-" "-" "-" "-" "185.15.59.224:443" dynamic_forward_proxy_cluster 172.17.5.170:48262 envoy://sni_listener/ envoy://internal_client_address/ en.wikipedia.org -
[2023-11-24T13:21:52.798Z] "- - -" 0 - - - "-" 1531 111950 55 - "-" "-" "-" "-" "envoy://sni_listener/" sni_cluster envoy://internal_client_address/ 172.17.5.170:8443 172.17.34.35:55102 outbound_.443_.wildcard_.egressgateway.istio-egress.svc.cluster.local -
[2023-11-24T13:21:53.000Z] "- - -" 0 - - - "-" 821 92848 49 - "-" "-" "-" "-" "185.15.59.224:443" dynamic_forward_proxy_cluster 172.17.5.170:48278 envoy://sni_listener/ envoy://internal_client_address/ de.wikipedia.org -
[2023-11-24T13:21:53.000Z] "- - -" 0 - - - "-" 1539 93646 50 - "-" "-" "-" "-" "envoy://sni_listener/" sni_cluster envoy://internal_client_address/ 172.17.5.170:8443 172.17.34.35:55108 outbound_.443_.wildcard_.egressgateway.istio-egress.svc.cluster.local -
3つのcurlリクエストのうち2つを表す4つのログ エントリがあります。各ペアは、単一のリクエストがenvoyトラフィック処理パイプラインをどのように流れるかを示しています。これらは逆の順序で出力されますが、2行目と4行目を見ると、リクエストがゲートウェイ サービスに到着し、内部sni_cluster
ターゲットを通過したことがわかります。1行目と3行目を見ると、最終的なターゲットが内部SNIヘッダー、つまりアプリケーションによって設定されたターゲット ホストから決定されていることがわかります。リクエストはdynamic_forward_proxy_cluster
に転送され、最終的にEnvoyからリモート ターゲットにリクエストを送信します。
素晴らしいですが、IBM Cloudへの3番目のリクエストはどこにありますか?サイドカー ログを確認してみましょう。
$ kubectl logs $SOURCE_POD -c istio-proxy
[...]
[2023-11-24T13:21:52.793Z] "- - -" 0 - - - "-" 813 111152 61 - "-" "-" "-" "-" "172.17.5.170:8443" outbound|443|wildcard|egressgateway.istio-egress.svc.cluster.local 172.17.34.35:55102 208.80.153.224:443 172.17.34.35:37020 en.wikipedia.org -
[2023-11-24T13:21:52.994Z] "- - -" 0 - - - "-" 821 92848 55 - "-" "-" "-" "-" "172.17.5.170:8443" outbound|443|wildcard|egressgateway.istio-egress.svc.cluster.local 172.17.34.35:55108 208.80.153.224:443 172.17.34.35:37030 de.wikipedia.org -
[2023-11-24T13:21:55.197Z] "- - -" 0 - - - "-" 805 15199 158 - "-" "-" "-" "-" "104.102.54.251:443" PassthroughCluster 172.17.34.35:45584 104.102.54.251:443 172.17.34.35:45582 cloud.ibm.com -
ご覧のとおり、Wikipediaのリクエストはゲートウェイを介して送信されましたが、IBM Cloudへのリクエストは、PassthroughCluster
ログに示されているように、アプリケーション ポッドからインターネットに直接送信されました。
結論
任意およびワイルドカードのドメイン名をサポートするエグレス ゲートウェイを使用して、エグレスHTTPS/TLSトラフィックの制御されたルーティングを実装しました。本番環境では、この投稿に示されている例は、HA要件をサポートするように拡張されます(たとえば、ゾーン対応ゲートウェイDeployment
を追加するなど)。また、アプリケーションの直接外部ネットワーク アクセスを制限して、アプリケーションがゲートウェイを介してのみパブリック ネットワークにアクセスできるようにします。これは、定義済みのリモート ホスト名セットに制限されます。
このソリューションは簡単に拡張できます。構成に複数のドメイン名を含めることができ、ロールアウトするとすぐに許可リストに登録されます。ドメインごとのVirtualService
やその他のルーティングの詳細を構成する必要はありません。ただし、ドメイン名は構成の複数の場所にリストされているため注意が必要です。CI/CDにツール(例:Kustomize)を使用する場合は、ドメイン名リストを1か所に抽出し、そこから必要な構成リソースにレンダリングするのが最善です。
以上です!これがお役に立てば幸いです。以前のNginxベースのソリューションの既存ユーザーであれば、現在の設定を中断するIstio 1.20にアップグレードする前に、このアプローチに移行できます。
SNIルーティングをお楽しみください!