多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

K8s 部署微服务实战:SpringBoot 与 Vue 从镜像到探针避坑指南

K8s 部署微服务实战:SpringBoot 与 Vue 从镜像到探针避坑指南 把一个单体应用拆成微服务再把它从本地的 docker-compose 搬到 K8s 集群上跑起来这件事听起来像是打包、推镜像、写 YAML、apply四步走完就结束但真正做过一遍的人都知道中间能卡住人的地方远不止这些。我做的是典型的 SpringBoot 后端加 Vue 前端的组合后端拆成用户、订单、商品、网关几个服务前端一个 Vue 3 项目本地用 docker-compose 跑得好好的但一上 K8s 就开始出各种幺蛾子——Pod 是 Running 了接口却返回 502配置写死在镜像里改个数据库地址要重新打包前端页面能打开但一调接口就跨域报错。这篇就把这套 K8s 部署微服务的完整流程和最脏最累的坑都摊开讲一遍不管你是第一次接触 K8s 部署还是已经在集群上折腾过几个服务应该都能从里面抄到几段能直接用的配置。1. 镜像这一步没做对后面全是返工——SpringBoot 与 Vue 的打包差异部署微服务第一件绕不开的事就是把代码变成镜像。这一步很多人图省事本地mvn package打好 jar 之后写个几十行的 Dockerfile 往里一塞就完事但等真正上了 K8s镜像体积、构建速度、启动参数这些问题都会一个个冒出来。SpringBoot 和 Vue 这两个东西的镜像构建逻辑完全不是一回事得分开对待。1.1 SpringBoot 的多阶段构建与分层缓存SpringBoot 后端我推荐一律用多阶段构建不要在本机打好包再 COPY 进去。原因很直接本机打包意味着你的构建环境和最终镜像环境解耦了别人拉你的代码想复现镜像时还得先确认自己的 JDK、Maven 版本对不对。多阶段构建把编译过程也写进 Dockerfile谁执行都用同一套环境。一个我常用的模板长这样FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --frombuild /app/target/*.jar app.jar ENV TZAsia/Shanghai ENTRYPOINT [java, -XX:MaxRAMPercentage75.0, -Duser.timezoneAsia/Shanghai, -jar, app.jar]这里有几个细节值得说。第一COPY pom.xml之后先跑一次dependency:go-offline再 COPY 源码这是为了让依赖下载这一层被 Docker 缓存住。你改业务代码的时候只要 pom 没动依赖那层就不会重新下载构建时间能从几分钟压到几十秒。第二运行时基础镜像用 alpine 版的 JRE不要用 JDK构建镜像里才需要 JDK跑起来只需要 JRE体积能小一半以上。再往深一层如果你特别在意镜像分层SpringBoot 2.3 之后官方支持layertools可以把 jar 拆成 dependencies、spring-boot-loader、snapshot-dependencies、application 四层依赖层变化最少镜像推送时只推变化的那层。这一步属于优化不是必须但服务多起来之后推镜像的时间会明显省下来。1.2 Vue 产物为什么要塞进 Nginx 而不是 Node前端这边新人最容易犯的错是把构建完的node_modules和 Node 运行时一起打进镜像里启动的时候跑npm run serve。这是典型的开发思维放到 K8s 上问题很多Node 进程本身占内存、启动慢、还需要额外的进程管理而且这套东西本质上是个开发服务器不该用于生产。正确做法是构建和运行分离构建阶段用 Node 把 dist 打出来运行阶段只用一个 Nginx 托管静态文件FROM node:18-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:1.25-alpine COPY --frombuild /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80这样最终的前端镜像基本就是 Nginx 加上一堆静态文件体积可以做到几十兆启动就是毫秒级。npm ci而不是npm install是因为 ci 严格按 lock 文件装依赖构建结果可复现不会出现我本地能跑、CI 上装出来的依赖不一样这种玄学问题。2. 从 docker-compose 到 K8s资源对象到底该怎么拆镜像有了接下来是把 docker-compose 里那套一个 service 一个容器的思维翻译成 K8s 的资源对象。很多人第一次写 YAML 会犯同一个毛病一个服务写一个巨大的 all-in-one 文件Deployment、Service、ConfigMap 全糊在一起。这种写法短时间看着省事但后面要改配置、要扩副本、要单独回滚的时候你会恨不得把文件拆了重来。2.1 Deployment、Service、ConfigMap 的职责边界先把三个核心对象的职责理清楚。Deployment管的是 Pod 的期望状态用哪个镜像、几个副本、什么探针、什么资源限制。Service管的是访问入口给一组 Pod 提供一个稳定的虚拟 IP 和 DNS 名字Pod 重建、IP 变了都不影响调用方。ConfigMap / Secret管的是配置外置把会变的东西从镜像里拿出来。这三者分开是有道理的。你想想改一个数据库地址按道理不需要动 Deployment因为镜像没变、副本数没变、探针也没变只需要更新 ConfigMap 然后让 Pod 重启加载新配置。如果全糊在一个文件里你每次改配置都要重写整个资源定义一不小心手滑改错了别的字段排查起来非常痛苦。一个后端服务的 Deployment 大致是这样apiVersion: apps/v1 kind: Deployment metadata: name: user-service namespace: micro spec: replicas: 2 selector: matchLabels: app: user-service template: metadata: labels: app: user-service spec: containers: - name: user-service image: registry.example.com/user-service:1.0.0 ports: - containerPort: 8080 envFrom: - configMapRef: name: user-service-config resources: requests: memory: 512Mi cpu: 250m limits: memory: 1Gi cpu: 1000m对应的 Service 就简单很多它只是靠 label selector 选中 PodapiVersion: v1 kind: Service metadata: name: user-service namespace: micro spec: selector: app: user-service ports: - port: 8080 targetPort: 8080 type: ClusterIP注意这里的name: user-service就是集群内部的 DNS 名字。其他服务要调它直接请求http://user-service:8080就行K8s 自带的 DNS 会帮你解析。这一点和 docker-compose 的 service name 逻辑很像是它俩最接近的地方。2.2 一个服务一套 YAML还是分文件管理我的习惯是按服务分目录每个服务目录下再按对象类型拆文件k8s/ user-service/ deployment.yaml service.yaml configmap.yaml order-service/ deployment.yaml service.yaml configmap.yaml frontend/ deployment.yaml service.yaml nginx-configmap.yaml这样每个文件只干一件事改配置找 configmap改副本数找 deployment改端口找 service定位成本极低。部署的时候kubectl apply -f k8s/user-service/一把梭。如果你服务特别多可以考虑用 Kustomize 做 overlay区分 dev、test、prod 环境的差异但项目初期用手动分目录就够了别一上来就上 Helm模板语法学起来也是成本。3. 配置外置让 SpringBoot 在容器里读对环境配置管理是微服务上 K8s 之后变化最大的一个点。以前在服务器上跑 jar改配置就是改application.yml然后重启现在配置要么来自 ConfigMap要么来自环境变量还得考虑环境和镜像的解耦。这里最容易踩的坑是配置写死在镜像里导致同一个镜像没法跨环境用。3.1 ConfigMap 与 Secret 的正确用法原则很简单普通配置进 ConfigMap敏感信息进 Secret。数据库地址、Redis 地址、服务名这些不敏感的放 ConfigMap数据库密码、JWT 密钥、第三方 API 密钥这些放 Secret。虽然 Secret 默认只是 base64 编码而不是加密但它和 ConfigMap 分开管理至少能在权限控制和审计上做得更细。ConfigMap 我一般用文件形式挂application.yml而不是一堆散的环境变量apiVersion: v1 kind: ConfigMap metadata: name: user-service-config namespace: micro data: application-prod.yml: | spring: datasource: url: jdbc:mysql://mysql-service:3306/user_db?useSSLfalseserverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD} redis: host: redis-service port: 6379这里密码用${DB_PASSWORD}占位真实值从 Secret 通过环境变量注入。然后在 Deployment 里把这个 ConfigMap 挂进去并设置SPRING_PROFILES_ACTIVEprodSpringBoot 启动时就会自动加载application-prod.yml。3.2 环境变量覆盖 vs 挂载配置文件两种方式各有场景。环境变量适合少量的、单值的配置比如激活哪个 profile、临时改个开关。挂载文件适合结构化的、层级深的配置比如application.yml里一大堆嵌套的数据库、Redis、日志配置。我的做法是混合用结构性配置走挂载文件需要按环境微调的走环境变量覆盖SpringBoot 里环境变量优先级高于配置文件这个特性很好用。注意ConfigMap 挂载成文件之后如果你改了 ConfigMap 内容挂载进去的文件会有一段时间延迟才更新默认几十秒到一分钟而且 SpringBoot 启动后不会自动重读。所以生产环境改配置稳妥做法是改完 ConfigMap 再手动kubectl rollout restart deployment/user-service让 Pod 重新拉配置。4. Vue 前端在 K8s 里的反向代理与跨域前端上 K8s 之后最典型的问题就是接口调用。本地开发时 Vue 的vue.config.js里配个 proxy 就搞定跨域了但上了集群这个 proxy 是开发服务器的功能生产环境用的是 Nginx得在 Nginx 配置里重新写一遍反向代理。4.1 Nginx 配置里的服务名解析生产环境的 Nginx 配置核心是两段SPA 的 history 路由回退加上 API 反向代理。server { listen 80; server_name _; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://gateway-service:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files那行是给 Vue Router 的 history 模式用的保证刷新页面不会 404。proxy_pass指向的是网关服务的 K8s Service 名字gateway-serviceNginx 从集群 DNS 直接解析这个名字拿到 Pod IP前端和后端就成了同源跨域问题自然消失。这里有个特别容易踩的坑proxy_pass后面带不带斜杠转发路径完全不一样。http://gateway-service:8080/带斜杠请求/api/user/1会被转成/user/1不带斜杠会原样转成/api/user/1。网关层如果路由规则是前缀匹配/api/**那你就得用不带斜杠的写法或者去网关里把前缀去掉。我一开始就是这里没注意接口全部 404查了半天。4.2 Ingress 还是 NodePort前端对外的暴露方式有两种主流选择。小规模、测试环境用NodePort最省事集群任意节点 IP 加一个 30000 以上的端口就能访问不用额外组件。生产环境一般上Ingress用一个统一入口按域名或路径分发还能顺手配上 HTTPS。Ingress 的配置大概是这样apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: frontend-ingress namespace: micro spec: ingressClassName: nginx rules: - host: app.example.com http: paths: - path: / pathType: Prefix backend: service: name: frontend port: number: 80Ingress 的前提是集群里已经装了 Ingress Controller比如 ingress-nginx。如果只是为了内部联调我建议先用 NodePort 把链路跑通别一开始就卡在 Ingress Controller 的安装上等业务跑起来了再补 Ingress思路会清楚很多。5. 探针、资源限制与 JVM 参数Pod 稳定运行的三道保险Pod 能起来不代表服务能正常用。我见过太多情况是 Pod 状态是 Running但接口请求一直超时或者 502最后发现是探针没配、JVM 堆内存被压爆、或者资源限制设得太死。这三样东西合起来决定了你的服务在集群里稳不稳。5.1 liveness/readiness 探针到底怎么写readinessProbe决定 Pod 什么时候能接流量。SpringBoot 启动需要时间如果没配 readinessK8s 会在容器一启动就把流量打进来这时候应用还没初始化完请求自然失败。配上之后K8s 会等探针返回成功才把 Pod 加进 Service 的 Endpoints。livenessProbe决定 Pod 要不要重启。如果应用卡死了探针一直失败K8s 就会重启容器。但要注意liveness 的initialDelaySeconds一定要给够否则 SpringBoot 还没启动完探针就判定失败容器被反复重启进入 CrashLoopBackOff。livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 60 periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 30 periodSeconds: 5 failureThreshold: 3探针路径用的是 SpringBoot Actuator 的健康检查端点。v3.4 之后把 liveness 和 readiness 分开了用起来更精确。老版本如果只有一个/actuator/health那就两个探针都指向它只是把 readiness 的延迟设小一点、liveness 设大一点。提示如果服务没有引入 Actuator就别用 httpGet 探针了用tcpSocket探端口更简单但精确度差一些只能判断端口通不通不判断应用是不是真的健康。5.2 requests/limits 与 JVM 堆内存的配合资源这一块requests是调度依据K8s 按它决定 Pod 能不能被分配到某个节点limits是硬上限超过内存会被 OOMKill超过 CPU 会被限流。很多新人只配 requests 不配 limits或者两个都配但没考虑 JVM。关键点在于JVM 默认会按容器内存限额来算堆大小。如果你 limits 设了 1GiJVM 默认堆大约是 1/4也就是 256Mi对稍微有点规模的服务就不够。我一般会显式指定-XX:MaxRAMPercentage75.0让 JVM 用 75% 的容器内存做堆剩下 25% 留给元空间、线程栈这些堆外开销。对应的资源限量建议给成这样服务类型requests.memorylimits.memoryMaxRAMPercentage轻量网关256Mi512Mi70普通业务服务512Mi1Gi75数据密集型服务1Gi2Gi75注意limits 内存千万别设得和 requests 差距太大否则节点内存吃紧时调度器按 requests 分配了 Pod实际跑起来逼近 limits很容易触发节点层面的驱逐。生产环境我一般让 limits 是 requests 的 1.5 到 2 倍。6. 一次真实的排错Pod 是 Running 但接口返回 502配置写完了最考验人的是排错。有一次订单服务部署完kubectl get pods看全是 Running但前端调接口一直 502网关日志里也是连接超时。这段排查过程很有代表性我按当时的顺序完整复盘一遍。6.1 从网关往前逐层定位第一步确认 502 是谁返回的。是网关返回的说明网关到订单服务的这一跳有问题是 Ingress 或前端返回的方向就不同。当时是网关返回 502所以焦点集中在网关到订单服务的链路。第二步kubectl get endpoints order-service看 Service 有没有真的把 Pod 的 IP 加进去。如果 Endpoints 是空的说明 readinessProbe 一直没过或者 Service 的 selector 和 Pod 的 label 对不上。当时 Endpoints 是有值的说明 readiness 是过的。第三步从网关的 Pod 里直接 curl 订单服务的地址kubectl exec -it gateway-pod -- sh curl http://order-service:8080/actuator/health结果超时。这就把问题缩小到了网关 Pod 能解析 Service但连不上。于是我进订单服务的 Pod 内部curl 自己kubectl exec -it order-pod -- sh curl http://localhost:8080/actuator/health本地是通的说明应用本身没问题。那问题就在 Service 到 Pod 这一层。6.2 根因容器端口和 targetPort 对不上kubectl describe service order-service一看targetPort: 8081但我订单服务的 Dockerfile 和配置文件里实际监听的是 8080。坑就出在这里当时复制别的服务 YAML 改的端口忘了改。这个问题隐蔽在于Service 的 port 是 8080targetPort 是 8081Pod 本身 Health 检查用的是 8080探针能通、Pod 是 Running、Endpoints 也有值但 Service 转发流量时是往 Pod 的 8081 转的那个端口根本没监听请求就被拒了。所以看 YAML 一定要一个字一个字核对端口、命名空间、selector 这些关键字段。修的时候顺手加了条验证习惯部署完先跑一句kubectl exec -it 任一pod -- wget -qO- http://服务名:端口/actuator/health从集群内部真实地拉一次健康检查比看面板状态靠谱得多。7. 上线前我会过一遍的检查清单部署这件事跑通一次不难难的是稳定地反复跑通。所以我给自己整理了一份上线前的检查清单每套服务上线前照着过一遍能挡掉大部分低级问题。镜像层面确认是不是多阶段构建、运行时是不是用的 JRE 而不是 JDK、镜像 tag 是不是明确版本号千万别用 latest回滚的时候你就知道痛了。配置层面确认敏感信息进的是 Secret、结构性配置进的是 ConfigMap、profile 有没有设对。资源层面确认 requests/limits 都配了、JVM 内存参数和 limits 匹配。探针层面确认 readiness 和 liveness 分开配、延迟给够、路径能在容器内 curl 通。网络层面确认 Service 的 port 和 targetPort 和容器实际监听一致、Service 名字和调用方代码里写的一致。检查项常见问题验证命令镜像 tag用了 latest 导致回滚困难kubectl describe pod看 imageConfigMap 挂载改完没重启 Pod 不生效kubectl rollout restart探针延迟启动慢导致 CrashLoopBackOff看 Pod 事件kubectl describe端口映射targetPort 和监听端口不符进 Pod curl localhostDNS 解析跨命名空间调用要先建 Servicenslookup 服务名这套流程我从最早的 docker-compose 迁移到 K8s 花了差不多两周才理顺。最开始那几天一个服务部署下去能冒出五六个问题配置读不到、探针把 Pod 反复重启、前端跨域、服务之间调不通、时区差 8 小时导致订单时间全乱。时区这个还得单独说一句容器默认 UTC不显式设TZAsia/Shanghai或者 JVM 参数-Duser.timezoneAsia/Shanghai你的 log 时间戳和数据库时间全会对不上。这些坑一个一个趟过来之后现在的部署流程基本能做到新服务半小时内上集群、出问题十分钟定位。真正让部署变简单的不是工具本身而是把每一步为什么这么做想清楚把那些一次性的、猜出来的配置变成有依据、能复现的标准动作。
返回列表