K8s落地CI/CD流水线:从架构设计到故障排查全记录

发布时间:2026/10/1 4:19:14
K8s落地CI/CD流水线:从架构设计到故障排查全记录 最近把团队的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 CDGitOps部署工具把Git仓库作为唯一事实来源自动同步部署状态只管CD不管CI需要配合一套CI工具一起用Harbor镜像仓库支持镜像漏洞扫描、权限分级、镜像复制本身不是CI工具是配合整个链路的基础设施我个人最终的组合是GitLab CI Kaniko K8s原生Deployment发布后期引入Argo CD做GitOps。选GitLab CI的原因很直接——团队代码本来就在GitLab上少维护一套Jenkins就少一堆事。Runner配Kubernetes executor后每个Job本质上就是一个PodCI任务跟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 --frombuilder /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-proddemo-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个副本滚动更新默认参数是maxSurge25%、maxUnavailable25%。这意味着发布过程中允许最多比期望副本数多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 executorRunner的安装方式很多但要让CI/CD真正跑在K8s上关键是让Runner使用Kubernetes executor。所谓Kubernetes executor是指Runner每次接收到Job之后不会在本机起一个进程来执行而是去K8s集群里创建一个PodJob在这个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与RBACRunner在K8s集群里要干活总得有授权。最安全的做法是给它最小权限的ServiceAccount不要一上来就cluster-admin。以下是一份比较推荐的RBAC配置只允许操作指定命名空间内的Deployment和PodapiVersion: 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 appdemo-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列表OOMKilledlimits内存设置过低、Java应用堆内存超限调大limits同时优化JVM参数CreateContainerConfigErrorconfigmap或者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-serverregistry.example.com \ --docker-usernamexxx \ --docker-passwordxxx需要注意的是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模式也引入进来这条路会更有意思——很多通用发布场景可以封装成自定义控制器让熟悉状态调谐逻辑的程序帮你管理应用生命周期。不过这是另一个话题了先把流水线基础和故障排查能力打扎实后续再往深了走会更从容。