在 Kubernetes 上实现 Spring Boot SSL 热重载

本文将带你了解如何为在 Kubernetes 上运行的 Spring Boot 应用配置 SSL 证书的热重载。我们将使用 Spring Boot 3.1 和 3.2 版本中引入的两个功能。第一个功能允许我们利用 SSL Bundle 在服务器端和客户端配置和使用自定义 SSL 配置。第二个功能使得在 Spring Boot 应用的嵌入式 Web 服务器中轻松进行 SSL 证书和密钥的热重载。

为了在 Kubernetes 上生成 SSL 证书,我们将使用 cert-manager。“cert-manager” 可以在指定期限后轮换证书,并将其保存为 Kubernetes Secret。之前的文章中介绍了如何在 Secret 更新时自动重启 Pod 的类似方案。我们使用 Stakater Reloader 工具在新版本的 Secret 上自动重启 pod。不过,这次我们使用 Spring Boot 新特性来避免重新启动应用(Pod)。

源码

你也可以克隆我的源代码,亲自尝试一下。首先克隆我的 GitHub 仓库。然后切换到 ssl 目录。你会发现两个 Spring Boot 应用:secure-callme-bundlesecure-caller-bundle。之后,你只需按照说明操作即可。

工作原理

在介绍技术细节之前,首先来了解我们的应用架构和面临的挑战。我们需要设计一个解决方案,在 Kubernetes 上运行的服务之间实现 SSL/TLS 通信。这个解决方案必须考虑到证书重载的情况。此外,服务器和客户端必须同时进行重载,以避免通信中出现错误。在服务器端,使用嵌入式 Tomcat 服务器。在客户端应用中,使用 Spring RestTemplate 对象。

“Cert-manager” 可以根据提供的 CRD 对象自动生成证书。它确保证书有效并保持最新,并在到期之前尝试更新证书。它将所有所需的数据作为 Kubernetes Secret 提供。此类 Secret 将作为卷挂载到应用 Pod 中。由于这样,我们无需重新启动 Pod 即可查看 Pod 内部的最新证书或 “keystore”。以下是所描述的架构的可视化图示。

spring-boot SSL 重载架构

在 Kubernetes 上安装 cert-manager

为了在 Kubernetes 上安装 “cert-manager”,我们将使用其 Helm chart。我们不需要任何特定设置。在安装 chart 之前,必须为最新版本 1.14.2 添加 CRD 资源:

$ kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.2/cert-manager.crds.yaml

然后,需要添加 jetstack chart repository:

$ helm repo add jetstack https://charts.jetstack.io

然后,可以使用以下命令在 cert-manager 命名空间中安装 chart:

$ helm install my-release cert-manager jetstack/cert-manager \
    -n cert-manager

为了验证安装是否成功完成,可以查看正在运行的 pod 列表:

$ kubectl get po
NAME                                          READY   STATUS    RESTARTS   AGE
my-cert-manager-578884c6cf-f9ppt              1/1     Running   0          1m
my-cert-manager-cainjector-55d4cd4bb6-6mgjd   1/1     Running   0          1m
my-cert-manager-webhook-5c68bf9c8d-nz7sd      1/1     Running   0 

你也可以将其安装为 “csi-driver”,而不是标准的 “cert-manager”。它为 Kubernetes 实现了容器存储接口(CSI),可与 “cert-manager” 一起工作。挂载此类卷的 Pod 将无需创建 Certificate 资源即可请求证书。这些证书将直接挂载到 Pod 中,不需要中间的 Kubernetes “Secret”。

就这样,现在我们可以开始实现了。

嵌入式服务器的 SSL 热重载

应用示例

第一个应用 secure-callme-bundle 通过 HTTP 公开了一个端点 GET /callme

secure-caller-bundle 应用将调用该端点。下面是 @RestController 的实现:

@RestController
public class SecureCallmeController {

    @GetMapping("/callme")
    public String call() {
        return "I'm `secure-callme`!";
    }

}

现在,我们的主要目标是为该应用程序启用 HTTPS,并使其在 Kubernetes 上正常运行。首先,应将 Spring Boot 应用的默认服务器端口更改为 8443 (1)。从 Spring Boot 3.1 开始,我们可以使用 spring.ssl.bundle.* 属性而不是 server.ssl.* 属性来配置 Web 服务器的 SSL 配置 (3)。它可以支持两种类型的 SSL 证书格式。要使用 Java keystore 文件来配置 bundle,必须使用 spring.ssl.bundle.jks 属性组。另一方面,也可以使用 spring.ssl.bundle.pem 属性组,使用 PEM 编码的文本文件来配置 bundle。

在本例中,我们将使用 Java keystore 文件(JKS)。我们在名为 server 的 bundle 下定义了一个单独的 SSL bundle。它包含了密钥库(keystore)和信任库(truststore)的位置。通过设置 reload-on-update 属性,可以指定 Spring Boot 在后台监视文件,并在文件发生更改时触发 Web 服务器重新加载。另外,使用 server.ssl.client-auth 属性强制验证客户端的证书 (2)。最后,需要使用 server.ssl.bundle 属性为 Web 服务器设置 bundle 的名称。以下是 Spring Boot 应用在 application.yml 文件中的完整配置。

# (1)
server.port: 8443

# (2)
server.ssl:
  client-auth: NEED
  bundle: server

# (3)
---
spring.config.activate.on-profile: prod
spring.ssl.bundle.jks:
  server:
    reload-on-update: true
    keystore:
      location: ${CERT_PATH}/keystore.jks
      password: ${PASSWORD}
      type: JKS
    truststore:
      location: ${CERT_PATH}/truststore.jks
      password: ${PASSWORD}
      type: JKS

使用 cert-manager 生成证书

在 Kubernetes 上部署 callme-secure-bundle 应用之前,需要配置 “cert-manager” 并生成所需的证书。首先,需要定义负责签发证书的 CRD 对象。下面是生成自签名证书的 ClusterIssuer 对象。

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: ss-cluster-issuer
spec:
  selfSigned: {}

如下 Kubernetes Secret,其中包含用于保护生成的 Keystore 的密码:

secure-callme-bundle/k8s/secret.yaml

kind: Secret
apiVersion: v1
metadata:
  name: jks-password-secret
data:
  password: MTIzNDU2
type: Opaque

之后,我们可以生成证书。以下是应用的 Certificate 对象。这里有一些重要的事项。首先,我们可以生成包含证书和私钥的密钥库 (1)。该对象引用了在前一步中创建的 ClusterIssuer(2)。在通信过程中使用的 Kubernetes Service 的名称是 secure-callme-bundle,因此证书的 CN 字段需要具有该名称。为了启用证书轮换,需要设置有效期。最低可设置为 1 小时 (4)。因此,每次在过期前 5 分钟,“cert-manager” 将自动更新证书 (5)。然而,它不会轮换私钥。

secure-callme-bundle/k8s/cert.yaml

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: secure-callme-cert
spec:
  keystores:
    jks:
      passwordSecretRef:
        name: jks-password-secret
        key: password
      create: true
  issuerRef:
    name: ss-cluster-issuer
    group: cert-manager.io
    kind: ClusterIssuer
  privateKey:
    algorithm: ECDSA
    size: 256
  dnsNames:
    - secure-callme-bundle
    - localhost
  secretName: secure-callme-cert
  commonName: secure-callme-bundle
  duration: 1h
  renewBefore: 5m

部署在 Kubernetes 上

创建证书后,就可以开始部署 secure-callme-bundle 应用了。它会将包含证书和 Keystore 的 Secret 挂载为卷。输出 Secret 的名称由 Certificate 对象中定义的 spec.secretName 的值决定。我们需要向 Spring Boot 应用注入一些环境变量。它需要 Keystore 的密码 (PASSWORD)、挂载在 Pod 中的配置资源的位置 (CERT_PATH),以及激活 prod profile (SPRING_PROFILES_ACTIVE)。

secure-callme-bundle/k8s/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: secure-callme-bundle
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: secure-callme-bundle
  template:
    metadata:
      labels:
        app.kubernetes.io/name: secure-callme-bundle
    spec:
      containers:
        - image: piomin/secure-callme-bundle
          name: secure-callme-bundle
          ports:
            - containerPort: 8443
              name: https
          env:
            - name: PASSWORD
              valueFrom:
                secretKeyRef:
                  key: password
                  name: jks-password-secret
            - name: CERT_PATH
              value: /opt/secret
            - name: SPRING_PROFILES_ACTIVE
              value: prod
          volumeMounts:
            - mountPath: /opt/secret
              name: cert
      volumes:
        - name: cert
          secret:
            secretName: secure-callme-cert

下面是与应用相关的 Kubernetes Service

apiVersion: v1
kind: Service
metadata:
  labels:
    app.kubernetes.io/name: secure-callme-bundle
  name: secure-callme-bundle
spec:
  ports:
    - name: https
      port: 8443
      targetPort: 8443
  selector:
    app.kubernetes.io/name: secure-callme-bundle
  type: ClusterIP

首先,确保你在 secure-callme-bundle 目录中。使用 Skaffold 在 Kubernetes 上构建并运行应用,并在 8443 端口下启用 “端口转发(port-forwar)”:

$ skaffold dev --port-forward

Skaffold 不仅会运行应用,还会应用应用 k8s 目录中定义的所有必要 Kubernetes 对象。这也适用于 “cert-manager” Certificate 对象。一旦 skaffold dev 命令成功完成,我们就可以访问 http://127.0.0.1:8443 地址下的 HTTP 端点。

访问 http://127.0.0.1:8443 端点

调用 GET /callme 端点。虽然我们启用了 --insecure 选项,但由于 Web 服务器要求客户端身份验证,所以请求失败了。为了避免这种情况,应该在 curl 命令中同时包含密钥和证书文件:

$ curl https://localhost:8443/callme --insecure -v
*   Trying [::1]:8443...
* Connected to localhost (::1) port 8443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Request CERT (13):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Certificate (11):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES256-GCM-SHA384
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: CN=secure-callme-bundle
*  start date: Feb 18 20:13:00 2024 GMT
*  expire date: Feb 18 21:13:00 2024 GMT
*  issuer: CN=secure-callme-bundle
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
* using HTTP/1.x
> GET /callme HTTP/1.1
> Host: localhost:8443
> User-Agent: curl/8.4.0
> Accept: */*
>
* LibreSSL SSL_read: LibreSSL/3.3.6: error:1404C412:SSL routines:ST_OK:sslv3 alert bad certificate, errno 0
* Closing connection
curl: (56) LibreSSL SSL_read: LibreSSL/3.3.6: error:1404C412:SSL routines:ST_OK:sslv3 alert bad certificate, errno 0

RestTemplate 的 SSL 热重载

应用示例

切换到 secure-caller-bundle 应用。这个应用也暴露了一个 HTTP 端点。在这个端点实现方法中,我们调用了 secure-callme-bundle 应用暴露的 GET /callme 端点。为此,我们使用 RestTemplate Bean。

pl.piomin.services.caller.controller.SecureCallerBundleController

@RestController
public class SecureCallerBundleController {

    RestTemplate restTemplate;

    @Value("${client.url}")
    String clientUrl;

    public SecureCallerBundleController(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @GetMapping("/caller")
    public String call() {
        return "I'm `secure-caller`! calling... " +
                restTemplate.getForObject(clientUrl, String.class);
    }
}

这次我们需要在应用设置中定义两个 SSL Bundle。server bundle 用于 Web 服务器,与上一个应用示例中定义的 bundle 非常相似。client bundle 专用于 RestTemplate Bean。它使用的 keystore 和 truststore 来自为服务器端应用生成的 Secret。有了这些文件,RestTemplate Bean 就可以对 secure-callme-bundle 应用进行身份验证。当然,我们还需要在证书轮换后自动重新加载 SslBundle Bean。

server.port: 8443
server.ssl.bundle: server

---
spring.config.activate.on-profile: prod
client.url: https://${HOST}:8443/callme
spring.ssl.bundle.jks:
  server:
    reload-on-update: true
    keystore:
      location: ${CERT_PATH}/keystore.jks
      password: ${PASSWORD}
      type: JKS
  client:
    reload-on-update: true
    keystore:
      location: ${CLIENT_CERT_PATH}/keystore.jks
      password: ${PASSWORD}
      type: JKS
    truststore:
      location: ${CLIENT_CERT_PATH}/truststore.jks
      password: ${PASSWORD}
      type: JKS

Spring Boot 3.1 的 Bundle 概念极大地简化了 Spring REST 客户端(如 RestTemplateWebClient)的 SSL Context 配置。不过,目前(Spring Boot 3.2.2)还没有内置的实现,例如在 SslBundle 更新时重载 Spring RestTemplate。因此,我们需要添加一部分代码来实现这一目标。幸运的是,SslBundles 允许我们定义一个在 Bundle 更新事件中触发的自定义 Handler。我们需要为 client Bundle 定义 Handler。一旦接收到 SslBundle 的轮换版本,它就会使用 RestTemplateBuilder 将上下文中现有的 RestTemplate Bean 替换为新的 RestTemplate Bean。

@SpringBootApplication
public class SecureCallerBundle {

   private static final Logger LOG = LoggerFactory
      .getLogger(SecureCallerBundle.class);

   public static void main(String[] args) {
      SpringApplication.run(SecureCallerBundle.class, args);
   }

   @Autowired
   ApplicationContext context;

   @Bean("restTemplate")
   RestTemplate builder(RestTemplateBuilder builder, SslBundles sslBundles) {
      sslBundles.addBundleUpdateHandler("client", sslBundle -> {
         try {
            LOG.info("Bundle updated: " + sslBundle.getStores().getKeyStore().getCertificate("certificate"));
         } catch (KeyStoreException e) {
            LOG.error("Error on getting certificate", e);
         }
         DefaultSingletonBeanRegistry registry = (DefaultSingletonBeanRegistry) context
            .getAutowireCapableBeanFactory();
         registry.destroySingleton("restTemplate");
         registry.registerSingleton("restTemplate", 
            builder.setSslBundle(sslBundle).build());
      });
      return builder.setSslBundle(sslBundles.getBundle("client")).build();
   }
}

部署在 Kubernetes 上

让我们看看当前应用的 Kubernetes Deployment 清单。这次,我们将两个 Secret 挂载为卷。第一个是为当前应用的 Web 服务器生成的,第二个是为 secure-callme-bundle 应用生成的,并被 RestTemplate 用于建立安全通信。我们还设置了目标服务的地址,以便将其注入应用(HOST)并激活 prod profile(SPRING_PROFILES_ACTIVE)。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: secure-caller-bundle
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: secure-caller-bundle
  template:
    metadata:
      labels:
        app.kubernetes.io/name: secure-caller-bundle
    spec:
      containers:
        - image: piomin/secure-caller-bundle
          name: secure-caller-bundle
          ports:
            - containerPort: 8443
              name: https
          env:
            - name: PASSWORD
              valueFrom:
                secretKeyRef:
                  key: password
                  name: jks-password-secret
            - name: CERT_PATH
              value: /opt/secret
            - name: CLIENT_CERT_PATH
              value: /opt/client-secret
            - name: HOST
              value: secure-callme-bundle
            - name: SPRING_PROFILES_ACTIVE
              value: prod
          volumeMounts:
            - mountPath: /opt/secret
              name: cert
            - mountPath: /opt/client-secret
              name: client-cert
      volumes:
        - name: cert
          secret:
            secretName: secure-caller-cert
        - name: client-cert
          secret:
            secretName: secure-callme-cert

使用 skaffold dev --port-forward 命令来部署应用。它将再次在 Kubernetes 上部署所有所需的内容。由于我们已经使用 port-forward 选项暴露了 secure-callme-bundle 应用,因此当前的应用将暴露在 8444 端口下。

应用暴露在 8444 端口下

尝试调用 GET /caller 端点。在内部,它使用 RestTemplate 调用 secure-callme-bundle 应用暴露的端点。如你所见,安全的通信已成功建立。

curl https://localhost:8444/caller --insecure -v
*   Trying [::1]:8444...
* Connected to localhost (::1) port 8444
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES256-GCM-SHA384
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: CN=secure-caller-bundle
*  start date: Feb 18 20:40:11 2024 GMT
*  expire date: Feb 18 21:40:11 2024 GMT
*  issuer: CN=secure-caller-bundle
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
* using HTTP/1.x
> GET /caller HTTP/1.1
> Host: localhost:8444
> User-Agent: curl/8.4.0
> Accept: */*
>
< HTTP/1.1 200
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 57
< Date: Sun, 18 Feb 2024 21:26:42 GMT
<
* Connection #0 to host localhost left intact
I'm `secure-caller`! calling... I'm secure-callme-bundle!

现在,我们可以等待一小时,直到 “cert-manager” 轮换 secure-callme-cert Secret 中的证书。不过,我们也可以删除该 secret,因为 “cert-manager” 会根据 Certificate 对象重新生成它。下面是包含证书和 Keystore 的 secret,用于在两个 Spring Boot 示例应用之间建立安全通信。

包含证书和 Keystore 的 secret

不管是等到 1 小时后轮转发生,还是手动删除 Secret,你都应该在 secure-callme-bundle 应用 Pod 中看到以下日志。这意味着 Spring Boot 接收到了 SslBundle 更新事件,然后重新加载了 Tomcat 服务器。

SslBundle 更新事件

SslBundle 事件也在 secure-caller-bundle 应用端处理。它会刷新 RestTemplate Bean,并在日志中打印包含最新证书的信息。

RestTemplate 重新加载 SSL 证书

最后

Spring Boot 的最新版本大大简化了服务器和客户端的 SSL 证书管理。有了 SslBundles,我们无需重启 Kubernetes 上的 Pod 就能轻松处理证书轮换过程。还有一些其他事项需要考虑,本文没有涉及。其中包括在应用间分发 Tust Bundle 的机制。不过,举例来说,要在 Kubernetes 环境中管理 Tust Bundle,我们可以使用 “cert-manager” trust-manager 功能。


Ref:https://piotrminkowski.com/2024/02/19/spring-boot-ssl-hot-reload-on-kubernetes/