一个 jar 包部署到 k8s 上的全流程(DevOps 流程)
一个 jar 包从提交到线上可访问的完整链路:镜像与仓库、Deployment/Service/Ingress、配置与凭据、三类探针、滚动发布与回滚,附一条可落地的 GitHub Actions 流水线。
| 项目 | 说明 |
|---|---|
| 适用版本 | Kubernetes 1.19+(networking.k8s.io/v1 Ingress)、JDK 17、Spring Boot 3.x |
| 示例环境 | 支持 Deployment / Service / Ingress 的集群;示例使用 dev 命名空间 |
| 前置条件 | 可用镜像仓库、ingress-nginx、有 dev 命名空间权限的 kubectl |
| 最后核对 | 2026-10-27 |
先说结论
- 交付物是「镜像 + 唯一 tag/digest」,不是裸 jar。
- Deployment 管副本与版本,Service 吸收 Pod IP 变化,Ingress 收敛七层入口。
- 配置和凭据走 ConfigMap / Secret,不写进镜像。
- 三类探针分别回答:启动慢怎么办、能不能接流量、进程是否还活着。
- 回滚靠
kubectl rollout undo,前提是镜像不可变。
#一、从 jar 包到线上服务:先看清整条链路
一个 jar 包从"提交进代码仓库"到"被用户在浏览器里访问",并不是把 jar 拷到服务器上 java -jar 跑起来这么简单。jar 包只是字节产物,它依赖 JVM、依赖操作系统的时区与字符集、依赖环境变量和外部服务地址;生产环境又不止一台机器,服务要持续在线、进程挂了要自愈、发新版本不能停机。这些需求叠加后,"拷贝即运行"必然被一套自动化流程替代,这套流程就是 DevOps 视角下的完整链路。
Git 仓库(源码 + 变更历史)
│ push 事件触发
▼
CI 流水线(编译 → 单元测试 → 打包 jar → 构建镜像 → 推送到镜像仓库)
│ 产出不可变产物:镜像 + 唯一 tag/digest
▼
CD 环节(更新 Deployment 镜像版本 → 滚动发布)
▼
Kubernetes 集群(Deployment → ReplicaSet → Pod)
▼
网络抽象(Service 稳定接入 Pod 易变 IP → Ingress 对外暴露)
▼
用户通过域名访问服务图中每一环都有明确职责:Git 仓库保存源码与变更历史;CI 流水线把源码变成可验证的构建产物;镜像把 jar 与运行环境固化成一个不可变单元;镜像仓库负责分发与版本管理;Kubernetes 负责运行、自愈与发布;Service 与 Ingress 负责网络抽象;ConfigMap 与 Secret 把配置和凭据与镜像解耦。下面从"为什么需要镜像"开始逐环展开:每个组件先讲清它解决什么问题、不引入它会怎样,再给出可复制的实现。
#二、为什么需要镜像与镜像仓库
#只拷 jar 包会遇到什么问题
如果没有镜像,部署动作就是"把 jar 包拷贝到服务器固定目录,然后 java -jar"。这个流程在一台机器、一个环境、一个人操作时勉强能工作,规模一扩大就暴露三个问题:环境漂移、动作不可追溯、回滚不可靠。
- 环境漂移:jar 依赖的 JDK 版本、系统库、时区、字体、字符集在各环境里并不一致,同一份 jar 在开发机正常、在测试机报编码错误、在生产机时间显示异常;
- 动作不可追溯:谁在什么时间、用了哪个版本的 jar、覆盖了哪些配置,只存在于人的记忆里,机器之间无法保证一致状态;
- 回滚不可靠:没有"上一版产物"的概念,回滚只能重新构建或翻找旧文件,而旧环境未必还存在。
要同时解决这三者,办法是让交付物不再是一个裸 jar,而是自带运行环境的完整运行时单元,并且这个单元一旦产出就再也不变——这就是不可变产物。
#镜像:把 jar 与运行环境一起固化成不可变单元
镜像(Image)把三层内容打包在一起:基础操作系统层(系统库与 glibc)、运行时(JVM)、应用(jar 包与启动命令)。镜像由一层层只读层组成,构建完成后内容不可修改;它的唯一标识是摘要(digest,内容哈希),内容一变标识就变,因此"线上跑的是哪个产物"永远可以精确回答。
对一个 Spring Boot 应用,Dockerfile 只需几行。下面这份 Dockerfile 的作用是:基于 Temurin JDK 17 的 JRE 镜像,把构建产物 jar 拷入工作目录,并把 JVM 启动参数留成可注入的环境变量(容器内存限制与 JVM 堆大小的关系见排障一节):
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY target/demo-0.0.1.jar app.jar
ENV JAVA_OPTS=""
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]构建并推送到镜像仓库的命令如下,tag 直接使用构建序列号,保证一个 tag 只对应一次构建:
docker build -t registry.example.com/backend/demo:20261021-01 .
docker push registry.example.com/backend/demo:20261021-01构建完成后先在本地验证这个不可变产物能独立运行。下面三条命令分别查看镜像信息、以该镜像启动容器并通过健康端点确认应用可访问、最后停掉临时容器:
docker images registry.example.com/backend/demo
docker run --rm -d -p 8080:8080 --name demo-probe registry.example.com/backend/demo:20261021-01
curl -s http://127.0.0.1:8080/actuator/health
docker stop demo-probe#镜像仓库:让不可变产物可分发、可追溯
镜像如果只存在于构建机上,生产服务器拿不到,等于没有。镜像仓库(Registry)解决分发与追溯:它给每个镜像一个稳定地址(如 registry.example.com/backend/demo:20261021-01),并附带两层元数据——tag(人类可读的版本别名)与 digest(内容哈希)。拉取时按摘要拉取,可以保证拿到与推送时完全一致的内容。
如果不引入镜像仓库,就要把镜像文件手工传送到每一台目标机器,版本管理退化成散落在各机器的 tar 包,与不可变产物的目标背道而驰。私有镜像仓库可用 Harbor 或各云厂商的容器镜像服务搭建,接入集群的配置方式以官方文档对应版本为准;本文中的 registry.example.com 是示例地址,替换成实际仓库地址即可。
#三、Kubernetes 侧:从容器到可访问服务
镜像只是一份静态资产,真正让服务运行并保持在线的是 Kubernetes。这一节依次回答三个问题:谁来运行容器并保证数量、谁来屏蔽 Pod 的易变 IP、谁来对外暴露入口。
#谁运行容器并保证数量:Deployment 与 ReplicaSet
先分清三个对象。Pod 是最小的调度与运行单元,一个容器(或一组紧密协作的容器)运行在一个 Pod 里;ReplicaSet 负责维持指定数量的 Pod 副本,Pod 意外退出就重建;Deployment 在 ReplicaSet 之上再做一层版本管理,每次修改 Pod 模板就生成一个新的 ReplicaSet,从而支持滚动发布与回滚。
三个对象背后的核心机制是期望状态收敛:控制器的控制循环不断读取"期望状态"(manifest 中声明的副本数、镜像版本)与"实际状态"(集群里真实存在的 Pod),发现不一致就执行动作让实际状态向期望状态靠拢。这是 Kubernetes 与"脚本批量操作机器"的本质区别:管理员不是直接创建 Pod,而是声明结果,由控制面持续收敛。
如果不引入 Deployment 而手动创建 Pod,Pod 被驱逐、节点重启或容器崩溃后没有人把它拉起来;只用裸 ReplicaSet 则失去版本与发布抽象。下面是 demo 应用的 Deployment 声明,作用是:在 dev 命名空间(Namespace)里声明 3 个副本,使用前面推送的镜像,容器监听 8080 端口:
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo
namespace: dev
labels:
app: demo
spec:
replicas: 3
selector:
matchLabels:
app: demo
template:
metadata:
labels:
app: demo
spec:
containers:
- name: demo
image: registry.example.com/backend/demo:20261021-01
ports:
- containerPort: 8080应用这份清单,并确认 Pod 都进入 Running 状态:
kubectl apply -f deployment.yaml
kubectl -n dev get pods -l app=demo -o wide#谁屏蔽 Pod 的易变 IP:Service
Pod 创建后有自己的 IP,但这是临时 IP:Pod 重建、扩缩容、节点迁移都会换新地址。如果调用方写死 Pod IP,任何一次重建都会打断调用链;如果调用方每次现查 IP,就退化成手工服务发现。
Service 加一层稳定抽象来解决这个问题:用标签选择器(selector)圈定一组 Pod,对外只暴露一个稳定的虚拟 IP(ClusterIP)和域名(格式为
如果不引入 Service,每个消费方都得自己维护 Pod 的 IP 集合并按时刷新,扩容与故障自愈带来的 IP 变化会让整个系统持续断连。下面是 demo 的 Service 声明,作用是:把标签为 app=demo 的一组 Pod 抽象为稳定入口,集群内访问 80 端口,后端转发到容器 8080 端口:
apiVersion: v1
kind: Service
metadata:
name: demo
namespace: dev
spec:
selector:
app: demo
ports:
- name: http
port: 80
targetPort: 8080应用后验证 Service 与后端 Pod 的关联关系:
kubectl apply -f service.yaml
kubectl -n dev get svc demo -o wide
kubectl -n dev get endpoints demo#谁对外提供统一入口:Ingress
Service 的 ClusterIP 只在集群内部可达,外界访问不到。Kubernetes 有三类对外暴露方式:NodePort 在每个节点开一个高位端口;LoadBalancer 让云厂商创建一个外部负载均衡器;Ingress 在集群入口统一做七层路由。每个服务分配 NodePort 会迅速耗尽端口且不便按域名路由;每个服务挂独立 LoadBalancer,成本与运维负担都高。
Ingress 把对外暴露收敛成一个统一入口对象:它声明"哪个域名、哪个路径转发到哪个 Service",真正干活的是集群里部署的 Ingress Controller(如 ingress-nginx),Controller 监听 Ingress 对象并动态生成反向代理配置。如果不引入七层入口,就要在应用层各自维护入口与 TLS 证书,域名和路径路由也无法集中管理。
下面是 demo 的 Ingress 声明,作用是:把域名 demo.example.com 的根路径流量转发到 dev 命名空间的 demo Service 的 http 端口:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: demo
namespace: dev
spec:
ingressClassName: nginx
rules:
- host: demo.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: demo
port:
name: http至此链路已经完整:Ingress → Service → Endpoints → Pod。域名这头稳定,Pod IP 那头易变,中间的 Service 把变化吸收掉。
#四、配置与凭据:ConfigMap 与 Secret
#配置为什么不能写进镜像
镜像的价值在于不可变,而配置恰恰是要变的:同一份代码要部署到开发、测试、生产多个环境,数据库地址、日志级别、功能开关各不相同。如果配置写死在镜像里,任何环境差异都要重新构建镜像,不可变产物就退化成"变化产物"。
解法是把配置从镜像中抽离成集群里的独立对象,在 Pod 启动时以环境变量或文件的形式注入。镜像保持单一,环境差异由配置对象承载。
#ConfigMap:注入非敏感配置
ConfigMap 用来保存普通配置。下面这份 manifest 的作用是:把 Spring Boot 的外部配置文件 application.yml 单独保存,数据源地址直接使用集群内部的 Service 域名(命名空间为 dev,Service 名为 mysql),数据库密码属于敏感信息,放到后面的 Secret 中:
apiVersion: v1
kind: ConfigMap
metadata:
name: demo-config
namespace: dev
data:
application.yml: |
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://mysql.dev.svc:3306/demo
username: demo
logging:
level:
root: info把 ConfigMap 注入 Pod 的标准做法是挂载成文件。下面这段 Deployment 容器片段的作用是:声明一个引用 demo-config 的卷并挂载到容器的 /app/config 目录,再通过环境变量 SPRING_CONFIG_ADDITIONAL_LOCATION 让 Spring Boot 优先读取该目录(具体加载优先级以所用 Spring Boot 版本官方文档为准):
env:
- name: SPRING_CONFIG_ADDITIONAL_LOCATION
value: /app/config/
volumeMounts:
- name: app-config
mountPath: /app/config
readOnly: true
volumes:
- name: app-config
configMap:
name: demo-config#Secret:分离凭据
配置里还有一类敏感数据:数据库密码、消息队列凭据、第三方 Token。这类数据不能以明文出现在 ConfigMap 或镜像里,否则等于把凭据写进了代码仓库。Secret 把它们从普通配置中分离出来独立管理。
下面这份 manifest 的作用是:把数据库密码保存为名为 demo-secret 的 Secret,创建时用 stringData 以明文书写、由 API 服务器编码存储:
apiVersion: v1
kind: Secret
metadata:
name: demo-secret
namespace: dev
type: Opaque
stringData:
DB_PASSWORD: "demo-db-password"需要澄清两点:其一,Secret 只做数据分类与访问控制,并不提供静态加密,etcd 中默认以 base64 存储,需要加密时应启用 etcd 静态加密或使用外部密钥管理系统;其二,上面示例中的密码仅用于演示流程,真实环境应把凭据托管在外部 Secret 管理方案中,以所选方案官方文档对应版本为准。Secret 注入 Pod 的方式与 ConfigMap 相同,下面这段 Deployment 容器片段的作用是:把 Secret 中的 DB_PASSWORD 以环境变量形式注入容器:
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: demo-secret
key: DB_PASSWORD#五、探针:让健康可判定、让发布可收敛
容器进程启动不等于服务就绪。Spring Boot 应用启动后,还要经历上下文初始化、连接池建立、依赖服务连通等阶段,这个窗口可能有几十秒;如果流量在就绪前进来,第一批请求会全部失败。同时,进程可能陷入死锁或内存异常却不退出,导致服务"活着但不可用"。这两类问题分别由就绪探针与存活探针回答。
- 就绪探针(readinessProbe):判定 Pod 是否具备接收流量的条件,失败时从 Service 的 Endpoints 中摘除,恢复后再接流量;
- 存活探针(livenessProbe):判定容器进程是否还健康,失败时由 kubelet 重启容器;
- 启动探针(startupProbe):给启动慢的应用一个缓冲期,避免存活探针在启动阶段误杀容器。
如果不配置探针:就绪探针缺失时,滚动发布的新 Pod 一旦进入 Running 就会被 Service 转发流量,出现短暂 5xx;存活探针缺失时,死锁或内存异常的进程不会被回收,服务挂着但不可用。
Spring Boot 应用要暴露健康端点,需要在 pom.xml 中引入 Actuator 依赖,下面这段 XML 的作用是声明该依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>下面这段 Deployment 容器片段的作用是:为 demo 容器配置启动、就绪、存活三类探针,均以 /actuator/health 为探测目标,用不同周期与失败阈值区分语义:
startupProbe:
httpGet:
path: /actuator/health
port: 8080
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
periodSeconds: 10
failureThreshold: 3到这里,Deployment 需要的元素已经齐了:配置注入、凭据注入、三类探针。下面这份完整的 deployment.yaml 把所有元素合在一起,作用是:一份可以直接复制应用的最终清单(对应 ConfigMap 与 Secret 已用前面两节的内容创建):
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo
namespace: dev
labels:
app: demo
spec:
replicas: 3
selector:
matchLabels:
app: demo
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: demo
spec:
containers:
- name: demo
image: registry.example.com/backend/demo:20261021-01
ports:
- containerPort: 8080
env:
- name: SPRING_CONFIG_ADDITIONAL_LOCATION
value: /app/config/
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: demo-secret
key: DB_PASSWORD
volumeMounts:
- name: app-config
mountPath: /app/config
readOnly: true
startupProbe:
httpGet:
path: /actuator/health
port: 8080
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
periodSeconds: 10
failureThreshold: 3
volumes:
- name: app-config
configMap:
name: demo-config#六、滚动发布与回滚
Deployment 的发布策略(strategy)决定新老版本如何切换。默认策略是 RollingUpdate:创建一个新 ReplicaSet,逐个建立新副本、销毁旧副本;配合就绪探针,新 Pod 真正就绪后才开始接流量,从而做到发布期间不中断服务。与之相对的 Recreate 策略会先删光旧 Pod 再创建新 Pod,适用于可接受停机的场景。
发布新版本有两种等价的操作方式:方式一是直接修改运行中的 Deployment 的镜像引用(demo 是容器名);方式二是修改清单文件里的镜像版本后重新 apply。两种方式都会触发同一条滚动发布链路,下面的命令演示方式一并等待收敛、观察新副本上线:
kubectl -n dev set image deployment/demo demo=registry.example.com/backend/demo:20261022-01
kubectl -n dev rollout status deployment/demo
kubectl -n dev get pods -l app=demo -o wide发布出现问题需要回退时,Deployment 保留了历史 ReplicaSet,可以一键回滚。下面两条命令先查看发布历史,再把 demo 回滚到上一个版本并等待收敛:
kubectl -n dev rollout history deployment/demo
kubectl -n dev rollout undo deployment/demo
kubectl -n dev rollout status deployment/demo回滚到指定历史版本用 --to-revision 参数,版本号从 rollout history 的输出中读取。更细的发布策略、探针配合与回滚验证见 Kubernetes 滚动发布与回滚。
#七、CI/CD 流水线:把整条链路串成一次提交
前面所有环节如果靠人手工执行,会立即回到"动作不可追溯"的老问题。CI/CD 流水线把「Git 触发 → 构建 → 测试 → 打镜像 → 推送 → 更新清单」自动化为一次提交触发的完整流程,并且让每个产物与一次提交一一对应。
流水线的六个环节分别是:Git 触发(push 分支或打 tag 事件);构建与测试(编译、单元测试、静态检查);打镜像(按 Dockerfile 构建);推送(推送到镜像仓库并记录 tag/digest);更新清单(把新镜像版本写进 Kubernetes 声明);等待集群收敛(通过 rollout status 或集群侧工具确认)。
更新清单有两种模式。模式一是流水线直接调用 kubectl(需要为流水线配置集群访问凭据);模式二是把清单更新提交回 Git 仓库,再由集群侧的部署工具(如 Argo CD)自动同步,清单本身成为唯一事实来源。下面以 GitHub Actions 为例给出可落地的流水线,作用是:main 分支推送时编译测试、构建并推送镜像,然后把新镜像版本写回 deploy/deployment.yaml 并提交回仓库(由集群侧或手动 kubectl apply 生效;具体语法以 GitHub 官方文档对应版本为准):
name: build-and-deploy
on:
push:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: maven
- name: Compile and test
run: mvn -B clean verify
- name: Login to registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ vars.REGISTRY }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and push image
run: |
IMAGE="${{ vars.REGISTRY }}/backend/demo:${{ github.sha }}"
docker build -t "$IMAGE" .
docker push "$IMAGE"
- name: Bump image in deployment manifest
run: |
sed -i "s|image: .*backend/demo:.*|image: ${{ vars.REGISTRY }}/backend/demo:${{ github.sha }}|" deploy/deployment.yaml
git add deploy/deployment.yaml
git config user.name "ci-bot"
git config user.email "ci-bot@example.com"
git commit -m "chore: bump demo image to ${{ github.sha }}"
git push如果团队选择模式一,流水线最后一步直接执行滚动发布即可,与前面滚动发布节的 set image 命令一致,这里不再重复完整命令,只需注意 KUBECONFIG 内容应通过 CI 平台的 secret 注入,不要把集群凭据写进仓库。
#八、验证链路与常见排障
部署完成后,用下面的命令组从下到上验证每一层:先看 Pod 状态,再看 Service 与 Endpoints 关联,然后用端口转发访问健康端点:
kubectl -n dev get pods -l app=demo -o wide
kubectl -n dev get svc demo -o wide
kubectl -n dev get endpoints demo
kubectl -n dev port-forward svc/demo 8080:80
curl -s http://127.0.0.1:8080/actuator/health从集群内部按 Service 域名访问,可以避开本地端口转发的干扰,直接验证服务发现;下面的命令用临时 Pod 发起请求:
kubectl -n dev run curl-test --image=curlimages/curl --rm -i --restart=Never -- curl -s http://demo.dev.svc:80/actuator/health常见失败模式与排查方向:
| 现象 | 先看什么 | 常见原因 |
|---|---|---|
ImagePullBackOff | 镜像地址与 imagePullSecrets | 镜像不存在、tag 写错、私有仓库未配置拉取凭据 |
CrashLoopBackOff | kubectl logs 启动日志 | 配置缺失、端口冲突、启动即异常 |
| Pod 在 Running / NotReady 之间抖动 | kubectl describe pod 的探针事件 | 健康端点路径或端口写错、initialDelaySeconds 过短 |
| Ingress 返回 404 | ingressClassName、path 与 backend 端口 | Controller 不匹配、路径或 Service 端口不对应 |
| 配置更新不生效 | 注入方式 | 环境变量注入的 Pod 不会自动感知,需要 kubectl rollout restart |
容器被 OOMKilled | resources.limits 与 JVM 堆参数 | 堆上限与容器限制不匹配;可用 -XX:MaxRAMPercentage=75 贴合 limits |
| 高并发下短暂 5xx | 就绪探针是否覆盖依赖 | 健康端点只返回 UP,但数据库等依赖不可用 |
#九、下一步与边界
链路打通后,值得继续深入的三个方向:用 HPA(HorizontalPodAutoscaler)按指标自动扩缩容;把镜像摘要(digest)而非 tag 写进清单,让回滚与追溯精确到字节;引入完整可观测性(metrics、日志、链路追踪),让每次发布都有数据佐证。
也要说清楚什么时候这套复杂度是多余的:如果只有一个实例、几乎没有发布频率、没有多环境需求,用 systemd 跑 java -jar 反而更合适。引入镜像、Kubernetes、CI/CD 的成本是真实的,收益只有在"多环境、多实例、要自愈、要快速发布"时才成立。判断标准很简单:当手工部署开始反复制造事故时,就是该把这条链路自动化的时候。
#附:最小可运行清单
| 步骤 | 动作 | 验证 |
|---|---|---|
| 1 | 构建并推送镜像:docker build / docker push | 镜像仓库能按 tag 拉到 |
| 2 | 创建 ConfigMap 与 Secret | kubectl -n dev get configmap,secret |
| 3 | apply Deployment / Service / Ingress | kubectl -n dev get pods,svc,ingress |
| 4 | 等发布收敛 | kubectl -n dev rollout status deployment/demo |
| 5 | 确认 Service 有后端 | kubectl -n dev get endpoints demo 有地址 |
| 6 | 验证应用健康 | 端口转发或临时 Pod 请求 /actuator/health 返回 UP |
#延伸阅读
- 把博客从 docker-compose 搬到 k3s:单机迁移与上线全过程 —— 同一套对象在单机集群上的完整落地。
- 5 台服务器,从二进制装 K8s 到 Spring Cloud 上线 —— 从零搭集群,再走一遍这条链路。
- Kubernetes 核心组件详解与实战 —— 控制面与 kubelet 的职责边界。
- Argo CD 实战:用 GitOps 把 CI/CD 部署搬进 Kubernetes —— 模式二的完整实现。
相关阅读
Related留言讨论
0 条还没有留言,来说点什么吧。