Kubernetes上实现Spring Boot SSL热重载

本文将教你如何为在 Kubernetes 上运行的 Spring Boot 应用程序配置 SSL 证书的热重载。

译自Spring Boot SSL Hot Reload on Kubernetes,作者 piotr.minkowski 。

本文将教你如何为在 Kubernetes 上运行的 Spring Boot 应用程序配置 SSL 证书的热重载。我们将使用 Spring Boot 框架的 3.1 和 3.2 版本引入的两个功能。第一个功能允许我们利用 SSL 绑定来配置和使用自定义 SSL 信任材料,既可以在服务器端也可以在客户端使用。第二个功能使得在 Spring Boot 应用程序中的嵌入式 Web 服务器中热重载 SSL 证书和密钥变得很容易。让我们看看它在实践中是如何工作的!

为了在 Kubernetes 上生成 SSL 证书,我们将使用cert-manager。"Cert-manager" 可以在指定的时间后轮换证书,并将其保存为 KubernetesSecrets。我已经在这篇文章中描述了如何实现类似的场景,即在 Secret 更新后自动重新启动 pod。我们曾经使用 Stakater Reloader 工具,在Secret的新版本上自动重新启动 pod。然而,这一次我们使用 Spring Boot 的功能来避免重新启动应用程序(pod)。

源代码

如果您想要自己尝试这个练习,您可以随时查看我的源代码。为了做到这一点,您需要克隆我的 GitHub存储库。然后切换到ssl目录。您会找到两个 Spring Boot 应用程序:secure-callme-bundlesecure-caller-bundle。之后,您只需要按照我的说明操作。让我们开始吧。

工作原理

在我们深入技术细节之前,让我多写一点关于我们解决方案的架构。我们的挑战非常普遍。我们需要为在 Kubernetes 上运行的服务之间启用 SSL/TLS 通信设计一个解决方案。这个解决方案必须考虑到证书重新加载的场景。此外,它必须同时发生在服务器端和客户端,以避免通信中的错误。在服务器端,我们使用嵌入式 Tomcat 服务器。在客户端应用程序中,我们使用 SpringRestTemplate对象。

"Cert-manager" 可以根据提供的 CRD 对象自动生成证书。它确保证书有效且最新,并在到期前尝试更新证书。它作为 KubernetesSecret提供了所有所需的员工。这样的秘密然后被挂载为一个卷到应用程序 pod 中。由于这样,我们不需要重新启动一个 pod,就可以在 pod 内看到最新的证书或"密钥库"。这是描述的架构的可视化。

在 Kubernetes 上安装 cert-manager

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

shell 复制代码
$ kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.2/cert-manager.crds.yaml

然后,我们需要添加 jetstack Chart仓库:

csharp 复制代码
$ helm repo add jetstack https://charts.jetstack.io

之后,我们可以使用以下命令在 cert-manager 命名空间中安装Chart:

shell 复制代码
$ helm install my-release cert-manager jetstack/cert-manager \ -n cert-manager

为了验证安装是否成功,我们可以显示运行中的 pod 列表:

sql 复制代码
$ 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

除了标准的 "cert-manager",您还可以将其安装为 "csi-driver"。它为 Kubernetes 实现了容器存储接口(CSI),并与 "cert-manager" 一起工作。挂载此类卷的 pod 将请求创建证书,而不是创建 Certificate 资源。这些证书将直接挂载到 pod 中,没有中间的 Kubernetes "Secret"。

就是这样。现在我们可以继续实施了。

示例应用程序实现

我们的第一个应用程序secure-callme-bundle在 HTTP 上公开了一个单一的端点GET /callme。该端点将由secure-caller-bundle应用程序调用。下面是@RestController的实现:

kotlin 复制代码
@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.*属性来配置 Web 服务器的 SSL 信任材料,而不是使用server.ssl.*属性(3)。它可以支持两种类型的受信任材料。为了使用 Java 密钥库文件配置包,我们必须使用spring.ssl.bundle.jks组。另一方面,也可以使用 PEM 编码的文本文件配置包,使用spring.ssl.bundle.pem属性组。

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

yaml 复制代码
# (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 生成证书

在我们将callme-secure-bundle应用部署到 Kubernetes 上之前,我们需要配置 "cert-manager" 并生成所需的证书。首先,我们需要定义负责发放证书的 CRD 对象。这是生成自签名证书的ClusterIssuer对象。

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

这是用于保护生成的密钥库的密码的 Kubernetes Secret:

secure-callme-bundle/k8s/secret.yaml

yaml 复制代码
kind: Secret
apiVersion: v1
metadata:
  name: jks-password-secret
data:
  password: MTIzNDU2
type: Opaque

然后,我们可以生成证书。这是用于应用程序的Certificate对象。这里有一些重要的事情。首先,我们可以一起生成密钥库、证书和私钥(1)。该对象引用了在前一步中创建的ClusterIssuer(2)。用于通信的 KubernetesService的名称是secure-callme-bundle,因此证书的 CN 需要具有该名称。为了启用证书轮换,我们需要设置有效期。最低可能值是 1 小时(4)。因此,每次在过期前 5 分钟,"cert-manager" 将自动更新证书(5)。但是,它不会轮换私钥。

secure-callme-bundle/k8s/cert.yaml

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应用程序的部署。它将Secret挂载为卷,其中包含证书和密钥库。输出Secret的名称由 Certificate 对象中定义的spec.secretName的值确定。我们需要将一些环境变量注入到 Spring Boot 应用程序中。它需要密钥库的密码(PASSWORD)、Pod 内挂载的受信任材料的位置(CERT_PATH)以及激活prod配置文件(SPRING_PROFILES_ACTIVE)。

secure-callme-bundle/k8s/deployment.yaml

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:

yaml 复制代码
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端口下启用"端口转发":

css 复制代码
$ skaffold dev --port-forward

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

让我们调用GET /callme端点。尽管我们启用了--insecure选项,但请求失败,因为 Web 服务器需要客户端认证。为了避免这种情况,我们应该在curl命令中包含密钥和证书文件。然而,

markdown 复制代码
$ 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 实现 Spring Boot SSL 热重载

示例应用实现

让我们切换到secure-caller-bundle应用。这个应用也暴露了一个单一的 HTTP 端点。在这个端点的实现方法内部,我们调用了 secure-callme-bundle 应用暴露的GET /callme端点。我们使用RestTemplate bean来实现这个调用。

pl.piomin.services.caller.controller.SecureCallerBundleController

kotlin 复制代码
@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 bundles。服务器 bundle 用于 web 服务器,与之前的应用示例中定义的 bundle 非常相似。客户端 bundle 专门用于 RestTemplate bean。它使用从为服务器端应用程序生成的 Secret 中获取的 keystore 和 truststore。有了这些文件,RestTemplatebean 就可以对secure-callme-bundle应用进行身份验证。当然,我们还需要在证书轮换后自动重新加载 SslBundle bean。

yaml 复制代码
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 上下文配置。然而,当前(Spring Boot 3.2.2)尚未内置重新加载例如 SpringRestTemplateSslBundle更新的实现。因此,我们需要添加一部分代码来实现这一点。幸运的是,SslBundles 允许我们定义一个自定义处理程序,该处理程序在 bundle 更新事件上触发。我们需要为客户端 bundle 定义处理程序。一旦它接收到SslBundle的旋转版本,它将使用 RestTemplateBuilder 将上下文中的现有RestTemplatebean 替换为新的。

java 复制代码
@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部署清单。这次,我们将两个 Secret 挂载为卷。第一个是为当前应用的 Web 服务器生成的,而第二个是为secure-callme-bundle应用程序生成的,由RestTemplate在建立安全通信时使用。我们还设置了目标服务的地址,以便将其注入到应用程序中(HOST),并激活了 prod 环境配置文件(SPRING_PROFILES_ACTIVE)。

yaml 复制代码
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应用程序暴露的端点。如您所见,安全通信已成功建立。

markdown 复制代码
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密钥。然而,我们也可以删除该密钥,因为 "cert-manager" 将基于Certificate对象重新生成它。这是用于在我们的两个示例 Spring Boot 应用程序之间建立安全通信的证书和密钥存储的 secret。

无论您等待 1 小时直到轮换发生,还是通过删除密钥手动执行,您都应该在secure-callme-bundle应用程序的 pod 中看到以下日志。这意味着 Spring Boot 已接收到SslBundle更新事件,然后重新加载了 Tomcat 服务器。

secure-callme-bundle应用程序也处理了SslBundle事件。它会刷新RestTemplatebean,并在日志中打印带有最新证书的信息。

最后的想法

Spring Boot 的最新版本极大地简化了服务器和客户端 SSL 证书的管理。借助SslBundles,我们可以在 Kubernetes 上轻松处理证书轮换过程,而无需重新启动 pod。本文未涵盖的还有一些其他事项需要考虑,包括跨应用程序分发信任捆绑包的机制。但是,例如,在 Kubernetes 环境中管理信任捆绑包,我们可以使用"cert-manager"的trust-manager功能。

本文在云云众生yylives.cc/)首发,欢迎大家访问。

相关推荐
陈大爷(有低保)8 分钟前
UDP Socket聊天室(Java)
java·网络协议·udp
kinlon.liu21 分钟前
零信任安全架构--持续验证
java·安全·安全架构·mfa·持续验证
FLGB38 分钟前
Flink 与 Kubernetes (K8s)、YARN 和 Mesos集成对比
大数据·flink·kubernetes
王哲晓42 分钟前
Linux通过yum安装Docker
java·linux·docker
java6666688881 小时前
如何在Java中实现高效的对象映射:Dozer与MapStruct的比较与优化
java·开发语言
Violet永存1 小时前
源码分析:LinkedList
java·开发语言
执键行天涯1 小时前
【经验帖】JAVA中同方法,两次调用Mybatis,一次更新,一次查询,同一事务,第一次修改对第二次的可见性如何
java·数据库·mybatis
Jarlen1 小时前
将本地离线Jar包上传到Maven远程私库上,供项目编译使用
java·maven·jar
蓑 羽1 小时前
力扣438 找到字符串中所有字母异位词 Java版本
java·算法·leetcode
Reese_Cool1 小时前
【C语言二级考试】循环结构设计
android·java·c语言·开发语言