在 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-bundle 和 secure-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 客户端(如 RestTemplate 或 WebClient)的 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/