在 GKE 上构建基于服务网格与 Loki 的 mTLS 握手失败无侵入审计管道


在生产环境中,一个分布式系统中最难以排查的问题,往往不是代码逻辑的 bug,而是网络层面的幽灵。当一个服务调用在日志中简单地留下一句 connection reset by peerupstream request timeout 时,运维和开发团队的噩梦就开始了。在强制实施了 mTLS (Mutual TLS) 的服务网格架构中,这个问题被放大了十倍。连接失败的原因可能是证书过期、身份验证策略错误、命名空间信任域不匹配,甚至是底层的 SPIFFE ID 分发延迟。传统的 Metrics 和 Tracing 在这里几乎无能为力,因为它们通常记录的是成功的请求,而失败的 TLS 握手发生在应用层流量建立之前,它们根本不会出现在应用日志或分布式追踪系统中。

我们需要的是一个专门的审计管道,能够精确捕获每一次 mTLS 握手失败的上下文:谁在尝试连接谁?失败的原因是什么?它必须是无侵入的,不能要求业务团队为了可观测性而修改他们的应用代码。同时,它必须能处理整个集群每日数以亿计的连接请求所产生的海量数据,且成本可控。

最初的构想是利用某种网络探针或 eBPF 程序来监控内核的网络事件,但这会引入额外的复杂性和维护成本。一个更务实的方案是,利用服务网格自身的能力。我们选择 Istio 作为服务网格,因为它底层的 Envoy 代理在处理 TLS 握手时,会在其访问日志中留下蛛丝马迹。我们的核心任务,就是将这些分散在成百上千个 Sidecar 代理中的、格式不统一的日志,转化为一个结构化的、可查询的、能告警的审计数据流。

技术选型决策

  1. GKE (Google Kubernetes Engine): 作为一个成熟的托管 Kubernetes 平台,GKE 提供了稳定的控制平面、与 GCP IAM 的深度集成以及对 Istio(通过 Anthos Service Mesh 或开源 Istio)的良好支持。我们选择 GKE Autopilot 模式以简化节点管理,让团队能专注于上层架构。

  2. Istio: 相比 Linkerd,Istio 提供了更强大的流量控制和可观测性配置能力。特别是其 EnvoyFilter 资源,允许我们直接对数据平面的 Envoy 代理进行深度定制,这对于捕获我们需要的特定 TLS 失败信息至关重要。这是本次方案成功的技术关键。

  3. Loki: 传统的日志方案如 Elasticsearch 功能强大,但其倒排索引的机制对于处理高基数的、主要用于事后审计的日志数据来说,成本过高。Loki 的设计哲学——“像 Prometheus 一样索引日志”——完美契合我们的场景。它只对元数据(我们称之为标签,如 source_app, destination_app, failure_reason)建立索引,而将原始日志内容压缩存储。这使得在大规模部署时,Loki 的存储和计算成本远低于 ELK Stack。

步骤化实现:从 Envoy 日志到可行动的洞察

我们的实现路径分为四步:首先,在 GKE 集群中复现一个典型的 mTLS 握手失败场景;其次,通过 EnvoyFilter 定制 Envoy 的访问日志格式,使其暴露失败的详细信息;再次,部署并配置 Promtail 来采集这些日志并发送给 Loki;最后,使用 LogQL 查询并可视化这些审计数据。

1. 场景构建:模拟 mTLS 握手失败

我们需要一个受控环境来触发并观察 mTLS 握手失败。假设我们有两个服务:order-servicepayment-servicepayment-service 的服务网格策略要求所有入站流量必须使用 mTLS 加密(STRICT 模式)。

首先,部署 payment-service,并为其配置一个严格的 mTLS 策略。

# payment-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: payment-service
  labels:
    app: payment-service
spec:
  ports:
  - port: 8080
    name: http
  selector:
    app: payment-service
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: payment-service
  template:
    metadata:
      labels:
        app: payment-service
    spec:
      containers:
      - name: payment
        image: nginxdemos/hello:plain-text # 一个简单的 HTTP 服务
        ports:
        - containerPort: 8080
---
# istio-peer-authentication.yaml
apiVersion: "security.istio.io/v1beta1"
kind: "PeerAuthentication"
metadata:
  name: "payment-service-strict-mtls"
  namespace: "default"
spec:
  selector:
    matchLabels:
      app: payment-service
  mtls:
    mode: STRICT

应用以上配置后,任何没有携带有效 Istio 客户端证书的请求都将被 payment-service 的 Sidecar 拒绝。

现在,我们部署一个没有注入 Istio Sidecar 的 “恶意” 或 “错误配置” 的客户端 legacy-client,尝试调用 payment-service

# legacy-client.yaml
apiVersion: v1
kind: Pod
metadata:
  name: legacy-client
  labels:
    app: legacy-client
spec:
  containers:
  - name: client
    image: curlimages/curl
    # 这个 Pod 会在启动后尝试访问 payment-service 然后退出
    command: ["/bin/sh", "-c"]
    args:
    - |
      while true; do
        echo "Attempting to curl payment-service...";
        curl -s -o /dev/null -w "%{http_code}\n" http://payment-service:8080/ || echo "Curl command failed";
        sleep 5;
      done

部署这个 Pod 后,curl 命令会持续失败。如果我们检查 payment-service 的 Envoy 代理日志 (kubectl logs -l app=payment-service -c istio-proxy),会看到类似这样的默认日志:

[2023-10-27T10:30:00.000Z] "- - -" 0 - - - "-" 0 0 0 - "-" "-" "-" "-" "-" - - 10.0.1.2:8080 10.0.1.5:45678 - -

这行日志毫无价值。它甚至没有记录请求的来源。这里的坑在于,TLS 握手失败发生在 HTTP 请求被解析之前,所以很多 HTTP 相关的字段都是空的。我们需要的信息隐藏在 Envoy 的连接属性和响应标志中。

2. EnvoyFilter:解锁关键审计信息

为了获取有用的信息,我们必须修改 Envoy 的访问日志格式。我们将创建一个 EnvoyFilter 来应用到 payment-service 的入站监听器上。这个 filter 会利用 Envoy 的 Access Log Command Operators 来提取我们需要的数据。

一个常见的错误是尝试在 HTTP_FILTER 中修改日志,但 mTLS 握手失败发生在更早的 TCP_FILTER 阶段。因此,我们的 EnvoyFilter 必须作用于监听器本身。

# envoyfilter-mtls-audit-log.yaml
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: mtls-failure-log-format
  namespace: istio-system # 应用于整个网格
spec:
  workloadSelector: {} # 选择网格中的所有 workload
  configPatches:
    - applyTo: NETWORK_FILTER # 作用于网络过滤器链, 在 HTTP 解析之前
      match:
        context: SIDECAR_INBOUND
        listener:
          filterChain:
            filter:
              name: "envoy.filters.network.tcp_proxy"
      patch:
        operation: MERGE
        value:
          typed_config:
            "@type": "type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy"
            access_log:
              - name: envoy.access_loggers.stdout
                typed_config:
                  "@type": "type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog"
                  log_format:
                    json_format:
                      # 关键字段定义
                      # response_flags: UT 表示上游传输失败, 这是 mTLS 失败的典型标志
                      response_flags: "%RESPONSE_FLAGS%"
                      # connection_termination_details: 提供 TLS 失败的更具体原因
                      connection_termination_details: "%CONNECTION_TERMINATION_DETAILS%"
                      # downstream_remote_address: 连接来源 IP, 用于追溯
                      downstream_remote_address: "%DOWNSTREAM_REMOTE_ADDRESS%"
                      # downstream_local_address: 被访问的服务 IP
                      downstream_local_address: "%DOWNSTREAM_LOCAL_ADDRESS%"
                      # upstream_host: 目标服务
                      upstream_host: "%UPSTREAM_HOST%"
                      # requested_server_name: SNI, 对调试 TLS 很重要
                      requested_server_name: "%REQUESTED_SERVER_NAME%"
                      # downstream_tls_cipher: TLS 密码套件
                      downstream_tls_cipher: "%DOWNSTREAM_TLS_CIPHER%"
                      # downstream_tls_version: TLS 版本
                      downstream_tls_version: "%DOWNSTREAM_TLS_VERSION%"
                      # downstream_peer_uri_san: 对端身份的 SAN
                      downstream_peer_uri_san: "%DOWNSTREAM_PEER_URI_SAN%"
                      # downstream_local_uri_san: 本地身份的 SAN
                      downstream_local_uri_san: "%DOWNSTREAM_LOCAL_URI_SAN%"
                      # downstream_peer_subject: 对端证书的主题
                      downstream_peer_subject: "%DOWNSTREAM_PEER_SUBJECT%"

应用这个 EnvoyFilter 后,我们再次查看 payment-serviceistio-proxy 日志。当 legacy-client 尝试连接时,会看到结构化的 JSON 日志:

{
  "response_flags": "UT",
  "connection_termination_details": "tls_protocol_error",
  "downstream_remote_address": "10.0.1.20:54321",
  "downstream_local_address": "10.0.1.15:8080",
  "upstream_host": null,
  "requested_server_name": "payment-service",
  "downstream_tls_cipher": null,
  "downstream_tls_version": null,
  "downstream_peer_uri_san": null,
  "downstream_local_uri_san": "spiffe://cluster.local/ns/default/sa/payment-service-sa",
  "downstream_peer_subject": null
}

这才是我们需要的!"response_flags": "UT" 明确表示上游传输失败。"connection_termination_details": "tls_protocol_error" 提供了更具体的线索。最重要的是,downstream_peer_uri_san 为空,这直接指出了失败的原因:对端没有提供有效的 SPIFFE 身份。我们已经成功捕获了审计事件。

3. Promtail & Loki: 构建日志采集管道

现在我们需要将这些 JSON 日志集中收集起来。我们将使用 Promtail(Loki 的采集代理)作为 DaemonSet 部署在 GKE 集群中。

# promtail-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: promtail-config
  namespace: monitoring
data:
  promtail.yaml: |
    server:
      http_listen_port: 9080
      grpc_listen_port: 0

    positions:
      filename: /tmp/positions.yaml

    clients:
      - url: http://loki-stack.monitoring.svc.cluster.local:3100/loki/api/v1/push

    scrape_configs:
    - job_name: kubernetes-pods-istio-proxy
      kubernetes_sd_configs:
        - role: pod
      relabel_configs:
        # 只选择 istio-proxy 容器的日志
        - source_labels: [__meta_kubernetes_pod_container_name]
          action: keep
          regex: 'istio-proxy'
        # 从 Pod 标签中提取 app 标签作为日志标签
        - source_labels: [__meta_kubernetes_pod_label_app]
          target_label: 'app'
        - source_labels: [__meta_kubernetes_namespace]
          target_label: 'namespace'
        - source_labels: [__meta_kubernetes_pod_name]
          target_label: 'pod'
      pipeline_stages:
        # 核心处理流水线
        # 1. 解码 JSON 日志
        - json:
            expressions:
              response_flags: response_flags
              termination_details: connection_termination_details
              source_ip: downstream_remote_address
              source_san: downstream_peer_uri_san
        # 2. 只保留 mTLS 失败的日志
        - match:
            selector: '{response_flags="UT"}'
            action: keep
        # 3. 将关键信息提升为 Loki 标签,用于高效查询
        - labels:
            response_flags:
            termination_details:
            source_san:

这个 Promtail 配置非常关键:

  • 它通过 kubernetes_sd_configs 自动发现所有 Pod。
  • relabel_configs 确保只采集 istio-proxy 容器的日志,并从 Pod 元数据中提取 app, namespace 等作为初始标签。
  • pipeline_stages 是处理的核心:
    • json 阶段解析日志行,并将我们感兴趣的字段提取到临时变量中。
    • match 阶段是过滤器。这是在客户端进行预过滤,大大减少发送到 Loki 的数据量。我们只保留 response_flagsUT 的日志行,因为我们只关心失败的连接。
    • labels 阶段将 termination_detailssource_san 等关键信息提升为 Loki 的标签。这是一个重要的架构权衡:成为标签的字段可以被高效索引和查询,但会增加索引大小。低基数、用于筛选和聚合的字段(如 termination_details)是标签的理想选择。高基数的字段(如 source_ip)则应保留在日志内容中。

4. LogQL: 查询与可视化

数据进入 Loki 后,我们就可以在 Grafana 中使用 LogQL 进行查询和分析了。

查询所有 mTLS 握手失败事件:

{job="kubernetes-pods-istio-proxy", response_flags="UT"}

统计每种失败原因的数量:

sum by (termination_details) (count_over_time({job="kubernetes-pods-istio-proxy", response_flags="UT"}[5m]))

这个查询会给我们一个类似 Prometheus 的时序图,显示 tls_protocol_errorsecret_not_found 等失败类型的频率。

找出哪些目标服务被没有身份的源攻击最频繁:

topk(10, sum by (app, namespace) (count_over_time({job="kubernetes-pods-istio-proxy", response_flags="UT", source_san=""}[1h])))

这个查询可以快速定位出配置错误或被未授权客户端频繁尝试连接的服务。source_san="" 过滤出那些来源身份为空的失败事件。

构建数据流图
整个审计管道的数据流可以用下图清晰地表示:

graph TD
    subgraph GKE Cluster
        A[Legacy Client] -- TCP Connect --> B{Payment Service Pod};
        subgraph Payment Service Pod
            C[istio-proxy / Envoy] -- Handshake Fail --> D[Custom JSON Log];
        end
        B -.-> C;
        E[Promtail DaemonSet] -- Scrapes --> D;
    end
    
    subgraph Observability Stack
        F[Loki];
        G[Grafana];
    end

    E -- Push (Filtered Logs) --> F;
    G -- LogQL Query --> F;
    H[SRE/Security Team] -- Views Dashboard --> G;

    style C fill:#f9f,stroke:#333,stroke-width:2px
    style D fill:#bbf,stroke:#333,stroke-width:2px
    style E fill:#ccf,stroke:#333,stroke-width:2px

这个流程实现了我们的目标:一个无侵入的、低成本的、可查询的 mTLS 失败审计系统。当再出现连接问题时,我们不再需要猜测,而是可以直接查询 Loki,精确知道是哪个源、哪个身份、在什么时间、因何种原因连接哪个目标失败了。

方案的局限性与未来迭代

尽管此方案解决了核心痛点,但在真实项目中,它仍有其局限性。首先,EnvoyFilter 的 API 在 Istio 的不同版本之间可能存在兼容性问题,升级 Istio 时需要仔细测试。其次,connection_termination_details 字段提供的原因有时仍然不够具体,例如它可能无法区分“客户端证书过期”和“客户端证书不受信任”。要获得更深层次的信息,可能需要启用 Envoy 的调试日志,但这会带来显著的性能开销。

未来的一个优化方向是探索基于 eBPF 的解决方案。通过在内核层面挂载探针来监控 TLS 握手事件,理论上可以获得比 Envoy 日志更丰富、更底层的上下文信息,且对应用代理完全透明。例如,可以使用 Cilium/Tetragon 或 Pixie 这类工具来捕获此类事件。然而,这也意味着需要维护另一套复杂的技术栈,并且对内核版本有一定要求。

另一个可行的迭代路径是自动化响应。基于 Loki 的告警规则,当检测到某个服务的 mTLS 失败率突然飙升,或来自某个未知源的连接尝试激增时,可以自动触发告警通知到 Slack,甚至可以联动 Kubernetes API 自动创建一个 NetworkPolicy 来临时隔离可疑的流量源,从而形成一个从观测到响应的闭环安全体系。


  目录