使用 mTLS 的 GKE gRPC 入口健康检查

GKE gRPC Ingress Health Check with mTLS

我正在尝试在 GKE (v1.11.2-gke.18) 上使用双向 TLS 身份验证实现 gRPC 服务。

当不强制执行客户端身份验证时,GKE 自动创建的 HTTP2 健康检查有响应,并且一切都连接问题。

当我打开相互身份验证时,健康检查失败 - 可能是因为它无法完成连接,因为它缺少客户端证书和密钥。

一如既往,文档很简单且相互矛盾。我需要一个完全编程的解决方案(即没有控制台调整),但除了手动将运行状况检查更改为 TCP 之外,我还没有找到解决方案。

据我所知 我猜我要么需要:

或者还有什么我没有考虑到的?下面的配置非常适用于带有 TLS 的 REST 和 gRPC,但与 mTLS 不兼容。

service.yaml

apiVersion: v1
kind: Service
metadata:
  name: grpc-srv
  labels:
    type: grpc-srv
  annotations:
    service.alpha.kubernetes.io/app-protocols: '{"grpc":"HTTP2"}'
spec:
  type: NodePort
  ports:
  - name: grpc
    port: 9999
    protocol: TCP
    targetPort: 9999
  - name: http
    port: 8080
    protocol: TCP
    targetPort: 8080
  selector:
    app: myapp

ingress.yaml

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: io-ingress
  annotations:
    kubernetes.io/ingress.global-static-ip-name: "grpc-ingress"
    kubernetes.io/ingress.allow-http: "true"
spec:
  tls:
  - secretName: io-grpc
  - secretName: io-api
  rules:
  - host: grpc.xxx.com
    http:
      paths:
      - path: /*
        backend:
          serviceName: grpc-srv
          servicePort: 9999
  - host: rest.xxx.com
    http:
      paths:
      - path: /*
        backend:
          serviceName: grpc-srv
          servicePort: 8080

HTTP/2 和 GKE 上的 gRPC 支持尚不可用。请参阅工作中的 limitation. There is already a feature request 以解决该问题。

目前似乎没有办法使用 GKE L7 ingress 来实现这一点。但是我已经成功部署了 NGINX Ingress Controller. Google have a not bad tutorial on how to deploy one here.

这会安装一个 L4 TCP 负载平衡器,不对服务进行健康检查,让 NGINX 处理 L7 终止和路由。这为您提供了更大的灵活性,但细节决定成败,而细节并不容易获得。我发现的大部分内容都是通过 github 问题学到的。

我设法实现的是让 NGINX 处理 TLS 终止,并且仍然将证书传递到后端,因此您可以通过 CN 处理诸如用户身份验证之类的事情,或者检查证书序列CRL.

下面是我的入口文件。注解是实现mTLS认证的最低要求,在后端仍然可以访问证书。

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: grpc-ingress
  namespace: master
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/auth-tls-verify-client: "on"
    nginx.ingress.kubernetes.io/auth-tls-secret: "master/auth-tls-chain"
    nginx.ingress.kubernetes.io/auth-tls-verify-depth: "2"
    nginx.ingress.kubernetes.io/auth-tls-pass-certificate-to-upstream: "true"
    nginx.ingress.kubernetes.io/backend-protocol: "GRPCS"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/grpc-backend: "true"
spec:
  tls:
    - hosts:
        - grpc.example.com
      secretName: auth-tls-chain
  rules:
    - host: grpc.example.com
      http:
        paths:
          - path: /grpc.AwesomeService
            backend:
              serviceName: awesome-srv
              servicePort: 9999
          - path: /grpc.FantasticService
            backend:
              serviceName: fantastic-srv
              servicePort: 9999

注意几点:

  • auth-ls-chain 机密包含 3 个文件。 ca.crt 是证书链,应该包括任何中间证书。 tls.crt 包含您的服务器证书,tls.key 包含您的私钥。
  • 如果这个秘密位于与 NGINX 入口不同的名称空间中,那么您应该在注释中给出完整路径。
  • 我的 verify-depth 是 2,但那是因为我使用的是中间证书。如果您使用的是自签名,那么您只需要深度为 1。
  • backend-protocol: "GRPCS" 是防止 NGINX 终止 TLS 所必需的。如果您想让 NGINX 终止 TLS 并且 运行 您的服务不加密,请使用 GRPC 作为协议。
  • grpc-backend: "true" 需要让 NGINX 知道后端请求使用 HTTP2。
  • 您可以列出多个路径并指向多个服务。与 GKE 入口不同,这些路径不应有正斜杠或星号后缀。

最好的部分是,如果您有多个命名空间,或者如果您还运行使用 REST 服务(例如 gRPC 网关),NGINX 将重用相同的负载均衡器。这比 GKE 入口节省了一些费用,GKE 入口将为每个入口使用一个单独的 LB。

上面来自 master 命名空间,下面是来自 staging 命名空间的 REST 入口。

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  namespace: staging
  annotations:
    kubernetes.io/ingress.class: nginx
    kubernetes.io/tls-acme: "true"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  tls:
    - hosts:
      - api-stage.example.com
      secretName: letsencrypt-staging
  rules:
    - host: api-stage.example.com
      http:
        paths:
          - path: /awesome
            backend:
              serviceName: awesom-srv
              servicePort: 8080
          - path: /fantastic
            backend:
              serviceName: fantastic-srv
              servicePort: 8080

对于 HTTP,我使用的是 LetsEncrypt,但是有很多关于如何设置它的信息。

如果您执行到 ingress-nginx pod,您将能够看到 NGINX 是如何配置的:

...
        server {
                server_name grpc.example.com ;
                listen 80;
                set $proxy_upstream_name "-";
                set $pass_access_scheme $scheme;
                set $pass_server_port $server_port;
                set $best_http_host $http_host;
                set $pass_port $pass_server_port;

                listen 442 proxy_protocol   ssl http2;

                # PEM sha: 142600b0866df5ed9b8a363294b5fd2490c8619d
                ssl_certificate                         /etc/ingress-controller/ssl/default-fake-certificate.pem;
                ssl_certificate_key                     /etc/ingress-controller/ssl/default-fake-certificate.pem;

                ssl_certificate_by_lua_block {
                        certificate.call()
                }

                # PEM sha: 142600b0866df5ed9b8a363294b5fd2490c8619d
                ssl_client_certificate                  /etc/ingress-controller/ssl/master-auth-tls-chain.pem;
                ssl_verify_client                       on;
                ssl_verify_depth                        2;

                error_page 495 496 = https://help.example.com/auth;

                location /grpc.AwesomeService {

                        set $namespace      "master";
                        set $ingress_name   "grpc-ingress";
                        set $service_name   "awesome-srv";
                        set $service_port   "9999";
                        set $location_path  "/grpc.AwesomeServices";

                        rewrite_by_lua_block {
                                lua_ingress.rewrite({
                                        force_ssl_redirect = true,
                                        use_port_in_redirects = false,
                                })
                                balancer.rewrite()
                                plugins.run()
                        }

                        header_filter_by_lua_block {
                                plugins.run()
                        }
                        body_filter_by_lua_block {
                        }

                        log_by_lua_block {
                                balancer.log()
                                monitor.call()
                                plugins.run()
                        }

                        if ($scheme = https) {
                                more_set_headers                        "Strict-Transport-Security: max-age=15724800; includeSubDomains";
                        }

                        port_in_redirect off;
                        set $proxy_upstream_name    "master-analytics-srv-9999";
                        set $proxy_host             $proxy_upstream_name;
                        client_max_body_size                    1m;
                        grpc_set_header Host                   $best_http_host;

                        # Pass the extracted client certificate to the backend
                        grpc_set_header ssl-client-cert        $ssl_client_escaped_cert;
                        grpc_set_header ssl-client-verify      $ssl_client_verify;
                        grpc_set_header ssl-client-subject-dn  $ssl_client_s_dn;
                        grpc_set_header ssl-client-issuer-dn   $ssl_client_i_dn;

                        # Allow websocket connections
                        grpc_set_header                        Upgrade           $http_upgrade;
                        grpc_set_header                        Connection        $connection_upgrade;
                        grpc_set_header X-Request-ID           $req_id;
                        grpc_set_header X-Real-IP              $the_real_ip;
                        grpc_set_header X-Forwarded-For        $the_real_ip;
                        grpc_set_header X-Forwarded-Host       $best_http_host;
                        grpc_set_header X-Forwarded-Port       $pass_port;
                        grpc_set_header X-Forwarded-Proto      $pass_access_scheme;
                        grpc_set_header X-Original-URI         $request_uri;
                        grpc_set_header X-Scheme               $pass_access_scheme;
                        # Pass the original X-Forwarded-For
                        grpc_set_header X-Original-Forwarded-For $http_x_forwarded_for;
                        # mitigate HTTPoxy Vulnerability
                        # https://www.nginx.com/blog/mitigating-the-httpoxy-vulnerability-with-nginx/
                        grpc_set_header Proxy                  "";

                        # Custom headers to proxied server
                        proxy_connect_timeout                   5s;
                        proxy_send_timeout                      60s;
                        proxy_read_timeout                      60s;
                        proxy_buffering                         off;
                        proxy_buffer_size                       4k;
                        proxy_buffers                           4 4k;
                        proxy_request_buffering                 on;
                        proxy_http_version                      1.1;
                        proxy_cookie_domain                     off;
                        proxy_cookie_path                       off;

                        # In case of errors try the next upstream server before returning an error
                        proxy_next_upstream                     error timeout;
                        proxy_next_upstream_tries               3;
                        grpc_pass grpcs://upstream_balancer;
                        proxy_redirect                          off;

                }
                location /grpc.FantasticService {

                        set $namespace      "master";
                        set $ingress_name   "grpc-ingress";
                        set $service_name   "fantastic-srv";
                        set $service_port   "9999";
                        set $location_path  "/grpc.FantasticService";

...

这只是生成的 nginx.conf 的摘录。但是您应该能够看到单个配置如何处理跨多个命名空间的多个服务。

最后一段是我们如何通过上下文获取证书的 go 片段。从上面的配置中可以看出,NGINX 将经过身份验证的证书和其他详细信息添加到 gRPC 元数据中。

meta, ok := metadata.FromIncomingContext(*ctx)
if !ok {
    return status.Error(codes.Unauthenticated, "missing metadata")
}

// Check if SSL has been handled upstream
if len(meta.Get("ssl-client-verify")) == 1 && meta.Get("ssl-client-verify")[0] == "SUCCESS" {
    if len(meta.Get("ssl-client-cert")) > 0 {
        certPEM, err := url.QueryUnescape(meta.Get("ssl-client-cert")[0])
        if err != nil {
            return status.Errorf(codes.Unauthenticated, "bad or corrupt certificate")
        }
        block, _ := pem.Decode([]byte(certPEM))
        if block == nil {
            return status.Error(codes.Unauthenticated, "failed to parse certificate PEM")
        }
        cert, err := x509.ParseCertificate(block.Bytes)
        if err != nil {
            return status.Error(codes.Unauthenticated, "failed to parse certificate PEM")
        }
        return authUserFromCertificate(ctx, cert)
    }
}
// if fallen through, then try to authenticate via the peer object for gRPCS, 
// or via a JWT in the metadata for gRPC Gateway.