最近把团队的CI/CD流水线整体迁移到了K8s上,整个交付节奏从原来的一周一发变成了想发就发,发布这件事也从“高危操作”变成了日常操作。做这套东西之前我也犹豫过,项目本身不算大,非要上一套K8s流水线是不是有点小题大做。跑完第一个版本之后我确认了:这套改造不只是把构建部署的脚本换了换,而是把整个交付的思路从头到尾理了一遍。这篇文章把我整个落地过程、设计取舍和踩过的坑完整记录下来,给准备在K8s上做CI/CD的同行一个参考。篇幅不短,基本上从架构选择讲到具体命令,重点是解释清楚每个环节的“为什么”,而不是单纯贴配置。
1. 先从设计说起:为什么CI/CD跑在K8s上才是最终形态
1.1 传统交付流程到底卡在哪里
先说一个我亲身经历的典型场景。好几年前做项目交付,流程大概是这样的:开发本地编译打包,然后通过跳板机拷到服务器上,关掉旧进程,启动新进程,整个过程中还有人在群里喊“大家先别提交”“快发版了都别动”。这套模式最大的问题不是慢,而是不确定——本地环境、测试环境、生产环境,三套环境存在各种微妙差异,代码没变但运行结果变了,排查起来极其痛苦。
即使后来引入了Jenkins,把“编译-打包-拷贝-重启”自动化了一部分,也只是把脚本串起来而已。一旦换了机器,或者依赖版本变动,构建出来的产物可能就直接失真。回滚就更麻烦了,没有一套机制告诉你上次可用版本是什么、对应的配置是什么,全靠翻命令历史。
这些痛点在容器化时代被放大了,同时也有了解决方案。镜像一旦构建出来,就是不可变交付物——代码、运行时、依赖、配置全都打在镜像里,测试环境跑的是它,生产环境跑的还是它,环境差异从根上被抹掉了。但光把应用打包成镜像还不够,你得有一套机制把这些镜像稳定、可靠、可回滚地发布到运行环境里,这就是K8s要解决的第二个问题。
1.2 K8s把“部署”变成了“声明”
K8s给我的最大感受是,它把传统运维中“黑话式”的操作流程变成了“声明式”的状态管理。以前你要告诉运维“帮我把包放到这台机器上,然后执行启动脚本”,现在你只需要描述“我希望这个应用最终是3个副本,跑最新镜像,端口8080”,剩下的调度、重启、伸缩、健康检查,K8s自己搞定。
这跟CI/CD结合之后,威力非常大。你想滚动更新,Deployment自带RollingUpdate策略;你想回滚,一条kubectl rollout undo就回到上一个版本;你想扩容,改一下replicas或者配个HPA就完了。而且这些“声明”本身都是YAML文本,可以放进Git仓库,于是整个发布历史变成了可审计、可追溯的Git记录。
很多人觉得K8s门槛高,我觉得门槛不在K8s本身,而在思维转换——从“如何执行某个动作”变成“如何描述期望状态”。一旦接受这个思路,后面做CI/CD会流畅很多。
1.3 工具选型对比与我的选择
关于CI/CD工具链,圈子里的方案五花八门。我把常见几套对比一下,这张表花了不少时间整理,几乎每个方案我都实际用过或至少做过PoC验证。
| 工具 | 典型定位 | 优点 | 需要注意的地方 |
|---|---|---|---|
| Jenkins | 传统CI服务器 | 生态庞大、插件多、老团队容易上手 | 维护成本和系统复杂度偏高,新项目用起来有点重 |
| GitLab CI | 与GitLab深度集成 | 配置简单、Pipeline自定义程度高、内置镜像仓库 | 部分高级功能需要收费版 |
| Tekton | 云原生CI/CD框架 | 完全跑在K8s上、CRD方式定义流水线、支持可复用任务 | 学习曲线比较陡,生态还在成长 |
| Argo CD | GitOps部署工具 | 把Git仓库作为唯一事实来源,自动同步部署状态 | 只管CD不管CI,需要配合一套CI工具一起用 |
| Harbor | 镜像仓库 | 支持镜像漏洞扫描、权限分级、镜像复制 | 本身不是CI工具,是配合整个链路的基础设施 |
我个人最终的组合是:GitLab CI + Kaniko + K8s原生Deployment发布,后期引入Argo CD做GitOps。选GitLab CI的原因很直接——团队代码本来就在GitLab上,少维护一套Jenkins就少一堆事。Runner配Kubernetes executor后,每个Job本质上就是一个Pod,CI任务跟K8s结合得最紧密,资源调度、并发数这些都能交给集群处理。
2. 拆解流水线关键环节:从镜像构建到滚动更新的原理
2.1 镜像构建:kaniko为什么比DinD更适合在K8s上跑
在K8s环境里跑CI,第一个绕不开的问题是:镜像怎么构建?最“惯性”的做法是Docker-in-Docker,也就是在构建容器里再装一个Docker Daemon。听起来简单,实际用起来问题不少——特权模式会让安全边界变成摆设,Daemon崩溃可能导致所有并发构建互相影响,镜像缓存管理也是一团乱。
所以我在K8s上做镜像构建时用的是kaniko。kaniko是Google开源的工具,它的运行方式跟平常的docker build不太一样,不需要Daemon,直接解析Dockerfile里的每一条指令,在用户态把镜像层解包、执行、再打包。作为Pod里的一个普通容器进程,它不需要特权,不会污染宿主机,很适合K8s这种隔离环境。
下面是我常用的一个多阶段构建Dockerfile,以Spring Boot服务为例:
# 第一阶段:编译 FROM maven:3.8-openjdk-11 AS builder WORKDIR /src COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests # 第二阶段:运行 FROM openjdk:11-jre-slim RUN useradd --create-home appuser USER appuser WORKDIR /app COPY --from=builder /src/target/demo-0.0.1-SNAPSHOT.jar ./app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]第一阶段装依赖、编译、出包,第二阶段只把最终产物放进去。这样阶段一用的Maven镜像比较重也没关系,阶段二精简到极致,生产镜像可以控制在几十MB。这里还有个容易忽略的点——RUN useradd那一步。生产容器如果默认root启动,安全扫描大概率会有告警,后续排查问题也会很痛苦,还是一开始就建个低权限用户比较稳。
在GitLab CI里配合kaniko的实际执行命令是这样:
build-image: stage: build image: name: gcr.io/kaniko-project/executor:debug entrypoint: [""] script: - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA2.2 镜像版本管理:tag设计决定可回滚性
镜像构建出来之后,第一个要命的决策是tag怎么打。我看到很多团队一上来就爱用latest,这样确实省事,但很快就会发现两个严重问题:一是没法表达“我部署的是哪个代码版本”,二是镜像仓库里的latest被反复覆盖,本地缓存跟线上镜像不一致,出问题后拿着latest根本无法回滚。
我的做法是tag尽量跟代码提交绑定。GitLab CI里自带CI_COMMIT_SHORT_SHA这个变量,拿它当镜像tag就是最直观的做法——每个commit都能找到对应的镜像,回滚时改一下tag重新部署就完事。如果还要区分正式环境以及测试环境,可以在后面拼一层环境标识,比如demo-app:effa6c1-prod,demo-app:effa6c1-staging。
这里特别想强调一下:如果团队里有多个人维护流水线,tag命名规范一定要先定下来,白纸黑字写到流程文档里。我见过太多线上事故是因为某个人手动打了一个不规范的tag,结果部署出错后所有人都不知道这个镜像是哪来的。规范这东西不能靠人自觉,得靠系统约束。
2.3 部署更新三阶段:kubectl、Helm、GitOps
镜像推送到仓库后,就到了部署环节。部署方式选择其实是跟团队的成熟度挂钩的,不需要一步到位,但方向要想清楚。
最开始可以只用kubectl set image。这个命令在CI Job里执行一行就完成镜像升级:
kubectl -n demo set image deployment/demo-app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA如果应用只有一个或少数几个Deployment,这种直白的方式足够用了。但随着应用变多,配置开始重复,values、环境差异、版本管理这些问题冒出来,就需要引入Helm。Helm把一组K8s资源打成一个chart包,通过参数化渲染不同环境的部署配置,升级和回滚也都有版本记录。
再往后走,就到了GitOps模式。GitOps的核心思想是“Git仓库里保存的YAML就是环境的唯一事实来源”,Argo CD这类工具会持续监控Git仓库,一旦仓库里的内容有变化,就自动把线上环境调整成跟仓库一致的状态。CI流水线只负责构建镜像和更新Git仓库里的tag,部署动作完全交给Argo CD完成。这种模式的好处是权限边界特别清晰——开发者可以改应用代码,但不一定有直接操作生产集群的权限,所有变更先过Git,审核流程自然就有了。
2.4 滚动更新参数的计算逻辑
Deployment的滚动更新策略里有两个关键参数:maxSurge和maxUnavailable。这两个参数看着不起眼,实际发布安全和发布速度全看它们怎么配。
说一个最常见的例子。假设一个服务有4个副本,滚动更新默认参数是maxSurge=25%、maxUnavailable=25%。这意味着发布过程中,允许最多比期望副本数多25%(也就是多1个新Pod),同时允许最多有25%的Pod处于不可用状态。整个发布过程里,旧Pod逐步减少,新Pod逐步增加,集群里Pod总数的变动范围在3到5之间。在任意时刻,至少有3个Pod在正常服务,不会出现全部副本同时重建的情况。
如果服务对可用性要求特别高,可以把maxUnavailable调成0,这意味着发布期间旧Pod一个都不提前销毁,只允许新增Pod,等新Pod通过就绪检查后再轮流摘掉旧的。代价是发布过程中需要两倍Pod资源量,发布周期也会长一些,但服务始终是满血状态。
这里必须要配合就绪探针readinessProbe才有意义。没有就绪探针的话,K8s无法判断新Pod是不是真的能接流量,滚动更新可能只是形式上的“起了一圈新Pod”,实际服务入口还在旧Pod上,发布失败但是滚动状态还是显示成功。我自己踩过这个坑,那次排查花了大半天,最后发现就是少写了一个readinessProbe。
3. 完整实操:搭建一套能跑的K8s CI/CD流水线
3.1 环境准备:K3s + GitLab Runner的快速起法
搭建这套环境,我不建议一开始就上多节点生产集群。自己学习或者小团队实验的话,K3s是最适合的起点。K3s是CNCF认证的轻量Kubernetes发行版,二进制小、内存占用低,API跟原生K8s完全兼容,一台2C4G的机器就能跑得很流畅。后续要切到正式环境的多节点集群,流水线配置基本不需要改。
安装K3s非常简单,一条命令就完成了:
curl -sfL https://get.k3s.io | sh -装完后执行kubectl get nodes确认节点状态,你会发现Node已经Ready了。这一步验证过了,说明集群基础是好的,不用像传统K8s那样装完还要折腾CNI插件和kubelet配置。
还有一套GitLab服务,最简单的做法是用Docker Compose在另一台机器上跑一个轻量的GitLab实例。这里有个经验之谈:GitLab是个内存大户,跑起来至少需要4G内存,如果机器内存不够的话,建议直接用gitlab/gitlab-ce镜像把CI功能关掉一部分再跑。不是生产环境的话,把Prometheus监控这些耗时耗内存的组件关掉,体验会好很多。
3.2 配置Runner走Kubernetes executor
Runner的安装方式很多,但要让CI/CD真正跑在K8s上,关键是让Runner使用Kubernetes executor。所谓Kubernetes executor,是指Runner每次接收到Job之后,不会在本机起一个进程来执行,而是去K8s集群里创建一个Pod,Job在这个Pod里运行,执行完就销毁。每个Job相互隔离,不会有环境残留,并发跑几十个Job也只是集群资源的事。
安装Runner我是直接用Docker跑一个gitlab-runner容器,然后注册到GitLab实例。注册后的Runner配置文件通常在/etc/gitlab-runner/config.toml里,open这个文件,把executor改成kubernetes:
[[runners]] name = "k8s-runner" url = "https://gitlab.example.com/" token = "你的注册token" executor = "kubernetes" [runners.kubernetes] namespace = "ci-cd" service_account = "gitlab-runner" image = "docker:20.10"这一段配置里,namespace指定了Job Pod会被创建到哪个命名空间,service_account指定了Job Pod使用哪个服务账号。权限最小化原则下,这个ServiceAccount只需要能操作必要的资源就行,下面会专门写。
3.3 为部署授权:ServiceAccount与RBAC
Runner在K8s集群里要干活,总得有授权。最安全的做法是给它最小权限的ServiceAccount,不要一上来就cluster-admin。以下是一份比较推荐的RBAC配置,只允许操作指定命名空间内的Deployment和Pod:
apiVersion: v1 kind: ServiceAccount metadata: name: gitlab-runner namespace: ci-cd --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: deployment-manager namespace: ci-cd rules: - apiGroups: ["apps"] resources: ["deployments", "deployments/scale"] verbs: ["get", "list", "watch", "create", "update", "patch"] - apiGroups: [""] resources: ["pods", "pods/log"] verbs: ["get", "list", "watch"] - apiGroups: ["extensions", "networking.k8s.io"] resources: ["ingresses"] verbs: ["get", "list", "watch", "create", "update", "patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: deployment-manager-binding namespace: ci-cd subjects: - kind: ServiceAccount name: gitlab-runner namespace: ci-cd roleRef: kind: Role name: deployment-manager apiGroup: rbac.authorization.k8s.io一个常见的坑是,Runner配置了ServiceAccount但是没绑定权限,Job启动后调用kubectl一直报forbidden。这时候不要急着给cluster-admin,先看RoleBinding的namespace对不对——很多权限问题最后查下来就是Role建对了,RoleBinding却建到了别的namespace。
3.4 编写流水线与落地一次发布
前置条件都准备好之后,核心的.gitlab-ci.yml长这样。我这里把构建、测试、推送、部署四个阶段都放进去,并把关键参数写清楚:
stages: - build - test - package - deploy variables: KUBE_NAMESPACE: ci-cd APP_NAME: demo-app build: stage: build image: maven:3.8-openjdk-11 script: - mvn compile test: stage: test image: maven:3.8-openjdk-11 script: - mvn test artifacts: when: always reports: junit: target/surefire-reports/TEST-*.xml package: stage: package image: name: gcr.io/kaniko-project/executor:debug entrypoint: [""] script: - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA deploy: stage: deploy image: bitnami/kubectl:latest script: - kubectl -n $KUBE_NAMESPACE set image deployment/$APP_NAME app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA - kubectl -n $KUBE_NAMESPACE rollout status deployment/$APP_NAME这是最简单、最直白的一套流水线,跑通一次从提交代码到上线新版本的完整链路。你在GitLab上提交一个MR或者直接push到主干,Pipeline就会依次执行。执行过程中可以在GitLab的Pipeline页面看到每个Job的实时日志,哪一步挂了直接就能定位到。
部署应用前需要先把Service、Deployment这些基础资源定义好,比如Deployment的滚动更新参数和健康检查就按下面的格式写在manifests里:
apiVersion: apps/v1 kind: Deployment metadata: name: demo-app namespace: ci-cd spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: demo-app template: metadata: labels: app: demo-app spec: containers: - name: app image: registry.example.com/demo-app:latest ports: - containerPort: 8080 readinessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 15 periodSeconds: 20 resources: requests: cpu: 100m memory: 256Mi limits: cpu: 500m memory: 512Mi这里maxUnavailable设成0、maxSurge设成1,是保可用性优先的配置——发布过程中始终有3个旧Pod在提供服务,新Pod最多提前起来1个,只有它ready了才继续往下滚动。
发布完成后用一下命令查看状态,确认Pod全部就绪:
kubectl -n ci-cd get pods -l app=demo-app kubectl -n ci-cd rollout status deployment/demo-app如果新版本有问题,一条命令回到上个版本:
kubectl -n ci-cd rollout undo deployment/demo-app4. 故障排查实录:K8s CI/CD最常见的坑与解法
4.1 控制平面初始化失败
很多第一次自己搭K8s集群的朋友都会遇到一个问题:master节点初始化之后卡住,输入kubeadm init后来一句类似“the apiserver is not healthy after 4m”的报错。这句话翻译过来是:kubelet已经启动了,但是控制平面的apiserver容器长时间没有通过健康检查。
这种问题九成出在三个地方。第一是swap没关,kubelet对swap很敏感,kubeadm初始化之前必须swapoff -a,并且把/etc/fstab里的swap条目注释掉。第二是kubelet和容器运行时的cgroup驱动不一致,kubelet默认用systemd,容器运行时如果用了cgroupfs,两者配合不起来,apiserver就起不来。第三是网络插件没就位,CNI不装的话,apiserver容器即使起来了也无法正常工作,可以用kubectl get pods -n kube-system看看有没有Pending的Pod。
排查顺序建议这样:先看kubelet状态,systemctl status kubelet,再看日志journalctl -u kubelet -f,最后确认为基础环境问题。查完再init一次,通常问题就好解决。
4.2 Pod常见异常与排查思路
流水线跑起来之后,大部分时间都是在跟Pod的异常状态打交道。我把平时遇到最多的几种异常整理成一个速查表,方便遇到问题直接对照。
| 异常状态 | 典型原因 | 排查方向 |
|---|---|---|
| Pending | 资源不足、节点亲和性不匹配、PVC未绑定 | kubectl describe pod看事件,查request是否超节点容量 |
| CrashLoopBackOff | 启动命令错误、健康检查失败、依赖服务没就绪 | 看容器日志、检查启动参数、确认探针路径 |
| ImagePullBackOff | 镜像仓库地址错、imagePullSecret失效、私有仓库认证问题 | 重启Pod之前先手动docker pull一遍,看报错明细 |
| Running但无法访问 | Service selector不对、端口映射错、Pod健康但Endpoint没更新 | kubectl describe service,看Endpoints列表 |
| OOMKilled | limits内存设置过低、Java应用堆内存超限 | 调大limits,同时优化JVM参数 |
| CreateContainerConfigError | configmap或者secret引用不存在 | kubectl describe pod会显示具体缺失对象 |
逐个说一下我的排查心法。遇到Pod状态异常,第一步永远是kubectl describe pod,而不是kubectl logs。describe输出里最后一段Events几乎把原因写完了——调度失败会告诉你insufficient cpu/memory,镜像拉取失败会告诉你具体错误,探针失败会告诉你健康检查哪个环节挂了。99%的Pod问题都能从Events里找到线索,真正需要看容器日志的场景反而是少数。
关于readinessProbe和livenessProbe,这里有个特别容易搞混的细节。readinessProbe失败只会导致Pod从Service Endpoints中摘除,不会被杀掉;livenessProbe失败才会触发容器重启。如果你看到Pod一直重启,先检查livenessProbe检查的接口是否真的返回200,以及initialDelaySeconds是否给足。有些应用启动慢,2秒容器都没起来就执行健康检查,那就会被K8s无限重启,形成死循环。
4.3 镜像与权限问题
镜像拉取失败绝对是高频坑。特别是接了私有镜像仓库之后,很容易遇到ImagePullBackOff。我遇到过几次,主要原因都是Deployment模板里忘了配置imagePullSecrets。K8s默认不会把每个Secret都拿去向镜像仓库做认证,必须显式声明:
spec: template: spec: imagePullSecrets: - name: registry-secret创建这个secret也有固定格式:
kubectl -n ci-cd create secret docker-registry registry-secret \ --docker-server=registry.example.com \ --docker-username=xxx \ --docker-password=xxx需要注意的是,secret是namespace级别的。如果生产、测试、开发各占一个namespace,每个namespace都要创建对应secret,否则换个namespace部署就拉不到镜像。
权限问题的另一个常见场景是RBAC配置。Runner的ServiceAccount权限给少了,Job调用kubectl执行部署就会报forbidden。我建议在部署阶段使用独立ServiceAccount,并明确授予对应namespace的Deployment操作权限,而不是把整个集群的管理员权限都交出去。
4.4 实战总结与经验心得
整套流水线跑通之后,我自己复盘了几条心得,可能对刚准备接手这套体系的朋友更有价值。
第一条,镜像tag永远不要用latest。这条我必须再强调一次。任何“图省事”的tag方案最后都会变成事故的起点,找不到对应代码、没法回滚、缓存不一致,全是latest惹的祸。
第二条,流水线脚本里的凭证不要明文放在.gitlab-ci.yml里,全部通过CI/CD的Secret变量注入。仓库里一旦出现了真实密码,撤销和重新分发凭证的代价可比配置变量的成本高得多。
第三条,CI和CD要区分清楚。CI负责代码到镜像的过程,包括编译、测试、扫描;CD负责镜像到运行环境的过程,包括部署、滚动、回滚。很多人把两者揉在一起,导致只要代码一提交就直接上生产,这是非常危险的行为。至少要加一个环境校验或审核步骤,让发布到生产环境前有一道人工确认关卡。
第四条,把Argo CD当成下一阶段目标。这套方案用kubectl set image已经很顺手,但遇到多集群部署、需要细粒度权限管理、想要更好的审计能力时,GitOps模式的优势会逐渐体现出来。把期望状态放进Git仓库,Argo CD自动调谐,发布行为完全可追溯,这才是K8s上CI/CD的最终形态。
另外如果你想把Operator模式也引入进来,这条路会更有意思——很多通用发布场景可以封装成自定义控制器,让熟悉状态调谐逻辑的程序帮你管理应用生命周期。不过这是另一个话题了,先把流水线基础和故障排查能力打扎实,后续再往深了走会更从容。