如何在 Kubernetes 集群内调试 Spring Boot 应用程序

Kubernetes使用template部署图

在第一篇文章中,我们构建了一个可独立运行的原型(对应上述第1点),并成功将其连接至我们Kubernetes集群内部托管的数据库(对应上述第2点)。在本文里,我们将对该原型进行升级,使其能够在Kubernetes环境中运行(对应上述第3点),同时支持在集成开发环境(IDE)中对我们的应用程序进行调试。

假设你已经完成了之前的步骤,此时你的Spring Boot应用程序应已具备独立模式(standalone)和连接模式(connected)两种运行方式。这也就意味着你已经搭建好了本地的Kind Kubernetes集群,并且完成了Grafana监控、PostgreSQL数据库以及Vault密钥管理器的配置。

你可以在 GitHub 上获取到本文所涉及的代码及配置文件。

那么,接下来我们要做什么呢?

在本文中,我们将对应用程序进行一系列设置,使其能够在本地的Kind Kubernetes集群中顺利运行与调试。

除了启用远程调试功能,我们还会利用Vault中的静态凭证来解决数据库凭证相关问题。这样一来,既能避免因长时间调试会话以及密码轮换所引发的各类问题,又能实现与Vault的无缝集成。

Kubernetes-端口映射

总而言之,我们将用上在Kind集群配置中所暴露的所有端口,具体情况如上图所示。

k8s-debug配置文件

现在,我们要将这个配置文件添加到原型之中。在这个名为application-k8s-debug.yml的配置文件里,我们的目标是将应用程序部署到集群内部。鉴于要在集群内部调试应用程序,我们需执行以下操作:

  • 通过内部服务地址,连接到集群数据库。
  • 从Vault获取数据库密码,将其作为静态密钥。
  • 向集群中的Java虚拟机(JVM)暴露调试端口。
  • 启用日志记录功能,以便与Grafana和Loki协同工作。

数据库连接详情

首先,我们得更改数据源,以此访问PostgreSQL数据库集群(db-cluster-rw.pg.svc)所暴露的内部服务。针对这一操作,有几点需要留意:

  • 我们不指定集群名称,如此一来,你便能在不同名称的集群中使用该配置。
  • 我们选用 - rw服务,原因在于它代表的是可接受读写命令的实例。
  • 我们把pg作为数据库的命名空间。

数据库凭证

鉴于我们正借助k8s-debug配置文件开发,所以我们将采用一种简单的键值对密钥形式,为应用程序提供固定的数据库用户名(app-user)和密码(app-secret)。这样做能够规避调试期间密码轮换带来的问题。我们会从Vault获取这些信息,从而实现集成。

在配置文件中,我们会把用户名和密码分别设置为环境变量STATIC_DB_USERNAMESTATIC_DB_PASSWORD。以下是相关代码片段:

src/main/resources/application-k8s-debug.yml

spring:
  datasource:
    url: jdbc:postgresql://db-cluster-rw.pg.svc:5432/myapp?currentSchema=myapp
    username: ${STATIC_DB_USERNAME}
    password: ${STATIC_DB_PASSWORD}
...

此刻,我们必须在部署文件中设置这些环境变量。我们将从default命名空间下的static-db-credentials Kubernetes密钥里获取这些变量。以下是文件中的代码片段:

[k8s/k8s-debug-deployment.yml](https://github.com/MartinHodges/spring-boot-k8s-template/blob/main/k8s/k8s-debug-deployment.yml)

...
        env:
          ...

          - name: STATIC_DB_USERNAME
            valueFrom:
              secretKeyRef:
                name: static-db-credentials
                key: username
          - name: STATIC_DB_PASSWORD
            valueFrom:
              secretKeyRef:
                name: static-db-credentials
                key: password

从 Vault 创建密钥

接下来,我将展示如何从 Vault 中提取密钥,并将其作为 Kubernetes 密钥添加到我们的集群。虽然这里我们主要针对数据库凭证进行操作,但这种方法对于任何想从 Vault 注入到 Spring Boot 应用程序的内容都非常实用。

首先,我们要在 Vault 中创建一个键值对密钥。需要注意的是,Vault 的键值(KV)引擎允许在单个 KV 密钥中存储多个属性,在我们的场景中,就是用户名和密码。

vault-0 Pod 中开启一个命令行,登录到 Vault,启用键值密钥引擎,然后创建密钥:

kubectl exec -it vault-0 -n vault -- sh
vault login <root token>
vault secrets enable -path=spring-boot-k8s-template kv-v2
vault kv put -mount=spring-boot-k8s-template db username=app-user password=app-secret

我们使用的是 KV - V2 引擎,它支持版本化的密钥。我们将其挂载在 spring - boot - k8s - template 路径下。

现在,我们在 spring - boot - k8s - template/db 处已经有了包含 usernamepassword 字段的密钥。

接下来,我们要把这些密钥提取到一个 Kubernetes Secret 中。我们将使用 Kubernetes ExternalSecrets,并借助外部密钥操作符(ESO)来管理 SecretStore。这个存储会从 Vault 中获取密钥,并将其作为 Kubernetes Secret 注入到我们的集群。之后,我们就可以像使用普通的 Kubernetes Secret 一样使用这个密钥,比如将它们作为环境变量注入到容器中。

我们通过 Helm 来部署 ESO。先把所需的仓库添加到本地 Helm 中:

helm repo add external-secrets https://charts.external-secrets.io
helm repo update

我们将把 ESO 安装到一个新创建的独立命名空间中:

kubectl create namespace eso
helm install external-secrets external-secrets/external-secrets -n eso

现在,我们来创建一个 SecretStore,它代表外部密钥的提供者,也就是我们的 Vault。通过以下文件来完成:

k8s/secret-store.yml

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: vault-backend
  namespace: default
spec:
  provider:
    vault:
      server: "http://vault-active.vault:8200"
      path: "spring-boot-k8s-template"
      version: "v2"
      auth:
        # 指向包含 Vault 令牌的密钥
        tokenSecretRef:
          name: "vault-token"
          key: "token"

注意,我们把 SecretStore 部署到了 default 命名空间,这也是我们的 Spring Boot 原型将要运行的命名空间。如果你打算在其他命名空间运行应用程序,这里也需要相应更改。

此外,我们将挂载点指定为 path: "spring-boot-k8s-template",以和之前启用密钥引擎时使用的名称保持一致。

在使用 SecretStore 之前,我们必须设置它要使用的令牌(即 vault-token 密钥中的 token 键)。

要为这个配置文件创建一个静态令牌,需要在 Vault Pod 上开启一个命令行,使用根访问令牌来创建新令牌。

kubectl exec -it vault-0 -n vault -- sh
vault token create -period 0

如果你使用根令牌登录后创建了新令牌,新令牌会生成,结果大致如下:

Key                  Value
---                  -----
token                hvs.Lw3Kv46jTCT2OMXFXjHWettD
token_accessor       phSvwm5lrGqQvclXS5qKdC1H
token_duration       ∞
token_renewable      false
token_policies       ["root"]
identity_policies    []
policies             ["root"]

然后可以将此令牌保存为 Kubernetes 密钥(将 < > 字段替换为合适的值):

kubectl create secret generic vault-token --from-literal=token='<上面的令牌>'

注意,Kubernetes 在使用 --from -literal 创建密钥时会自动处理 Base64 编码。

现在使用以下命令创建 SecretStore

kubectl apply -f k8s/secret-store.yml

并检查它是否已启动:

kubectl get secretstore
NAME            AGE   STATUS   CAPABILITIES   READY
vault-backend   2s    Valid    ReadWrite      True

注意,状态为 Valid(表示可以登录到 Vault),并且 readyTrue

现在,我们从 Vault 为包含用户名和密码的 static-db-credentials 创建外部密钥(同样在默认命名空间中)。创建以下文件:

k8s/external-secrets.yml

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: myapp-db-username
  namespace: default
spec:
  refreshInterval: "15s"
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: static-db-credentials
    creationPolicy: Owner
  data:
  - secretKey: username
    remoteRef:
      key: spring-boot-k8s-template/db
      property: username
  - secretKey: password
    remoteRef:
      key: spring-boot-k8s-template/db
      property: password

并应用此配置:

kubectl apply -f k8s/external-secrets.yml

现在检查密钥是否已创建:

kubectl get secret static-db-credentials -o jsonpath={.data}

结果应该是数据库的 Base64 编码的用户名(app-user)和密码(app-secret)。

创建我们的镜像文件

要让我们的应用程序在集群中运行,就需要创建一个 Docker 镜像。

而创建镜像,首先得有一个可执行的 JAR 文件。

我们在构建文件里添加以下代码片段:

build.gradle

jar {
    manifest {
        attributes "MainClass":"com.requillion_solutions.sb_k8s_template.MainApplication"
    }
}

现在,我们就能通过集成开发环境(IDE)来构建 JAR 文件了。以 IntelliJ 为例,如果在项目里看不到 Gradle 工具图标,可以通过以下操作把它打开:

视图 -> 工具窗口 -> Gradle

之后,展开 Tasks,再展开 build,会看到一个选项列表,双击 build 选项。

这样,就会在项目文件夹的 build/libs 文件夹中生成一个可执行的 JAR 文件。

接下来,要基于这个 JAR 文件创建 Docker 镜像。这就需要一个 Dockerfile 来明确镜像中包含哪些内容。由于我们要对这个版本的模板进行远程调试,所以专门为这个配置文件创建一个镜像:

Docker/Docker.k8s.debug

FROM openjdk:17.0.2-slim-buster
RUN addgroup --system spring && useradd --system spring -g spring
USER spring:spring
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000","-jar","/app.jar"]
EXPOSE 8000 8080 8081

这里有几点需要留意:

  • 基于 slim-buster 镜像运行,这样我们就能在 Pod 中访问命令行,还能使用一些熟悉的工具。
  • 为避免以 root 用户身份运行,我们创建了一个新用户(spring)。
  • 复制 *.jar 文件,这样可以忽略 JAR 文件名中的版本信息。
  • 启用了远程调试(-agentlib…)。

这里我们不设置 Spring 配置文件,因为在部署文件中通过环境变量来设置会更好。

下面是在 Kubernetes 部署清单中设置 Spring 配置文件的代码片段:

k8s/k8s-debug-deployment.yml

...
        env:
          # 请注意,以下环境变量在被 Spring 读取时会转换为
          # 一个名为 spring.profiles.active 的属性覆盖项
          - name: SPRING_PROFILES_ACTIVE
            value: k8s-debug
...

在项目文件夹中,使用以下命令来构建 Docker 镜像:

docker build -t sb-k8s-template:01 -f Docker/Dockerfile.k8s.debug .

虽然这些操作目前是手动完成的,但在部署到非本地环境时,可以通过持续集成/持续交付(CI/CD)管道实现自动化。

现在,你就可以进行下一步操作了。这一步是把镜像加载到集群中,然后进行部署。

如果你用的是我之前提到的 Kind 集群,就可以直接把镜像加载到节点上,不用推送到仓库。使用以下命令操作:

kind load docker-image sb-k8s-template:01

现在,我们可以进行最后一步了,也就是用这个镜像在集群中部署一个 Pod,并添加 NodePort 服务来访问它。

部署文件

这是在我们的 Kubernetes 集群中部署并运行应用程序的最后一步。下面,让我们将所有配置整合到部署文件中:

k8s/k8s-debug-deployment.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sb-k8s-template
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: sb-k8s-template
  template:
    metadata:
      labels:
        app: sb-k8s-template
    spec:
      containers:
      - name: sb-k8s-template
        image: sb-k8s-template:01
        imagePullPolicy: IfNotPresent
        ports:
          - containerPort: 8080
        env:
          # 注意,以下环境变量在被 Spring 读取时会转换为
          # 名为 spring.profiles.active 的属性覆盖项
          - name: SPRING_PROFILES_ACTIVE
            value: k8s-debug
          - name: STATIC_DB_USERNAME
            valueFrom:
              secretKeyRef:
                name: static-db-credentials
                key: username
          - name: STATIC_DB_PASSWORD
            valueFrom:
              secretKeyRef:
                name: static-db-credentials
                key: password
---
apiVersion: v1
kind: Service
metadata:
  name: sb-k8s-svc
  namespace: default
spec:
  selector:
    app: sb-k8s-template
  type: NodePort
  ports:
    - port: 8080
      targetPort: 8080
      nodePort: 30000
---
apiVersion: v1
kind: Service
metadata:
  name: sb-k8s-debug-svc
  namespace: default
spec:
  selector:
    app: sb-k8s-template
  type: NodePort
  ports:
    - port: 8000
      targetPort: 8000
      nodePort: 30500

关于这个部署文件,有以下要点需要了解:

  • 部署到 default 命名空间。
  • 仅请求创建 1 个副本。
  • 拉取我们之前创建的 Docker 镜像(sb-k8s-template:01)。
  • 由于你应该已经直接将镜像加载到集群,所以不会再次拉取镜像。
  • 通过环境变量设置数据库的用户名和密码。
  • 部署了两个 NodePort 服务,将应用程序暴露在 30000 端口,将调试端口暴露在 30500 端口。

现在,开始进行部署操作:

kubectl apply -f k8s/k8s-debug-deployment.yml

使用以下命令检查 Pod 是否成功启动:

kubectl get pods

你应该会看到类似如下的输出:

NAME                               READY   STATUS    RESTARTS   AGE
sb-k8s-template-7c8fd874dc-p5r4n   1/1     Running   0          9m55s

恭喜!现在你的 Spring Boot 应用程序已经在本地 Kubernetes 集群中成功运行,并且已连接到 PostgreSQL 数据库。

日志记录

在结束本文之前,还有两个要点需要关注:远程调试与日志记录。我们先从日志记录说起。

如果一直依照本文步骤操作,你的集群中应当已安装好Loki和Grafana。鉴于Promtail日志收集器在节点层面的工作机制,此时无需额外配置。

接下来,我们检查一下它们是否能识别你的Spring Boot应用程序。

若要访问Grafana控制台,可访问:

http://localhost:31300

你会被导向一个登录界面。你或许还记得,要获取登录凭证,需通过以下命令从Kubernetes密钥中查找:

kubectl get secret loki-grafana -n monitoring -o jsonpath={.data.admin-user} | base64 -d; echo
kubectl get secret loki-grafana -n monitoring -o jsonpath={.data.admin-password} | base64 -d; echo

使用获取到的用户名和密码登录后,在主菜单下选择“Explore”,并选择数据源为“Loki”。

添加一个过滤器,用以查找app标签为sb-k8s-template的日志。倘若你熟悉Grafana的操作,也可使用这个过滤代码:

{app="sb-k8s-template"} |= ``

点击“Run Query”,此时你应该就能看到Spring Boot的日志了!

如果你使用以下命令获取所有鱼类数据:

curl localhost:30000/api/v1/fishes

在刷新Grafana查询后,你应该能看到该请求被记录下来,同时还会看到Hibernate执行的SQL语句。

虽然Grafana没有类似tail的功能,但你可以设置让它每5秒或10秒刷新一次查询。

远程调试

这个配置文件的最后一部分内容,是在应用程序于集群中运行时对其进行调试。我使用的是IntelliJ,下面将针对此IDE给出相关说明。Eclipse同样支持远程调试。

我们已在Dockerfile的ENTRYPOINT行中,通过额外参数在JVM上启用了远程调试功能。在部署清单中,还通过NodePort服务暴露了调试端口,随后该端口会经由Kind配置转发至我们的开发机器。这就为我们调试应用程序创造了条件。

请注意,在生产环境中绝不能如此操作。所有远程调试配置都应删除。

假设你已在IntelliJ中基于我们正在开发的这个Spring Boot原型创建了一个项目。

前往主菜单,选择“Run” -> “Edit Configurations…”。

在弹出的“Run/Debug Configurations”对话框中,点击左上角的“+”号以添加一个新配置,选择“Remote JVM Debug”选项。

给这个配置命名,例如“sb-k8s-template-remote”。选择“Attach to remote JVM”作为“Debugger mode”。输入“Host”为“localhost”,“Port”为“30500”。

弹出框会给出JVM命令行参数,但我们忽略这些参数,因为NodePort提供了端口转换,这意味着这些参数在我们的场景中并不适用。

点击“Ok”。

现在,我们就能够调试在Kubernetes集群中运行的应用程序了。从主菜单中选择“Run” -> “Debug…”,点击你设置的名字(例如“sb-k8s-template-remote”),调试器应该会连接到你的应用程序,并提示连接成功。

此刻,你可以设置断点了,比如在获取所有鱼类数据的控制器端点处设置断点,然后触发一个请求,你会看到程序在断点处暂停运行。

恭喜你,现在你已能够在本地Kubernetes集群中调试Spring Boot应用程序了!

进行测试

这个原型所提供的简单应用程序设有多个端点,你可以借助curl或Postman等工具对这些端点展开测试。具体如下:

  • GET localhost:30000/api/v1/fishes :获取所有鱼类。
  • GET localhost:30000/api/v1/fishes/{fishID} :获取指定的鱼类。
  • POST localhost:30000/api/v1/fishes :创建鱼类记录。
  • GET localhost:30000/api/v1/fish - tanks :获取所有鱼缸。
  • GET localhost:30000/api/v1/fish - tanks/{fishTankID} :获取指定的鱼缸。
  • POST localhost:30000/api/v1/fish - tanks :创建鱼缸记录。
  • PUT localhost:30000/api/v1/fish - tanks/{fishTankID}/fishes/{fishID} :将指定的鱼放入指定的鱼缸中。
  • DELETE localhost:30000/api/v1/fish - tanks/{fishTankID}/fishes/{fishID} :从指定的鱼缸中移除指定的鱼。

倘若你使用Postman进行测试,最好创建与配置文件相匹配的环境,并为每个环境定义一个名为port的变量。接着,把上述URL中的30000替换为{{port}}。如此一来,你只需切换环境,就能利用相同的集合对每个配置文件进行测试。

请记住,虽然我们当前是在本地的Kubernetes集群中运行这个应用程序,但要让它在其他任何集群中运行,所需做出的改动极少。在我后续关于为生产环境准备应用程序的文章中,大家将会看到这一点。

总结

在本文中,我们对上一篇文章中开始构建的Spring Boot原型进行了扩展。通过添加Docker和Kubernetes相关文件,我们得以构建一个Docker镜像,并将其部署到Kubernetes集群中。

完成上述操作后,我们能够运用Grafana和Loki对其进行监控,而且当应用程序在集群中运行时,还能对其进行远程调试。

我们还借助Vault为应用程序提供密钥,并利用这些密钥来访问数据库。

这使得我们能够着手探索在Kubernetes集群中开展更复杂的集成。

希望你喜欢这篇文章,并且至少能从中有所收获。

如果你觉得这篇文章有意思,请给我点赞,因为这有助于我了解大家认为哪些内容实用,以及我未来该撰写哪些文章。要是你有任何建议,欢迎以评论或回复的形式提出。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由技术书栈整理,本文链接:https://study.disign.me/article/202509/15.kubernetes-debugging-spring-boot.md

发布时间: 2025-02-26

原文阅读: https://medium.com/@martin.hodges/debugging-your-spring-boot-application-inside-your-kubernetes-cluster-b05927791ed5