1. 一个写Java的,怎么就绕不开K8s和Jenkins了
1.1 从"本地能跑就行"到"线上必须稳定"的转变
我先说一下自己的背景。写Java后端写了六七年,每天的工作几乎离不开Spring Boot、MyBatis、MySQL这些,最熟悉的是mvn package打出一个带依赖的fat jar,然后扔到测试服务器上,用java -jar启动,接着用ps看进程还在不在。那几年里我理所当然地认为,部署这件事就该归运维管,我只要能保证本地mvn test全绿、接口用Postman调通,就算交付完成任务了。
转折发生在一个对我来说有点尴尬的场景:公司要做容器化改造,测试环境从裸机迁到了K8s集群,Jenkins被提上日程,要求每个项目都要能自动构建、自动部署。当时我第一反应是,我一个写Java的,看K8s那堆Pod、Deployment、Service,满脑子都是"这跟我有什么关系"。但现实很快打了脸——线上环境没人帮我手动停服务、扔jar包了,我连自己写的Java服务为什么会起不来,都要花一整天去查日志。
后来我才意识到,作为Java开发者,已经不只是"写接口"这一个动作了。从代码提交、项目编译,到镜像构建、容器编排、服务发布,整条链路的任何一个环节出错,最终背锅的都是开发。与其被动挨打,不如主动把这套东西吃透。这篇文章里我想说的,就是一个普通Java工程师,怎么一步步把K8s和Jenkins从"陌生的部署工具"变成了"日常工作的左膀右臂"。如果你也正处在类似的转型期,看到标题大概能明白我的心情。
1.2 CI/CD不是新语言,只是把Java工程师熟悉的构建流程自动化了
很多人一听"持续集成""持续交付""流水线"就发怵,觉得这是运维才需要掌握的高级概念。但换个角度想:Java工程师从入门第一天在用Maven或者Gradle,那其实就是一个标准化的构建流程。validate、compile、test、package、install,每个阶段执行什么插件、依赖从哪里下载、产物输出到哪个目录,这不就是一个小型的流水线吗?Jenkins只不过是把这条流水线从单机搬到了一个更通用的调度平台上,并且允许你按需串联更多步骤。
K8s也同理。如果你懂Spring,就应该知道Spring IoC容器管理Bean的生命周期,K8s里的控制器(Controller)也在做类似的事情:保证Pod副本数符合期望、滚动更新时先起新再删旧、负载均衡后端的Service配置变化后自动更新Endpoint。我把这些对应关系弄明白之后,学习K8s的难度一下子降了好几个档次。后面我会专门写一节,拿大家熟悉的Java概念去类比K8s里的核心对象。
这一节想强调的核心观点是:Java程序员转去看K8s和Jenkins,不需要把自己当成一个空白的运维新手,你应该把自己已有的构建经验、进程管理经验、配置管理经验迁移过去。我之前就是太给自己设限,总觉得“这不是Java的活”,等到真去啃的时候,才发现这些工具离Java开发比想象中近得多。
2. 先啃K8s:给Java开发者的速通路线
2.1 用Java概念类比核心对象,立刻就不懵了
K8s的文档喜欢用官方术语,什么Pod、Deployment、Service、Ingress、ConfigMap、Secret,初次接触的人很容易被名词淹没。我用Java里已有的概念对照了一遍,发现很多对象是可以完美类推的,这里直接做成一张表。
| K8s对象 | Java/Spring对应物 | 一句话解释 |
|---|---|---|
| Pod | JVM进程实例 | 一个Pod就是一个运行中的实例,里面可以有一个或多个容器,类比JVM里跑着Spring Boot应用 |
| Deployment | Spring容器/Bean配置 | 它控制Pod的副本数量、镜像版本、更新策略,类似描述IoC里某个Bean的scope和初始化时机 |
| Service | 网关/注册中心负载均衡入口 | 给一组Pod提供稳定的访问入口,类似Nginx反向代理,也类似RPC服务发现里那个稳定的服务名 |
| ConfigMap | application.yml | 把配置和应用程序解耦,不把环境配置写死在镜像里 |
| Secret | Jasypt加密配置/数据库密码 | 保存敏感信息,比如数据库连接密码、密钥 |
| Ingress | Spring MVC的DispatcherServlet | 外部HTTP请求的统一入口,按host/path路由到不同Service |
| Namespace | Maven的groupId/模块分包 | 资源隔离和分组管理,可以类比不同业务模块、不同环境的jar包隔离 |
这个类比可能不是100%精确,但对初学者理解非常有用。我第一次看到Pod里有两个容器(比如一个应用容器、一个Sidecar日志采集容器)时,想到的是“同一个进程内的多线程虽然共享内存,但彼此独立运行”,一下子就觉得没那么玄了。
需要提醒的是,类比归类比,K8s里对象之间是有严格依赖关系的。Deployment负责管理Pod,Service通过Label Selector去选Pod,Ingress再转发到Service。这个依赖链条可以用一句话串起来:外部请求先到Ingress,Ingress路由到Service,Service负载均衡到Pod,Pod里运行着你的Java容器,而Deployment掌控整个过程里Pod的生死。
2.2 一个Spring Boot服务在K8s上的最小部署清单
我们不说理论,直接给一个能跑通的最小方案。假设你有一个Spring Boot项目,打包后叫demo.jar,JDK用的是17,你要把它部署到K8s。
第一步,先写一个Dockerfile。很多Java项目会直接用openjdk:17-jdk-slim这种镜像,但这会让镜像体积偏大,而且OpenJDK官方镜像维护不是很活跃。更稳的方式是用Eclipse Temurin的镜像,不过这里为了讲解简单,我还是用最熟悉的例子。
FROM openjdk:17-jdk-slim LABEL maintainer="yourname" ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone WORKDIR /app COPY target/demo.jar app.jar EXPOSE 8080 ENTRYPOINT ["java","-jar","app.jar"]构建好镜像之后推到镜像仓库,比如阿里云ACR或者Harbor。然后是部署文件,一般公司里会分成deployment.yaml和service.yaml。
apiVersion: apps/v1 kind: Deployment metadata: name: demo-deployment namespace: demo spec: replicas: 2 selector: matchLabels: app: demo template: metadata: labels: app: demo spec: containers: - name: demo image: registry.example.com/java-demo/demo:1.0.0 ports: - containerPort: 8080 readinessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 30 periodSeconds: 10 resources: requests: memory: "512Mi" cpu: "250m" limits: memory: "1Gi" cpu: "1000m"Service文件就更简单了。
apiVersion: v1 kind: Service metadata: name: demo-service namespace: demo spec: type: ClusterIP selector: app: demo ports: - port: 80 targetPort: 8080然后kubectl apply -f deployment.yaml -f service.yaml,Pod跑起来之后,在集群内部通过demo-service:80就能访问到Java服务的8080端口。这就是一个Java应用在K8s上的最小部署。需要注意,这个Service默认只在集群内部有效,外部访问还需要Ingress或者NodePort。
部署的时候最容易犯的一个错,就是忘了命名空间。我一开始部署到default里,后来服务多了之后环境非常混乱。建议从第一天开始就给每个环境建独立命名空间,比如demo-dev、demo-test、demo-prod。
2.3 让Java服务优雅上下线,别让滚动更新变成“请求杀手”
Java服务在K8s里和传统的Tomcat部署有一个很大的区别:Pod会被频繁销毁和重建,比如发布版本、扩容缩容、节点维护。如果你只是把Spring Boot应用原封不动丢进去,不做任何配置,大概率会在发布期间出现“连接被重置”、“报错Connection refused”这类问题。
原因在于两个信号没有处理好。一个是容器停止时,K8s默认向Java进程发送SIGTERM信号,而Spring Boot默认行为是立即关闭,不等请求处理完;另一个是K8s判断Pod就绪的探针(readinessProbe)没有配好,Service还在继续往正在停机的Pod转发流量。
我的解决办法有三条。
配置Spring Boot的优雅停机:
server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=30s然后在Deployment的spec里加preStop钩子和终止等待时间:
spec: terminationGracePeriodSeconds: 60 containers: - name: demo lifecycle: preStop: exec: command: ["sh", "-c", "sleep 10"]preStop里sleep 10秒钟,是为了让Pod先从Service的Endpoints里移除,再真正停掉进程。如果你用过Nginx upstream,这就像在Nginx里把某个后端标记为down之后,等它处理完现存连接再关闭。K8s其实也在做类似的事,只是需要你自己配置时机。
这就是Java开发在K8s里最容易踩的坑,也是面试里经常被问到的“优雅下线”问题。我建议无论在什么环境,只要用K8s跑Java服务,这三件套必须配齐。
3. Jenkins自动部署:从手动点按钮到全自动流水线
3.1 先搞清楚Jenkins在你的场景里到底扮演什么角色
很多人一提到Jenkins就想到那些五颜六色的仪表盘和插件列表,以为它只是一个“可视化按钮工具”。其实Jenkins的核心价值在于,它是一个构建调度的中枢,你可以理解成一个用Groovy脚本驱动、可以按时间或Git事件触发、能在多台Agent上并行执行任务的“定时任务Plus”。
从Java开发的角度看,我们通常在本地跑mvn clean install,然后手动上传jar包。Jenkins做的事很简单:替你把这段命令放到了一个可以重复执行、可以审计、可以自动触发的地方。同时它还能把你原来手工做的上传服务器、执行启动脚本这些步骤,通过Pipeline脚本固化成一条流水线。
新版本的Jenkins有两个事情需要先处理,一个汉化,一个插件加速。Jenkins 2.541.3这种版本装好默认是英文界面,虽然对日常使用没太大影响,但团队里如果有不熟悉英文的新人,建议在系统管理里安装“Localization: Chinese (Simplified)”插件,然后设置语言。插件加速是因为Jenkins默认插件下载源在国外,网络环境不好的时候经常装插件超时,可以手动把升级站点URL换成国内镜像地址,比如清华源的https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/update-center.json,这个在系统管理->插件管理->高级设置里配置。
还有一点需要提一下,Jenkins里有大量环境变量,比如BUILD_NUMBER、WORKSPACE、JOB_NAME,这些在你写Pipeline时特别有用,可以用来做镜像tag、归档路径、邮件通知标题。官方有一份“Jenkins可用环境变量”列表,虽然不一定全部记得住,但BUILD_NUMBER、GIT_COMMIT、JOB_URL这几个最常用。
3.2 用Pipeline脚本把Java项目的构建、推送、部署串起来
在Jenkins里建一个流水线项目时,我推荐直接用Pipeline脚本,而不是选择“自由风格项目”。自由风格项目虽然界面配置方便,但一旦步骤复杂,所有逻辑都散落在页面上,没法代码review,也不方便复制到下一个项目。Pipeline脚本本质就是一份Jenkinsfile,提交到Git仓库里,和Java代码一起管理。
这里给一个最基础的Java应用持续部署模板,假设你已经把K8s集群的kubeconfig配到了Jenkins所在的环境里,且Jenkins主机上装了kubectl。
pipeline { agent any environment { DOCKER_REGISTRY = 'registry.example.com' PROJECT_NAME = 'java-demo' IMAGE_TAG = "${BUILD_NUMBER}-${GIT_COMMIT?.take(7)}" NAMESPACE = 'demo' } stages { stage('拉取代码') { steps { checkout scm } } stage('编译打包') { steps { sh 'mvn clean package -DskipTests' } } stage('构建推送镜像') { steps { sh """ docker build -t ${DOCKER_REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG} . docker push ${DOCKER_REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG} """ } } stage('部署到K8s') { steps { sh """ kubectl set image deployment/demo-deployment demo=${DOCKER_REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG} -n ${NAMESPACE} kubectl rollout status deployment/demo-deployment -n ${NAMESPACE} """ } } } post { success { echo '部署成功' } failure { echo '部署失败' } } }这段脚本没有用任何插件,只依赖Jenkins主机本身能执行shell命令。思路非常直观:拉代码、打jar包、构建镜像、推镜像、更新K8s Deployment的镜像版本。IMAGE_TAG通过BUILD_NUMBER加上Git提交短哈希生成,目的是让每次构建的镜像都可追溯,这是生产环境里特别重要的一步。
如果完全用新版的Kubernetes插件来部署,还可以直接调用kubernetesDeploy步骤,传入镜像名即可。但我觉得对于刚开始接触的人,用kubectl set image更接近命令历史,排查问题也更直接。等你把流程跑通了,再往深度方向走,去研究Jenkins的Kubernetes插件动态Agent、声明式Pipeline的高级写法。
3.3 Jenkins容器里调用docker和kubectl,总有一个绕不开的坑
现在很多公司会把Jenkins也用容器方式跑,这就带来了一个经典问题:Jenkins容器里没有docker命令,你怎么构建镜像?我第一次遇到这个报错直接愣住了,sh: docker: command not found,后来折腾了很久才明白是容器环境的问题。
最常见的解决方案有三种。
第一种,把宿主机上的docker.sock挂载进Jenkins容器,也就是-v /var/run/docker.sock:/var/run/docker.sock。这种方式最直接,Jenkins容器里的docker命令会直接操作宿主机的docker守护进程。但风险也很明显:任何能执行Jenkins任务的人,都等同于有了宿主机root权限。自己开发环境可以这么玩,生产环境不推荐。
第二种,用Kaniko这种专门在容器里构建镜像的工具,不依赖docker daemon,会安全很多,但配置学习成本高一些。
第三种,也是我目前用得最多的,把Jenkins本身跑在K8s集群里,然后利用Kubernetes插件的“动态Agent”功能,让每次构建都临时拉起一个带kubectl和helm的Pod来执行任务。构建Agent用完就被销毁,既实现了资源隔离,也不用担心污染宿主机。
Jenkins里使用kubectl,核心就是准备一个能连接目标K8s集群的kubeconfig文件,可以通过withKubeConfig插件或直接在环境变量里设置KUBECONFIG路径。注意,集群地址、证书、token这些不要写死到Jenkinsfile里,应该放到Jenkins的凭据(Credentials)管理里。
4. 生产环境里那些让人头大的K8s和Jenkins故障
4.1 Pod起不来,镜像拉不下来,到底该怎么查
生产环境最常遇到的第一类问题,就是Pod一直停在ImagePullBackOff状态。你运行kubectl get pods,能看到的是一堆红色的错误状态,但具体原因需要靠kubectl describe pod来查看。很多新手习惯直接看Pod日志,但Pod没起来的时候根本没日志可看,所以describe才是第一步。
常见的几个原因有:镜像仓库地址写错、镜像标签不存在、私有仓库未登录、镜像拉取凭据不对。Java服务如果用的是私有镜像仓库,Deployment的spec.template.spec里要配置imagePullSecrets,不然K8s根本没权限从你的Harbor或ACR里拉镜像。另外,镜像tag如果总是用latest,开发环境没问题,但生产环境建议每次构建都带上唯一tag,并且使用imagePullPolicy: IfNotPresent之外的策略,否则会出现明明推了新镜像,但节点上还跑着旧镜像的情况。
还有一类问题是镜像能拉到,但Pod启动后马上崩溃重启。这时还是要先看状态:CrashLoopBackOff。如果是Java应用启动报错,去看Pod日志;如果是启动过程中内存不足被OOM杀死,要重点检查下一节说的JVM内存配置。这里有一个我个人的习惯:接到这类故障,先kubectl describe pod看一下Events,再kubectl logs看应用日志,按这个顺序来,永远不会跑偏。
4.2 Service、Ingress、externalIPs:服务访问不到的排查顺序
另一个高频问题,是服务部署好了,但外部访问始终超时。这个问题牵涉到K8s的网络模型,很多人一上来就怀疑Ingress配错了,其实有可能是下面的Pod没挂上Service,或者Service类型不对。
我建议一种排查顺序:先确认Pod正常且日志无报错;再确认Service的Endpoints里有Pod的IP;然后在集群内部用Service的DNS名字做curl测试;最后才检查Ingress转发规则。这个顺序其实就是数据包路径:客户端请求到Ingress,Ingress到Service,Service到Pod。哪一环断了,就排查哪一环。
说到Service类型,K8s里有ClusterIP、NodePort、LoadBalancer、ExternalName几种。ClusterIP只在集群内可达,NodePort会在所有节点上开放一个端口,LoadBalancer一般配合云厂商的负载均衡器使用。还有一个容易被忽略的externalIPs字段,它可以给Service手动指定一个外部可达IP,让集群外的流量直接通过这个IP访问Service。比如你有一台机器IP是10.0.0.5,你想让外部访问某个服务,可以这么写:
apiVersion: v1 kind: Service metadata: name: demo-service spec: selector: app: demo ports: - port: 80 targetPort: 8080 externalIPs: - 10.0.0.5这个在旧版K8s教程里经常被提及,实际上外部流量直接发给这台物理机时,请求会被转发到对应的Service。但它在现代集群中并不常用,因为Ingress和LoadBalancer才是主流方案。面试和实际排障时,把externalIPs和NodePort放到一起理解,不会错。
4.3 JVM在容器里的内存问题,这和Java开发关系太近了
K8s里跑Java服务,最经典的一幕就是Pod突然死掉,状态显示OOMKilled。开发一看应用日志,没有OutOfMemoryError,没有任何异常,进程直接没了。这是因为K8s从cgroup层面限制了Pod可以使用的内存,当Java进程使用的总内存超过这个上限时,内核会直接杀掉进程。
老版本的Java对容器内存的支持很差。JDK 8u131之前,JVM默认是把宿主机物理内存当成本机内存来算的,所以在容器里设置了-Xmx2g也没用,JVM可能以为机器有64G内存,然后不断扩张堆内存,直到触发cgroup的限制被杀死。后来的JDK版本默认开启了UseContainerSupport,但如果你还在用老JDK,或者手动覆盖了JVM参数,还是会出现问题。
我的部署参数是这样配的:
ENTRYPOINT ["java","-XX:MaxRAMPercentage=75.0","-jar","app.jar"]MaxRAMPercentage=75意味着JVM最多使用容器内存上限的75%,剩下的25%留给线程栈、Metaspace、JIT编译器这些容器看不见但确实需要的内存。同时Deployment里的resources.limits.memory要和这个比例配合好。比如限制1Gi内存,JVM堆最大就是768Mi左右,不要再把-Xmx直接写死成1G,否则堆还没到顶峰,容器就被K8s杀了。
排查这类故障还有一个实用的技巧:使用kubectl describe pod查看Last State那一栏,如果显示OOMKilled: true,基本就能确定是被杀而不是Java抛异常。然后再去看内存监控,确认是堆内存还是非堆内存的问题。
5. 进阶玩法:Java生态里K8s和Jenkins还能怎么融合
5.1 从“能用”到“好用”:动态Agent、Operator、监控预警
当你的Java项目数量多起来之后,每个项目都复制粘贴一份Jenkinsfile会变得很痛苦。这时值得去做的第一件事,就是让Jenkins Agent动态化。通过Kubernetes插件,Jenkins可以在执行Pipeline时自动新建一个Pod作为Agent,这个Pod里预装好Maven、JDK、kubectl、docker或Kaniko,构建完自动删除。这样的好处很明显:构建任务之间不会互相污染,高峰期能自动拉起多个Agent并发执行,空闲时不会白白占用资源。
K8s本身也有一个和Java生态深度结合的点,就是Operator模式。你可以把Operator理解成一个“不断检查期望状态和当前状态,然后自动把当前状态调整到期望状态”的程序,这和Spring里那些定时拉取数据做对账的Job很相似,只不过它操作的对象是K8s集群内的资源。比如你希望集群里任意时刻都有3个Java服务实例,当Pod挂了,Operator会立刻帮你拉起新的,这种自动化的扩展能力是纯手工运维做不到的。
监控方面,虽然这篇博文主要在说部署,但生产环境的K8s没有Prometheus基本等于瞎子。部署Prometheus监控K8s集群,重点盯几个指标:Pod的CPU和内存使用率、重启次数、镜像仓库拉取耗时、Ingress请求成功率。Java应用本身还可以通过Spring Boot Actuator暴露Metrics,让Prometheus来抓取指标。Jenkins也有对应的Prometheus插件,直接把jenkins/adhoc这些接口数据暴露出来,一旦构建成功率下降或平均构建时间变长,告警马上能推出来。这里不用一开始就做得大而全,但至少要把“Pod重启次数大于3”和“构建失败率超过20%”这两个告警配好。
5.2 资源学习清单和进阶方向:别只盯着“八股文”
我把这个放最后,是因为看到网上很多人问“K8s权威指南第五版pdf下载”,这本书固然经典,但如果你现在才开始学K8s,我建议不要只抱着一本书啃。K8s社区变化太快,一本书出版一年后,部分内容可能就过时了。更好的学习路径是:先看官方文档的Concepts部分,再跟着官方教程做一遍minikube或者kind的本地集群,最后再回到项目里实操。
Java这边也一样,面试常问的Java基础、Java面试题、并发编程、JVM调优,其实大多都能在K8s和Jenkins场景里找到回响。比如你在Jenkins里配了一个并行构建,不同stage同时跑,这本质就是多线程并发的调度问题;你在K8s里设置Deployment的滚动更新策略,本质就是分布式系统里的发布一致性;你处理容器中JVM内存问题,本质就是JVM参数和Linux cgroup的交互。
如果让我给一个更明确的进阶顺序,我会建议按这几步走:第一步,把本地Java项目容器化,能把镜像推上仓库;第二步,用K8s跑起来,配合Ingress暴露HTTP服务;第三步,用Jenkins做持续集成,实现代码提交后自动构建、自动部署;第四步,加入监控告警、环境隔离、灰度发布;第五步,去研究Operator、离线的制品管理、多集群容灾,这些才是真正拉开差距的地方。至于那些“Java POI Word能生成图表吗”之类的问题,其实也是同一个道理,工具不是限制,对工具链的理解才是。
再说两句关于技术选型的心得。现在社区里有些声音觉得Jenkins老了,应该用GitLab CI、GitHub Actions、Argo CD之类的替代品。这个判断要看场景。Jenkins的优势在于插件生态极其丰富,私有化部署非常成熟,在传统企业内部接受度极高。如果你在维护一个存量Java项目,老团队都会用Jenkins,你就不要硬推新东西;如果是从零开始的新项目,直接上云原生的CI/CD工具链当然更舒服。但无论如何,K8s作为统一部署平台,这个方向短期内不会变,Java开发者提前掌握它,对自己只有好处。
我个人在实际操作中的体会是,你不需要一开始就成为一个K8s和Jenkins的“专家”,只需要在Java应用交付出问题的时候,能快速定位到是哪一环出了问题,是代码、是镜像、是Service配置,还是Jenkins任务本身。这种能力,远比背熟几张八股文面试题更有价值。等你有过几次半夜爬起来排查ImagePullBackOff或者CrashLoopBackOff的经历之后,你自然就会明白,K8s和Jenkins不是运营团队的专属工具,而是Java应用生命周期里绕不开的一部分。最后再分享一个小技巧:把所有和部署相关的脚本,从Dockerfile到Jenkinsfile,从deployment.yaml到Service配置,全部放进Git仓库统一版本管理。这样一旦配合出现问题,你能检查到每次改动的原因,而不是靠“感觉这个配置以前能跑”去过日子。