ワイルドカード宛先へのエグレス トラフィックのルーティング

ワイルドカード ドメインを含む、制限されたリモート ホスト セットにトラフィックを動的にルーティングできるエグレス ゲートウェイを設定するための汎用的なアプローチ。

2023年12月1日 | 執筆者: Gergő Huszty - IBM

メッシュ内部のアプリケーションからメッシュ外部の宛先へのトラフィックを処理するために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ヘッダーを使用してターゲットを決定できます。

すべてを機能させるために、まず、アウトバウンド トラフィックをリスニングするようにカスタム エグレス ゲートウェイを構成します。 DestinationRuleVirtualServiceを使用して、アプリケーション サイドカーにIstio mTLSを使用してトラフィック(選択したホスト名リストの)をそのゲートウェイにルーティングするように指示します。ゲートウェイ ポッド側では、上記のEnvoyFilterを使用してSNIフォワーダーを構築し、内部Envoyリスナーとクラスターを導入してすべてを機能させます。最後に、ゲートウェイで実装されたTCPプロキシの内部宛先を内部SNIフォワーダーにパッチします。

エンドツーエンドのリクエスト フローを次の図に示します。

Egress SNI routing with arbitrary domain names
任意のドメイン名を使用したエグレスSNIルーティング

この図は、SNIをルーティング キーとして使用したen.wikipedia.orgへのエグレスHTTPSリクエストを示しています。

サンプルをデプロイする

サンプル構成をデプロイするには、まず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ルーティングをお楽しみください!

参考文献

この記事をシェアする