本文将带你了解如何为在 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"。以下是所描述的架构的可视化图示。

在 Kubernetes 上安装 cert-manager {#在-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 热重载 {#嵌入式服务器的-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 生成证书 {#使用-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 上 {#部署在-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 端点。

调用 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 热重载 {#resttemplate-的-ssl-热重载}
应用示例 {#应用示例-1}
切换到 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-上-1}
让我们看看当前应用的 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 端口下。

尝试调用 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 示例应用之间建立安全通信。

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

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

最后 {#最后}
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/
 51工具盒子
51工具盒子 
                 
                             
                         
                         
                         
                        