Vue+Spring Boot项目K8s迁移实战:镜像构建与服务编排全解析

发布时间:2026/10/19 14:58:48
Vue+Spring Boot项目K8s迁移实战:镜像构建与服务编排全解析 最近把团队维护的一套前后端分离项目整体迁到了K8s上前端是Vue 3跑Nginx容器后端是Spring Boot跑JVM容器中间踩了探针误杀、Ingress路由404、镜像构建缓存失效这些坑最后梳理出了一条可以直接复用的部署路径。这篇内容不是K8s入门科普而是给准备把Vue Spring Boot项目正经部署到K8s集群、并且打算长期维护的同学一份实战参考包含完整的Dockerfile、Deployment、Service、Ingress配置以及我在实际排障时的完整思路。你不需要K8s多精通但至少要知道Pod、Service、Deployment、Ingress这几个基础概念是什么意思跟着往下做基本能跑通。1. 前后端分离项目迁到K8s的起因与三条前置思考1.1 项目现状与容器化改造的边界要部署的项目是某公司一套内部运营管理平台技术栈是Vue 3 Vite Element Plus后端是Spring Boot 3 MyBatis-Plus依赖MySQL和Redis。传统部署方式是前端打包成静态文件扔到一台Nginx服务器后端打成jar包用systemd或裸java进程跑在同一台ECS上。平时开发环境、测试环境、生产环境完全靠人工切换配置每次发版都是深夜操作回滚要找回旧包手动替换十分痛苦。迁移到K8s之前我先把改造边界想清楚了第一容器化做到哪一层。不是单纯写个Dockerfile就完事还要考虑构建产物的体积、静态资源的服务方式、配置如何外部化、日志如何收集、健康检查如何设计。第二哪些组件放集群内、哪些放集群外。MySQL和Redis我一开始就决定放在集群外的托管数据库里。K8s里跑有状态服务不是不行但持久化存储、备份恢复、主从切换的运维成本明显更高对于这种规模的项目完全没必要自找麻烦。第三访问链路到底怎么设计。外部流量经过Ingress进入集群前端Nginx容器对外服务前端页面里发起的API请求最终落到后端Pod。这个链路里域名如何分流、前端Nginx要不要做反向代理、后端Service如何被访问都要提前规划好不然后面改起来会很折腾。1.2 K8s相比传统部署解决的实际问题很多人把K8s当成一个能把容器跑起来的平台其实它的核心价值在于让应用以可声明、可恢复、可扩展的方式运行。传统ECS部署的痛点很明显手动SSH上去替换jar包升级错了要翻历史包流量上来了想扩容得先买机器再配环境半个小时能搞定算快的一台机器宕机整个服务就断了只能等运维人工介入。K8s把这些问题给了解法声明式API配上GitLab CI/CD代码合并后自动构建镜像自动更新Pod级自愈节点挂了Pod自动调度到其他节点HPA可以根据CPU使用率自动扩容。发布和回滚都变成了kubectl命令操作比SSH上去手动替换文件安全得多。但我也得说句实话K8s不是银弹。如果你的项目只有一个后端服务加一个前端页面用户量几百人用Docker Compose在单机跑完全够用。上了K8s意味着你得接受一套新的运维复杂度——探针怎么写、资源配额怎么设、日志怎么收集、网络策略怎么配每一样都需要投入时间去学习和排错。这个账要提前算清楚。2. 镜像构建实战Vue多阶段构建与Spring Boot轻量化方案2.1 前端Vue项目的多阶段Dockerfile设计前端构建产物是纯静态文件部署方式有两个选择把dist目录打进Nginx镜像里用独立容器跑或者把前端打出来的包塞到Spring Boot的静态资源目录里由后端统一托管。我最终选了后者独立Nginx容器的方式。原因是前后端彻底解耦之后前端可以单独扩缩容、单独发布Nginx的配置也能在前端镜像里独立管理不会因为一次后端发版连带影响前端。前端Dockerfile用多阶段构建既保证构建环境干净又让最终运行镜像只保留运行期必要文件# 构建阶段 FROM node:18-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --registryhttps://registry.npmmirror.com COPY . . ARG VITE_API_BASE_URL/api ENV VITE_API_BASE_URL$VITE_API_BASE_URL 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这里有两个细节值得说。第一个是npm ci和npm install的区别。npm ci严格按照 package-lock.json 安装依赖不会去重新解析依赖树构建速度更快而且能保证CI环境里每次装出来的依赖版本完全一致。如果你用npm install一旦lock文件有细微变动或者依赖版本漂移就有可能在构建时埋下本地好好的、线上构建出问题的隐患。第二个是构建参数。ARGE和ENV的组合使用可以在docker build --build-arg VITE_API_BASE_URL/api时把变量传给构建过程Vite在编译时会通过import.meta.env.VITE_API_BASE_URL读取它。前端的nginx.conf需要注意两个点server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # Vue Router history模式刷新不404的关键 location / { try_files $uri $uri/ /index.html; } # 图片、字体等静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$ { expires 30d; add_header Cache-Control public, no-transform; } # 反向代理到后端服务可选方案 location /api/ { proxy_pass http://springboot-service:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }如果Vue Router用的是history模式location /下面必须有try_files $uri $uri/ /index.html否则直接在浏览器里访问非首页路由就会404。这个问题在我第一次部署时真实遇到过后面细讲。2.2 后端Spring Boot应用的轻量化镜像与启动优化Spring Boot项目的镜像构建关键是利用多阶段构建把体积降下来同时让构建缓存尽可能复用。先看Dockerfile# 构建阶段 FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # 运行阶段 FROM eclipse-temurin:17-jre-alpine WORKDIR /app RUN addgroup -S appgroup adduser -S appuser -G appgroup USER appuser COPY --frombuild /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]几个细节说明一下。首先COPY pom.xml .之后先跑mvn dependency:go-offline这样Maven会把所有依赖下载好并缓存进这一层之后每次改代码重新构建时只要pom.xml没变依赖层就不会重新下载构建速度快很多。其次运行镜像选了jre-alpine而不是完整的JDK镜像体积小很多。但要注意Alpine镜像基于musl libc如果应用里用了某些依赖本地化组件或者需要glibc支持的原生库可能会遇到问题这种情况建议换eclipse-temurin:17-jre-jammy。另外一点容器里不要用root用户跑Java进程。我在Dockerfile里创建了appuser用户再USER appuser属于常规安全实践避免容器被攻破后直接拥有宿主机root权限。最后是JVM参数。容器环境里一定要显式限制堆内存否则JVM默认也会认为它支持宿主机全量内存很容易在Pod触发OOMKilled。我通过环境变量注入JAVA_OPTS-Xms256m -Xmx512m -XX:MaxMetaspaceSize256m -XX:UseG1GC然后在启动命令里使用ENTRYPOINT [sh, -c, java $JAVA_OPTS -jar app.jar]如果你用的Spring Boot 3.x还可以用layertools把jar包拆成分层镜像实现依赖层构建一次、业务层单独推送的效果发布体验会有质的提升这里篇幅有限不展开有兴趣的可以自己查一下。3. Kubernetes核心编排Deployment、Service与Ingress逐个拆解3.1 后端Deployment与Service探针与资源限制是重点后端Deployment是整个部署的核心我直接给出一个经过验证的完整配置apiVersion: apps/v1 kind: Deployment metadata: name: springboot-app namespace: dev spec: replicas: 2 selector: matchLabels: app: springboot-app strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1 template: metadata: labels: app: springboot-app spec: containers: - name: springboot-app image: registry.example.com/dev/springboot-app:latest imagePullPolicy: IfNotPresent ports: - containerPort: 8080 env: - name: SPRING_PROFILES_ACTIVE value: prod - name: DB_PASSWORD valueFrom: secretKeyRef: name: app-secret key: db-password resources: requests: cpu: 250m memory: 512Mi limits: cpu: 1 memory: 1Gi readinessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 60 periodSeconds: 20 timeoutSeconds: 3 failureThreshold: 3这个配置有几个值得琢磨的地方。第一readiness探针和liveness探针必须分开理解。readiness是这个Pod能不能开始接流量如果它返回失败Service和Endpoint会把这个Pod摘掉但Pod本身不会重启liveness是这个Pod还活着吗如果它连续失败Kubelet会直接杀掉容器重启。对于Spring Boot应用二者都用/actuator/health端点但initialDelaySeconds要留足余量。我的项目启动时间大约25秒readiness的initialDelaySeconds设了30秒liveness设了60秒避免启动慢导致探针误杀。第二resources里的requests和limits必须写。很多人上线不写resourceK8s调度器会把Pod当成零资源占用来调度一旦节点上有几个这样的Pod内存打爆的概率极高。requests是调度依据limits是运行时限制。这里CPU的units是核的千分之一250m就是0.25核内存512Mi是请求值上限1Gi。写的时候参考压测数据不要凭感觉乱标。第三imagePullPolicy: IfNotPresent在测试环境能省去重复拉镜像的时间但生产环境强烈建议Always或者使用带tag的镜像否则latest相同tag时新镜像可能不会被拉取。Service配置相对简单apiVersion: v1 kind: Service metadata: name: springboot-service namespace: dev spec: selector: app: springboot-app ports: - protocol: TCP port: 8080 targetPort: 8080 type: ClusterIPClusterIP类型足够因为后端服务不需要直接暴露到集群外部前面有Ingress和前端Nginx做入口。3.2 前端Deployment与Service静态资源服务细节前端Deployment因为就是Nginx进程配置简单很多apiVersion: apps/v1 kind: Deployment metadata: name: vue-web namespace: dev spec: replicas: 2 selector: matchLabels: app: vue-web template: metadata: labels: app: vue-web spec: containers: - name: vue-web image: registry.example.com/dev/vue-web:latest imagePullPolicy: IfNotPresent ports: - containerPort: 80 resources: requests: cpu: 50m memory: 64Mi limits: cpu: 250m memory: 256Mi readinessProbe: httpGet: path: / port: 80前端容器资源给得很小因为Nginx静态服务本身非常轻。不过如果你在nginx.conf里开了一大堆代理配置、缓存、gzip或者流量很大建议结合压测数据适当上调。前端静态资源有个优化点很容易被忽略Nginx默认不启用gzipVite构建出来的js和css文件经过gzip压缩后体积往往能减小60%以上。我在nginx.conf里加了gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xmlrss image/svgxml; gzip_min_length 1024;同时把图片、字体这类静态资源的缓存时间设长一点减少重复请求。前端的Service和后端一样简单端口80映射到容器80。3.3 Ingress配置同一个域名如何把流量精确分流前后端共用一个域名通过路径区分是比较通用的方案。比如https://admin.example.com访问前端https://admin.example.com/api访问后端API。apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: app-ingress namespace: dev spec: ingressClassName: nginx rules: - host: admin.example.com http: paths: - path: / pathType: Prefix backend: service: name: vue-web port: number: 80 - path: /api pathType: Prefix backend: service: name: springboot-app port: number: 8080这里有一点要特别说明前端页面里的API请求浏览器最后访问的是https://admin.example.com/api它会被Ingress直接转发到后端Service不再经过前端Nginx。所以我之前nginx.conf里那段/api/的proxy_pass实际上是备选方案最终没有用上。这里存在两个可选架构思路方案A我最终采用的Ingress按路径分流到后端Service前端Nginx只服务静态文件。优点是链路短、少一跳代理、后端Service的负载均衡能力能直接生效缺点是前端Nginx里无法做URI重写。方案BIngress全部指向前端Nginx前端Nginx内部再proxy_pass到后端Service。优点是在Nginx层可以做更细粒度的请求处理比如按路径重写、限流缺点是浏览器请求会多经过一层代理排查链路时也多点干扰。选哪种取决于你对Nginx的依赖程度。如果你的前端有大量api路径需要重写方案B更灵活如果只是把/api原样透传给后端方案A简洁高效。4. 配置管理细节ConfigMap与Secret的正确拆分姿势4.1 前端运行时配置的动态注入方案前端配置有个常见的坑构建时把接口地址写死到JS文件里。如果是这样Dev、Test、Prod环境各自要打一个镜像维护成本极高。我的解法很直接构建时统一使用相对路径/api前端所有接口请求都以/api开头这样同一个前端镜像在任何环境都能跑环境差异完全交给Ingress和反向代理处理。但有些业务场景不能只用相对路径比如第三方平台回调地址、单独的CDN域名、授权跳转地址等这些必须在运行时区分环境。我的方案是运行时注入环境变量。在镜像里加一个启动脚本docker-entrypoint.sh#!/bin/sh cat /usr/share/nginx/html/config.js EOF window.RUNTIME_CONFIG { CALLBACK_URL: ${CALLBACK_URL}, CDN_BASE_URL: ${CDN_BASE_URL} } EOF exec nginx -g daemon off;然后在index.html里引入config.js前端代码通过window.RUNTIME_CONFIG读取配置。Deployment里通过ConfigMap注入这些环境变量这样同一个镜像配合不同ConfigMap就能在不同环境中跑出不同配置。这个思路的适用范围很广不只是Vue任何纯静态前端项目都能用。4.2 后端数据库连接与密钥的Secret管理数据库密码、Redis密码、JWT密钥这类敏感信息绝对不能写进镜像或代码仓库。K8s里的Secret就是干这个的。创建方式kubectl create secret generic app-secret \ --from-literaldb-urljdbc:mysql://rm-xxx.mysql.cn:3306/app?useSSLfalse \ --from-literaldb-usernameappuser \ --from-literaldb-passwordyour-password \ --from-literalredis-passwordyour-redis-password \ --from-literaljwt-secretyour-jwt-secret在Deployment里通过env注入env: - name: DB_URL valueFrom: secretKeyRef: name: app-secret key: db-url - name: DB_USERNAME valueFrom: secretKeyRef: name: app-secret key: db-usernameSpring Boot的application.yml里用占位符读取环境变量spring: datasource: url: ${DB_URL} username: ${DB_USERNAME} password: ${DB_PASSWORD} data: redis: password: ${REDIS_PASSWORD}这里必须强调一个很多人忽略的安全事实Secret里的值在etcd中默认只是base64编码不是加密存储。如果etcd被攻破或备份泄露Secret里的数据等于明文。生产环境建议开启集群层的etcd加密或者在应用层做更强的密钥保护我是把Secret和云厂商的凭据管理服务做了集成后续需要增强的话可以直接换。另外配置项要分清楚哪些进ConfigMap、哪些进Secret。非敏感的日志级别、功能开关、回调URL、域名配置放ConfigMap敏感的密码、密钥、token放Secret。不要图省事全塞进Secret也不要反过来。5. 上线排障实录从Pod异常到Ingress 404的逐层排查5.1 首次上线的操作顺序与kubectl命令串K8s里对象的创建顺序不算严格但为了出问题时方便排查我习惯按依赖顺序执行# 创建命名空间和基础配置 kubectl create namespace dev kubectl apply -f secret.yaml -n dev kubectl apply -f configmap.yaml -n dev # 部署后端 kubectl apply -f springboot-deployment.yaml -n dev kubectl apply -f springboot-service.yaml -n dev # 部署前端 kubectl apply -f vue-deployment.yaml -n dev kubectl apply -f vue-service.yaml -n dev # 最后创建Ingress kubectl apply -f ingress.yaml -n dev执行完kubectl apply之后先看一眼Pod状态kubectl get pod -n dev -o wide如果出现ContainerCreating用kubectl describe pod pod-name -n dev看事件。我第一次上线时Pod卡在ContainerCreating很久describe一看是镜像拉取失败镜像名里有个拼写错误。这种问题用describe能直接定位不用瞎猜。5.2 从Pod到访问链路的逐层排查方法上线期间我遇到的所有问题基本都能按Pod → Service → Endpoints → Ingress → 域名解析这条链路逐层查清。下面列几个真实遇到过的典型问题。问题一Pod Running但readiness探针一直失败现象是Pod状态显示Running但READY列是0/1前端调用后端接口时报502。这时先用端口转发直接连到Pod里看kubectl port-forward pod/springboot-app-xxxx 8080:8080 -n dev curl http://localhost:8080/actuator/health如果返回的health是DOWN去查看detail部分Spring Boot Actuator会把哪一项检查失败写得清清楚楚。我当时遇到的是健康检查里包含了数据库连接检查而测试环境的数据库白名单没有放行集群节点的IP数据库连不上健康检查一直失败。把节点IP加入白名单后Pod立刻变成READY。问题二Ingress访问404Ingress返回404是比较常见的坑。先确认后端Service是不是通的kubectl get svc -n dev kubectl get endpoints -n dev如果endpoints为空说明Service的selector和Pod的label没对上检查Deployment里的matchLabels和Service的selector是否一致。如果endpoints正常问题可能出在Ingress本身。用kubectl get ingress -n dev查看Ingress的ADDRESS列是否有了IP再看Ingress Controller的日志里面会写明后端服务是否连通。问题三静态资源文件加载很慢前端页面能打开但JS文件加载明显很慢打开浏览器Network发现一个请求要好几秒。排查到原因是Nginx未开启gzipVite构建出来的vendor chunk有几百KB压缩后体积能减一半以上。开启gzip后加载速度立竿见影。问题四Vue Router history模式刷新404这个坑很经典。前端的路由模式用的history部署后发现直接在浏览器访问子路由刷新会报404。原因就是Nginx默认找不到对应的物理文件直接返回404。解法是前面nginx.conf里的try_files $uri $uri/ /index.html那行配置。排查问题的核心原则是先确认链路断在哪而不是哪里看起来有问题。我见过有人花一小时调Ingress的annotation最后发现是后端Pod一直CrashLoopBackOffIngress连后端Service都连不通完全是白费功夫。6. 稳定运行期的运维经验扩缩容、发布与日志监控6.1 HPA自动伸缩与资源配额设计前后端都是无状态应用非常适合用HorizontalPodAutoscaler做自动伸缩。我基于CPU使用率给后端配置了HPAapiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: springboot-hpa namespace: dev spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: springboot-app minReplicas: 2 maxReplicas: 6 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60这里有一个容易踩的坑HPA要生效Pod的resources.requests.cpu必须设置否则HPA从metrics API拿不到Pod的CPU使用率基线扩缩容策略不会正常触发。这也是前面反复强调resources必须要写的原因之一。前端Nginx虽然没有高CPU压力但如果QPS上涨导致连接数增长明显也可以给前端做个HPA只不过阈值指标更适合用内存或自定义QPS指标CPU不太敏感。6.2 滚动发布与快速回滚K8s的Deployment默认就是滚动更新策略但策略参数一定要设置strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1maxUnavailable: 1表示最多允许一个Pod不可用maxSurge: 1表示可以先额外启动一个新Pod。这样发布期间服务不会中断还能保证至少有一个Pod在服务。发布新版本就是更新镜像tag后重新applykubectl set image deployment/springboot-app springboot-appregistry.example.com/dev/springboot-app:v2.0 -n dev或者更推荐的做法是直接修改yaml文件里的image然后kubectl apply -f这样集群里的实际配置和Git里的清单文件保持一致后续排查问题时不会出现实际跑的配置和仓库里的对不上的尴尬。回滚也很简单kubectl rollout history deployment/springboot-app -n dev kubectl rollout undo deployment/springboot-app -n dev如果要回滚到指定历史版本kubectl rollout undo deployment/springboot-app -n dev --to-revisionversion我还会在CI流水线里在每次发布前记录当前的revision号一旦给出了监控告警直接undo整个过程一分钟内可以完成比传统部署的恢复速度快太多了。6.3 日志收集与监控告警日志这块我强烈建议在Spring Boot里配置日志输出到stdout不要写到容器内文件。原因很简单容器一旦崩溃重建文件日志就丢了而stdout日志会被容器运行时捕获在云环境可以接托管日志服务本地集群也可以接Loki或EFK。Spring Boot默认就会把控制台日志打到stdout不需要额外配置。如果你用的是logback或log4j2需要确保没有把root logger指向文件。要保留存档日志的话也要把存档路径写到挂载的PVC上而不是容器可写层。监控告警我用的Prometheus Grafana的标配。Spring Boot只需引入micrometer-registry-prometheus依赖然后暴露/actuator/prometheus端点Prometheus就能抓取JVM的内存、线程、GC等指标。在Deployment里把actuator暴露为单独端口ports: - name: http containerPort: 8080 - name: metrics containerPort: 9090Spring Boot的配置management: server: port: 9090 endpoints: web: exposure: include: health,info,prometheusPrometheus的抓取配置加上jobGrafana里用JVM仪表盘CPU、内存、GC停顿、线程数都一目了然。后端接口的QPS和错误率我通过Ingress层暴露的metrics来做这需要用支持Prometheus的Ingress Controller比如ingress-nginx本身自带配置起来很方便。首页加载性能和一些真实用户的行为数据前端可以接入基础的Web Vitals埋点把数据推给指标服务。这个可以根据团队情况来选择没有统一标准。最后再分享一个真实的体会K8s部署这套东西第一次手动操作时步骤很多、报错也很折磨人但当你把Dockerfile、YAML清单、CI/CD流水线都固化下来之后后续每次发版都变成了一次标准操作反倒是省心很多。我就是从第一次部署踩坑花了快两天到现在整个流程走完只要十分钟。这个转变值得去花时间完成。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询