ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Kubernetes上CI/CD落地实践:从Runner部署到故障排查

Kubernetes上CI/CD落地实践:从Runner部署到故障排查 起步为什么非要把CI/CD搬上Kubernetes这两年凡是做微服务、做云原生的团队几乎都在聊基于k8s的CI/CD实现这个话题。我刚开始接触的时候也有点犯嘀咕原来用Jenkins跑流水线跑得好好的为什么非要折腾到K8s里去等自己真正把整套Pipeline迁到Kubernetes上又趟过几个项目的坑之后才想明白这件事的本质CI/CD本身也是一个有波峰波谷的资源消费者把它跑在K8s上恰恰能利用容器调度的弹性把资源吃干榨净。这篇文章我会从一个实际落地过的方案切入把从组件选型、Runner部署、Pipeline设计到常见故障排查的过程都拆开讲一遍。不管你是刚开始搭K8s集群还是已经在生产环境被CI/CD的稳定性折磨过这篇内容应该都能给你一些可以直接抄作业的参考。先交代一下我用的环境Kubernetes 1.24版本GitLab CE作为代码仓库和CI触发源Runner以Pod方式跑在K8s集群内镜像仓库用Harbor发布工具链用Helm加自研的滚动更新脚本。这套组合不算花哨但胜在接地气、容易复现下面所有的配置和踩坑记录都是基于这套环境。1. 方案设计K8s给CI/CD带来的不只是省钱1.1 传统CI/CD动辄环境不一致的老毛病以前用物理机或虚拟机跑Jenkins最常见的问题就是环境漂移。同一套流水线开发环境跑没事测试环境就报依赖缺失发布到生产又不一样。本质原因在于构建环境是一次性配置好之后慢慢变脏的装了很多临时软件、产生了缓存、环境变量被改来改去最后变成一台只可意会不可言传的神机。把CI/CD迁移到K8s之后每次构建的Runner Pod都是全新创建的基于同一个镜像启动流程结束后Pod销毁不存在环境残留的问题。这就像每次做饭都用一次性厨具虽然碗筷是一次性的但至少永远不会因为上一顿没刷干净而吃出怪味。1.2 弹性伸缩流水线排队不再是头疼事另一个痛点是企业里多个业务线共用一套CI高峰期流水线排队能排半小时以上。传统做法是多买几台机器当Jenkins agent可是空闲时段这些机器又闲着费电。K8s上挂Runner用的是Pod高峰期HPA自动扩出多个Runner副本低峰期缩容到几乎为零只为常驻的管理组件付资源。我在实践里遇到一个实际案例每周五下午是发布高峰期队列里最多挂过40多个Pipeline Job以前需要跑40分钟迁到K8s后因为并发Pod可以开到10个以上整体耗时压到了8分钟左右。1.3 声明式基建CI/CD的基础设施即代码在K8s中一切资源都用YAML描述Runner的部署、流水线的执行环境、发布用的Deployment全部可以纳入Git版本管理。这样有一个很直接的好处任何人都可以通过Merge Request来修改CI配置审计有记录回滚也方便不再需要SSH到某台服务器上手工改配置。结合Argo CD做GitOps整个CI/CD链路从人追着任务跑变成Git提交推动一切发生。2. 工具选型别迷信Jenkins按场景挑主菜2.1 主流CI/CD工具在K8s环境下的能力对比我在选型的时候横向对比过几款主流工具下面这个对比可以作为参考工具调度方式配置形式K8s原生程度适合场景学习曲线Jenkins可以跑在Pod里但核心CJ是不朽的Groovy DSL中等需要插件适配老项目迁移、复杂构建矩阵偏陡GitLab CIRunner Pod动态调度天然亲和K8sYAML简洁直观高Runner直接注册到集群代码已在GitLab上的团队平缓Tekton完全以CRD定义每个任务都是PodYAML CRD极高本身就是K8s应用想要全链路可声明的团队中等Argo CD作为发布工具专注持续部署YAML 仓库同步极高GitOps模式部署应用平缓Drone容器化Pipeline简洁YAML高配置轻量轻量团队、云原生新项目平缓我最终选择了GitLab CI配合Argo CD的组合。原因很简单公司代码本来就在GitLab上CI直接复用GitLab的身份体系和Merge Request流程不需要额外维护一套用户权限。Argo CD则专注做部署这件事把Git仓库里的YAML声明同步到集群完美补充GitLab CI在发布状态管理上的短板。2.2 一个教训别把发布逻辑塞进CI脚本里第一版设计时我把发布步骤直接写在GitLab CI的stage里用kubectl set image去更新Deployment镜像。跑了一段时间发现几个问题一是不好追踪哪个镜像对应哪个版本跑了多久二是如果发布到一半失败CI脚本里的回滚逻辑往往写得很粗糙容易把环境搞脏三是审计困难K8s里的资源实际状态与Git记录经常对不上。后来我调整为CI只负责构建镜像、推送镜像、更新「应用版本清单」这个Git仓库里的YAML文件Argo CD检测到Git仓库变化后自动完成集群内的部署和同步。这样一来凡是涉及把YAML应用到集群的动作全部由Argo CD负责回滚只需要把Git历史回退集群状态和Git记录永远保持一致。2.3 为什么不推荐自己搭一套Kubed也有人问过我为什么不考虑自研一套Orchestrator我直接劝退。自研调度器意味着要处理并发重试、死锁、资源竞争、失败恢复等等一堆顶级难题团队没到几十人规模根本养不起这种复杂度。用成熟工具的组合初期可能多花一点理解成本但长期看是在为未来的可用性买保险。3. 在K8s上部署Runner与构建Agent3.1 用Helm快速部署GitLab RunnerGitLab Runner官方提供了Helm Chart这是我认为最省事的部署方式。先添加仓库再准备自定义values文件helm repo add gitlab https://charts.gitlab.io helm repo update然后准备runner-values.yamlgitlabUrl: https://gitlab.example.com/ runnerRegistrationToken: 你的注册token rbac: create: true rules: - apiGroups: [] resources: [pods, pods/exec, secrets, pods/log] verbs: [get, list, watch, create, update, patch, delete] clusterWideAccess: false concurrent: 10 tolerations: [] nodeSelector: {}这里rback配置我踩过一次坑一开始权限开得太大给了cluster-admin导致任何能进Runner系统的人都等于拿到了集群管理权限这在高规格审计下非常危险。建议只给Pod管理相关的权限按命名空间隔离限制。部署命令helm install gitlab-runner gitlab/gitlab-runner -n ci-system -f runner-values.yaml等Pod起来后确认Runner已经注册到GitLabkubectl logs -n ci-system -l appgitlab-runner --tail50看到 Checking for jobs... nothing 这种日志说明一切正常。3.2 Runner Pod的动态调度策略GitLab Runner在K8s下的工作方式本质上就是一个控制器它从GitLab拿Job然后为每个Job创建对应的Pod来执行。每个Job Pod由runner-helper和用户定义的image共同组成。如果Pipeline里同时跑20个Job它就会创建20个Pod这非常符合按需使用的弹性理念也是我们无需自建调度器的原因。然而在实际生产中我发现有几个细节必须配置好否则会翻车一是每个Job Pod的资源限制必须显式声明。忘了设limits会让Pod无限占用节点内存最终触发节点OOM。二是默认的namespace建议划分独立的ci命名空间。有人把Runner Job Pod创建在default结果权限混乱删除的时候还会误删。三是concurrent参数要考虑集群容量。开太高会让Job Pod挤占业务Pod的资源开太低又浪费弹性优势。我自己的经验是一个普通3节点集群8C16G把concurrent设置为5比较稳妥。3.3 缓存与持久化一失误就慢到崩溃K8s Runner的一个天然弱点是每个Job Pod都是全新的缓存不落地的话每次构建都要重新下载依赖严重降低速度。一开始我没配置持久化缓存前端项目npm install要跑10分钟后端Maven要跑5分钟简直崩溃。后来在runner-values中加入持久化配置用NFS或Ceph作为后端存储runners: cache: storageClass: nfs-client size: 10Gi配置完成后第二次构建时间明显缩短npm缓存命中后同一项目的install时间从10分钟降到1分钟以内。官网文档没强调这一点但生产环境必须配置否则Pipeline速度会让你怀疑人生。3.4 关于私有镜像仓库的认证配置构建完成后推送镜像到私有仓库Harbor需要在Runner中配置docker认证。我采用的方案是在K8s中创建一个Secret然后在Runner的Pod中挂载kubectl create secret docker-registry harbor-auth \ --docker-serverharbor.example.com \ --docker-usernameadmin \ --docker-passwordxxx \ -n ci-system然后通过环境变量方式注入到Runner配置runners: config: | [[runners]] environment [DOCKER_AUTH_CONFIG{\auths\:{\harbor.example.com\:{\auth\:\base64字符串\}}}]生成base64字符串可以用echo -n admin:xxx | base64注意这里很容易出错的是JSON转义建议先在本地验证生成的字符串能否正确解析不然Runner日志会一直报认证失败。4. Pipeline设计与核心环节实操4.1 标准Pipeline的五个阶段一个典型的K8s配套CI/CD Pipeline我通常设计为五个阶段验证validate、构建build、推送push、更新清单update-manifest、通知notify。下面是GitLab CI的YAML片段你可以直接照抄改参数stages: - validate - build - push - update-manifest - notify variables: IMAGE_TAG: $CI_COMMIT_SHORT_SHA IMAGE_NAME: harbor.example.com/demo/app validate: stage: validate image: golang:1.20 script: - gofmt -l . - go vet ./... - go test ./... -cover build: stage: build image: docker:20.10 services: - docker:20.10-dind script: - docker build -t ${IMAGE_NAME}:${IMAGE_TAG} . tags: - k8s-runner push: stage: push image: docker:20.10 services: - docker:20.10-dind script: - docker push ${IMAGE_NAME}:${IMAGE_TAG} needs: - build update-manifest: stage: update-manifest image: alpine:3.18 before_script: - apk add --no-cache git git-lfs yq script: - git clone -b main https://oauth2:${DEPLOY_TOKEN}gitlab.example.com/ops/demo-manifest.git - cd demo-manifest - yq eval .image.tag \${IMAGE_TAG}\ -i values.yaml - git config user.email ciexample.com - git config user.name CI Bot - git commit -am Update image tag to ${IMAGE_TAG} - git push origin main rules: - if: $CI_COMMIT_BRANCH main notify: stage: notify image: curlimages/curl:latest script: - curl -X POST -H Content-Type: application/json -d {msg:发布完成} http://webhook.example.com/ci rules: - if: $CI_COMMIT_BRANCH main这段配置里包含了我认为非常重要的两个设计tag: k8s-runner明确指定用K8s注册的Runner执行避免误用了别的Runner组。update-manifest阶段没有直接连K8s集群执行kubectl而是向一个独立的manifest仓库提交变更。这个仓库里的values.yaml就是Argo CD同步的源这样整个发布过程可以通过Git历史追溯。谁在什么时间把哪个镜像发到了生产一查便知。4.2 滚动更新与弹性发布策略应用部署到K8s上的Deployment配置有一个核心参数是strategy。实战中建议采用如下配置strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1说白了先起一个新Pod等它Ready之后再杀掉一个旧Pod以此保证不中断流量。maxUnavailable设为0很重要这意味着在整个发布过程中始终至少有旧版本的全部副本在跑避免出现发布到一半没服务可用的情况。当初没经验时我直接用默认的25% maxUnavailable结果有一次发布到第二个节点时整个服务的可用副本数降到只有原来的75%流量高峰直接打崩了剩余Pod。从那之后对核心服务我都坚持maxUnavailable: 0。还有readinessProbe必须配好它决定K8s认为Pod什么时候可以接流量。有次我把readinessProbe的端口写错导致新Pod永远不Ready滚动更新卡死最后还得手动处理。配置项建议用readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 54.3 利用HPA实现自发弹性伸缩既然已经把应用装进K8s没有理由不用HPA。一个典型的HPA配置如下apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: demo-app-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: demo-app minReplicas: 3 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70这里需要注意的是HPA依赖metrics-server提供指标数据没装metrics-server的话HPA根本不会工作。我见过不少初学者在这里卡很久。集群没有采集CPU数据的组件HPA自然拿不到指标弹性从始至终没生效。4.4 版本回滚的兜底方案即使有Argo CD的Git回滚能力我也建议在CI层面保留一个手动回滚Job作为保险丝。因为Argo CD回滚需要修改Git仓库并等待同步过程相对慢而紧急情况下直接使用kubectl rollout undo往往更快。kubectl rollout undo deployment/demo-app -n production这个命令会将Deployment回滚到上一个Revision同时保留完整的历史记录。我建议在部署脚本中把每次发布前的Revision号记录下来比如发布前执行kubectl rollout history deployment/demo-app -n production然后记下当前REVISION号写进发布单或日志文件。万一需要回退直接指定目标Revisonkubectl rollout undo deployment/demo-app --to-revision3 -n production这比重新构建镜像再发布要快得多在生产故障处理中是最高效的兜底操作。5. 生产环境中常见故障与排查实录5.1 API Server初始化健康检查失败的排查这是很多人在搭集群时都会遇到的问题master节点初始化时提示the API server is not healthy after 4m0.00747357s。第一次遇到这个报错我差点以为是kubeadm的问题。后来检查才发现是容器运行时和系统配置不匹配。常规的排查顺序是先看kubelet日志确认容器运行时如containerd是否正常再检查系统参数特别是iptables和swap。kubeadm对swap的要求很严格必须关闭不然kubelet起不来API Server也就无法健康。我整理了一个排查清单现象检查项处理方式API Server not healthykubelet状态systemctl status kubelet看是否有容器运行时错误API Server not healthyswap是否关闭free -h确认swap为0关闭swap分区API Server not healthy容器运行时cgroup driver确认containerd的cgroup driver与kubelet一致kubelet报connection refused网络插件未装初始化后安装Calico或Flannel集群Node显示NotReadyCNI插件冲突检查/opt/cni/bin和/etc/cni/net.d配置记得有次初始化失败后重新执行kubeadm reset然后手动清理了/etc/kubernetes下的残留配置再重新初始化才成功。这类残留状态问题最坑因为报错信息完全是误导性的。5.2 Pod长时间Pending资源与调度的隐形墙Runner Pod提交后一直Pending这种现象在K8s里可以说是家常便饭。主要的排查角度有这些节点资源不足。看节点剩余量kubectl describe node。存在污点taint导致Pod无法调度。检查节点污点并配置对应的容忍度。PVC绑定失败。因为Runner配置了持久化缓存如果StorageClass没有对应的ProvisionerPVC会一直Pending。我自己遇到最无语的一次是StorageClass写错了名字PVC一直Pending但Runner日志完全没提示只显示Job卡在pending状态。后来执行kubectl describe pod才看到persistentvolumeclaim not found这样的提示瞬间定位。所以遇到Pod Pending我的建议顺序是describe pod - describe node - describe pvc基本能把90%问题定位出来。5.3 使用ExternalIPs方式暴露应用时经常踩的坑关于k8s externalips这个热搜词一句话说透ExternalIPs的作用是把Service关联到节点或外部设备的IP上让集群外部可以通过这个IP访问服务。但在生产环境我其实不太推荐用这种暴露方式因为它缺少负载均衡、健康检查的高级特性更像是手动绑定IP的折中方案。如果你非要测试环境里用一用可以这样配置apiVersion: v1 kind: Service metadata: name: demo-web spec: type: ClusterIP externalIPs: - 192.168.1.100 ports: - port: 80 targetPort: 8080 selector: app: demo-web需要注意externalIPs不会自动绑定到网卡需要确保该IP实际可用且被路由到集群节点。否则外部流量根本进不来。在正式环境我建议优先用NodePort或LoadBalancer如果用的是云厂商的K8sLoadBalancer能自动分配公网IP省事得多如果用裸金属可以考虑MetalLB这类负载均衡器而不是手工维护externalIPs。5.4 调用GPU资源时的配置细节关于K8s调用GPU这个热搜词简单分享一点经验。K8s原生是不感知GPU的需要安装设备插件比如NVIDIA的device plugin。安装后你需要给节点打上特定label然后在Pod中显式声明gpu资源resources: limits: nvidia.com/gpu: 1注意两个关键点limits和requests必须一致否则调度会报错另外GPU资源的调度粒度是卡不是显存你不能跟容器说给我2GB显存只能说给我1张卡。如果Pod启动后报Failed to initialize NVML: Unknown Error通常就是显卡驱动和容器内的CUDA版本不匹配。解决办法是确保基础镜像的驱动版本与宿主机驱动兼容建议参考NVIDIA官方镜像tag对应关系。这个坑我在跑模型训练任务时深入体会过校验驱动版本是前期最容易被忽略的一步。5.5 镜像拉取失败ImagePullBackOff排查这个故障算是CI/CD链路里发病率最高的一个。出现ImagePullBackOff通常原因是几点镜像仓库地址写错或不存在。私有仓库认证失败Secret里的docker-server与镜像地址不匹配。镜像tag不存在。运行Pod的命名空间下没有对应的imagePullSecret。排查技巧很直白先describe pod查看Events里面有具体的报错原因。如果是认证问题Events会显示unauthorized: authentication required这时去检查Secret配置。我在实践里专门写了一个镜像拉取自检脚本用与Pod相同的Secret生成一个手动Job去拉取镜像能快速复现问题。这个思路比kubectl describe更主动推荐大家尝试。5.6 K8s Operator实战把CI/CD流程也做成控制器热搜词里出现了operator这个关键词很多人在学这个。我之前也用Operator模式做了一个发布控制器通过监听自定义CRD自动从Harbor拉取最新镜像并更新Deployment。这个思路的好处在于发布逻辑被封装成一个带有调谐循环的控制器如果发布失败它会自动重试、记录状态、甚至可以自动回滚到上一个可用版本。用Operator重构之后CI脚本里那份更新Deployment的脚本彻底消失了因为整个发布引擎变成了集群里的一个控制器。每次更新镜像tag只需要通过Kubernetes API修改CRD的规格控制器自动完成剩余工作。这个模式非常契合云原生的终极目标一切皆声明、一切皆自动。6. 最后再分享一点实际体会这套基于K8s的CI/CD方案跑了一年多我最大的感受是基础设施一旦进入可声明、可追踪、可回滚的状态整个研发节奏会变得异常顺畅。以前发布是一件需要若干人盯着的仪式现在只要你代码推上去后面的事情全自动发生。还有一点想特别强调不要试图一上来就做全自动化先把手工流程走熟再逐步用工具替代人工判断。我见过很多团队急于把所有环节自动化结果在镜像构建、发布确认这些环节反而引入了更多不确定性。建议分三步走先用脚本半自动发布再引入CD工具完成自动部署最后才考虑用Operator把异常处理也自动化。在工具选型上如果团队还没有太重的历史包袱GitLab CI加Argo CD这套组合非常值得一试。如果你已经在用Jenkins也不要急着迁移可以把K8s Runner作为Jenkins的agent接入这样既能享受弹性又不用一次性推翻现有体系。最后分享一个小技巧在你信任自动化之前给Argo CD的同步加一个PreSync钩子用来执行自动化冒烟测试。测试不过Argo CD不会继续同步应用不会被替换。这个钩子的成本很低但它是整个PDCA闭环里最值得投入的一环。我在实际使用中踩掉上面的那么多坑之后最大的收获不是学会了多少个命令而是理解了K8s这套系统的核心思想任何复杂的状态变化只要抽象成声明式对象都可以被安全地自动化。把你的CI/CD流程也往这个思路上靠你会发现它愈用愈顺手。
返回列表