本文将带你了解如何为在 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/