将出口流量路由到通配符目标
一种通用的方法,用于设置出口网关,可以动态地将流量路由到一组受限的目标远程主机,包括通配符域名。
如果您正在使用 Istio 处理应用程序发起的到网格外部目标的流量,您可能熟悉出口网关的概念。出口网关可用于监控和转发来自网格内部应用程序到网格外部位置的流量。如果您的系统在受限环境中运行并且您希望控制从您的网格到公共互联网可以访问的内容,这是一个有用的功能。
将出口网关配置为处理任意通配符域名的用例已包含在Istio 官方文档中,直到 1.13 版本,但随后被删除,因为记录的解决方案未获得官方支持或推荐,并且在 Istio 的未来版本中可能会出现故障。尽管如此,旧解决方案在 1.20 之前的 Istio 版本中仍然可用。但是,Istio 1.20 删除了一些 Envoy 功能,这些功能是该方法工作所必需的。
这篇文章试图描述我们如何解决问题并使用与 Istio 版本无关的组件和 Envoy 功能填补空白,但无需单独的 Nginx SNI 代理。我们的方法允许旧解决方案的用户在他们的系统面临 Istio 1.20 中的重大更改之前无缝迁移配置。
待解决的问题
当前记录的出口网关用例依赖于这样一个事实,即流量的目标(主机名)是在 VirtualService
中静态配置的,告诉出口网关 Pod 中的 Envoy 将匹配的出站连接转发到哪里。您可以使用多个甚至通配符 DNS 名称来匹配路由标准,但您无法将流量路由到应用程序请求中指定的精确位置。例如,您可以匹配目标 *.wikipedia.org
的流量,但随后您需要将流量转发到单个最终目标,例如 en.wikipedia.org
。如果存在另一个服务,例如 anyservice.wikipedia.org
,它不是与 en.wikipedia.org
相同的服务器托管的,则到该主机的流量将失败。这是因为,即使 HTTP 负载的 TLS 握手中的目标主机名包含 anyservice.wikipedia.org
,en.wikipedia.org
服务器也将无法提供请求。
此问题的解决方案在高级别上是检查应用程序 TLS 握手中的原始服务器名称(SNI 扩展)(以明文发送,因此不需要 TLS 终止或其他中间人操作)在每个新的网关连接中,并将其用作动态 TCP 代理离开网关的流量的目标。
在通过出口网关限制出口流量时,我们需要锁定出口网关,以便它们只能被网格内的客户端使用。这是通过在应用程序 sidecar 和网关之间强制执行 ISTIO_MUTUAL
(mTLS 对等身份验证)来实现的。这意味着应用程序 L7 负载上将有两层 TLS。一个是应用程序发起的端到端 TLS 会话,由最终远程目标终止,另一个是 Istio mTLS 会话。
另一件需要注意的事情是,为了减轻任何潜在的应用程序 Pod 损坏,应用程序 sidecar 和网关都应执行主机名列表检查。这样,任何被破坏的应用程序 Pod 仍然只能访问允许的目标,而不会访问更多内容。
低级 Envoy 编程来救援
最近的 Envoy 版本包含一个动态 TCP 正向代理解决方案,该解决方案使用每个连接的基础上的 SNI 标头来确定应用程序请求的目标。虽然 Istio VirtualService
无法配置这样的目标,但我们可以使用 EnvoyFilter
来更改 Istio 生成的路由指令,以便使用 SNI 标头来确定目标。
为了使这一切正常工作,我们首先配置一个自定义出口网关来监听出站流量。使用 DestinationRule
和 VirtualService
,我们指示应用程序 sidecar 将流量(对于选定的主机名列表)路由到该网关,使用 Istio mTLS。在网关 Pod 端,我们使用上面提到的 EnvoyFilter
构建 SNI 转发器,引入内部 Envoy 侦听器和集群以使其全部工作。最后,我们将网关实现的 TCP 代理的内部目标修补到内部 SNI 转发器。
端到端请求流程如下图所示
此图显示了使用 SNI 作为路由密钥对 en.wikipedia.org
的出口 HTTPS 请求。
应用程序容器
应用程序发起 HTTP/TLS 连接到最终目标。将目标的主机名放入 SNI 标头。此 TLS 会话在网格内部未解密。仅检查 SNI 标头(因为它以明文形式存在)。
Sidecar 代理
Sidecar 从应用程序发起的 TLS 会话中拦截 SNI 标头中匹配主机名的流量。根据 VirtualService,流量被路由到出口网关,同时也将原始流量包装到 Istio mTLS 中。外部 TLS 会话在 SNI 标头中具有网关服务地址。
网格侦听器
在网关中创建了一个专用的侦听器,用于相互认证 Istio mTLS 流量。在外部 Istio mTLS 终止后,它无条件地将内部 TLS 流量与 TCP 代理发送到同一网关中的另一个(内部)侦听器。
SNI 转发器
另一个带有 SNI 转发器的侦听器对原始 TLS 会话执行新的 TLS 标头检查。如果内部 SNI 主机名与允许的域名(包括通配符)匹配,它会将流量 TCP 代理到目标,从每个连接的标头读取。此侦听器在 Envoy 内部(允许它重新启动流量处理以查看内部 SNI 值),因此没有 Pod(网格内部或外部)可以直接连接到它。此侦听器是通过 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
验证网关 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 -
如您所见,Wikipedia 请求通过网关发送,而对 IBM Cloud 的请求直接从应用程序 Pod 发送到互联网,如 PassthroughCluster
日志所示。
结论
我们使用出口网关实现了对出口 HTTPS/TLS 流量的受控路由,支持任意和通配符域名。在生产环境中,本文中所示的示例将扩展为支持 HA 要求(例如,添加区域感知网关 Deployment
等)并限制应用程序的直接外部网络访问,以便应用程序只能通过网关访问公共网络,该网关仅限于一组预定义的远程主机名。
该解决方案易于扩展。您可以在配置中包含多个域名,并且一旦您推出它们,它们就会被列入允许列表!无需配置每个域名的 VirtualService
或其他路由详细信息。但是,请注意,域名在配置中的多个位置列出。如果您使用 CI/CD 工具(例如 Kustomize),最好将域名列表提取到一个位置,从中您可以渲染到所需的配置资源。
就是这样!希望这对您有所帮助。如果您是先前基于 Nginx 解决方案的现有用户,您现在可以在升级到 Istio 1.20 之前迁移到此方法,否则会中断您当前的设置。
SNI 路由愉快!