將出口流量路由至萬用字元目的地

一種通用方法,用於設定可以將流量動態路由到一組受限目標遠端主機(包括萬用字元網域)的出口閘道。

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 中靜態配置的事實,告訴出口閘道 Pod 中的 Envoy 將匹配的輸出連線 TCP 代理到哪裡。您可以使用多個,甚至是萬用字元的 DNS 名稱來匹配路由條件,但您無法將流量路由到應用程式請求中指定的確切位置。例如,您可以匹配目標為 *.wikipedia.org 的流量,但接著需要將流量轉發到單一最終目標,例如,en.wikipedia.org。如果有另一個服務,例如,anyservice.wikipedia.org,它與 en.wikipedia.org 不在相同的伺服器上託管,則到該主機的流量將會失敗。這是因為,即使 HTTP 酬載的 TLS 交握中的目標主機名稱包含 anyservice.wikipedia.orgen.wikipedia.org 伺服器也將無法提供請求。

此問題的高階解決方案是在每個新的閘道連線中檢查應用程式 TLS 交握中的原始伺服器名稱(SNI 擴充),並使用它作為目標來動態 TCP 代理離開閘道的流量(此名稱以純文字傳送,因此不需要 TLS 終止或其他中間人操作)。

當透過出口閘道限制出口流量時,我們需要鎖定出口閘道,使其只能被網格內的用戶端使用。這是透過在應用程式 Sidecar 和閘道之間強制執行 ISTIO_MUTUAL(mTLS 對等驗證)來實現的。這表示在應用程式 L7 酬載上將有兩層 TLS。一層是由最終遠端目標終止的應用程式發起的端對端 TLS 會話,另一層是 Istio mTLS 會話。

另一個需要記住的事情是,為了減輕任何潛在的應用程式 Pod 損壞,應用程式 Sidecar 和閘道都應執行主機名稱清單檢查。這樣,任何受損的應用程式 Pod 仍然只能存取允許的目標,而不能存取其他任何內容。

低階 Envoy 程式設計來救援

最近的 Envoy 版本包含一種動態 TCP 轉發代理解決方案,該解決方案使用每個連線的 SNI 標頭來決定應用程式請求的目標。雖然 Istio VirtualService 無法配置這樣的目標,但我們可以使用 EnvoyFilter 來更改 Istio 產生的路由指示,以便使用 SNI 標頭來決定目標。

為了讓一切正常運作,我們首先配置一個自訂的出口閘道來監聽輸出流量。使用 DestinationRuleVirtualService,我們指示應用程式 Sidecar 將流量(針對選定的主機名稱清單)路由到該閘道,並使用 Istio mTLS。在閘道 Pod 端,我們使用上面提到的 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

確認閘道 Pod 在 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 和閘道日誌是否有任何錯誤或警告。如果一切順利,您的網格 Sidecar 現在會將 *.wikipedia.org 請求路由到您的閘道 Pod,而閘道 Pod 接著會將它們轉發到應用程式請求中指定的確切遠端主機。

試試看

遵循其他 Istio 出口範例,我們將使用 sleep Pod 作為傳送請求的測試來源。假設在您的預設命名空間中啟用自動 Sidecar 注入,請使用以下命令部署測試應用程式

$ kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.24/samples/sleep/sleep.yaml

取得您的 sleep 和閘道 Pod

$ 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 -

有四個日誌項目,代表我們的三個 curl 請求中的兩個。每一對都會顯示單個請求如何流經 Envoy 流量處理管道。它們以相反的順序印出,但我們可以看到第 2 行和第 4 行顯示請求到達閘道服務,並通過內部 sni_cluster 目標。第 1 行和第 3 行顯示最終目標是從內部 SNI 標頭決定的,也就是應用程式設定的目標主機。請求被轉發到 dynamic_forward_proxy_cluster,該叢集最終將請求從 Envoy 發送到遠端目標。

太棒了,但是對 IBM Cloud 的第三個請求在哪裡?讓我們檢查 Sidecar 日誌

$ 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 -

如您所見,維基百科請求透過閘道傳送,而對 IBM Cloud 的請求則直接從應用程式 Pod 傳送到網際網路,如 PassthroughCluster 日誌所示。

結論

我們使用出口閘道實作了對出口 HTTPS/TLS 流量的受控路由,支援任意和萬用字元網域名稱。在生產環境中,這篇文章中顯示的範例將會擴充以支援 HA 需求(例如,新增區域感知閘道 Deployment 等),並限制應用程式的直接外部網路存取,以便應用程式只能透過閘道存取公共網路,而該閘道僅限於一組預定義的遠端主機名稱。

此解決方案可輕鬆擴展。您可以在設定中包含多個網域名稱,它們會在您部署後立即被加入允許清單!無需為每個網域設定 VirtualService 或其他路由細節。但請注意,網域名稱會列在設定中的多個位置。如果您使用 CI/CD 工具(例如 Kustomize),最好將網域名稱列表提取到一個單一位置,您可以從該位置渲染到所需的設定資源。

就是這樣!希望這對您有所幫助。如果您是先前基於 Nginx 解決方案的現有使用者,您現在可以遷移到此方法,然後再升級到 Istio 1.20,否則會中斷您目前的設定。

SNI 路由愉快!

參考資料

分享此文章