把 Jenkins 从单机服务迁入 K3s,真正要处理的不是「启动一个容器」,而是持久化、入口 TLS、Controller 的最小权限,以及构建任务与 Controller 的隔离。本文以 K3s 默认 local-path 存储和 Traefik 为例,部署 Jenkins Controller,并让 Kubernetes 插件按需创建、使用后自动回收 Agent Pod。
目录
部署前检查
本文环境已由 Traefik 统一承接公网 HTTP/HTTPS,并在入口层完成 HTTP 到 HTTPS 的跳转。Jenkins 使用 jenkins.hyperbola.cc,默认 StorageClass 为 local-path。先确认集群、存储和入口资源均处于可用状态:
kubectl get nodes -o widekubectl get storageclasskubectl get ingress -Akubectl -n kube-system get secret hyperbola-cc-wildcard-tlslocal-path 的卷存储在被调度节点的本地磁盘。它适合单副本 Jenkins,但不具备跨节点高可用能力;节点迁移、节点故障或升级前都应先备份 /var/jenkins_home 对应的数据卷。生产环境若需要跨节点恢复与快照,应改用 Longhorn、NFS 或其他满足 RWO/RWX 需求的存储。
TLS Secret 的命名空间边界
Ingress 的 spec.tls[].secretName 只能引用同一命名空间的 Secret。即使集群已经在 kube-system 中维护通配符证书,jenkins 命名空间的 Ingress 也不能直接使用它。
创建命名空间并复制证书时,必须删除源对象的服务端元数据:
kubectl create namespace jenkins
kubectl -n kube-system get secret hyperbola-cc-wildcard-tls -o json \ | jq 'del( .metadata.creationTimestamp, .metadata.resourceVersion, .metadata.uid, .metadata.managedFields, .metadata.ownerReferences ) | .metadata.namespace="jenkins"' \ | kubectl apply -f -
kubectl -n jenkins get secret hyperbola-cc-wildcard-tls这份证书副本不会随源 Secret 自动更新。更可靠的方案是在目标命名空间由 cert-manager 单独签发,或部署 Secret 同步控制器。若集群已经配置 Traefik TLSStore/default 作为默认证书,也可以让业务 Ingress 不再声明 tls.secretName,避免副本续期漂移;应以当前入口架构为准,不要混用两种策略。
部署 Jenkins Controller
下面的清单创建 10 GiB PVC、受限 ServiceAccount/RBAC、单副本 Controller、集群内 Service 和 HTTPS Ingress。Recreate 可以避免升级时两个 Pod 同时读写同一个 RWO 卷;fsGroup: 1000 则让 Jenkins 进程能够写入持久卷。
保存为 jenkins.yaml:
apiVersion: v1kind: Namespacemetadata: name: jenkins---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: jenkins-home-10gi namespace: jenkinsspec: accessModes: [ReadWriteOnce] storageClassName: local-path resources: requests: storage: 10Gi---apiVersion: v1kind: ServiceAccountmetadata: name: jenkins namespace: jenkins---apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: name: jenkins-agent-manager namespace: jenkinsrules: - apiGroups: [""] resources: ["pods", "pods/log", "pods/exec"] verbs: ["create", "delete", "get", "list", "watch", "patch"] - apiGroups: [""] resources: ["events"] verbs: ["get", "list", "watch"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: name: jenkins-agent-manager namespace: jenkinssubjects: - kind: ServiceAccount name: jenkins namespace: jenkinsroleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: jenkins-agent-manager---apiVersion: apps/v1kind: Deploymentmetadata: name: jenkins namespace: jenkinsspec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: jenkins template: metadata: labels: app: jenkins spec: serviceAccountName: jenkins securityContext: fsGroup: 1000 fsGroupChangePolicy: OnRootMismatch containers: - name: jenkins image: jenkins/jenkins:latest imagePullPolicy: IfNotPresent env: # Jenkins 使用中国标准时间(UTC+8)显示构建记录和日志 - name: TZ value: Asia/Shanghai - name: JAVA_OPTS value: -Duser.timezone=Asia/Shanghai ports: - name: http containerPort: 8080 - name: agent containerPort: 50000 volumeMounts: - name: jenkins-home mountPath: /var/jenkins_home startupProbe: httpGet: { path: /login, port: http } periodSeconds: 10 failureThreshold: 60 readinessProbe: httpGet: { path: /login, port: http } periodSeconds: 10 failureThreshold: 6 livenessProbe: httpGet: { path: /login, port: http } periodSeconds: 20 failureThreshold: 3 volumes: - name: jenkins-home persistentVolumeClaim: claimName: jenkins-home-10gi---apiVersion: v1kind: Servicemetadata: name: jenkins namespace: jenkinsspec: selector: app: jenkins ports: - name: http port: 8080 targetPort: http - name: agent port: 50000 targetPort: agent---apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: jenkins namespace: jenkins annotations: traefik.ingress.kubernetes.io/router.entrypoints: websecure traefik.ingress.kubernetes.io/router.priority: "1000"spec: ingressClassName: traefik tls: - hosts: [jenkins.hyperbola.cc] secretName: hyperbola-cc-wildcard-tls rules: - host: jenkins.hyperbola.cc http: paths: - path: / pathType: Prefix backend: service: name: jenkins port: name: http镜像标签应在生产环境固定为已验证的 LTS 版本或 digest,不能长期依赖 latest。端口 50000 只保留给集群内通信;本文后续使用 WebSocket Agent,因此无需把它暴露到公网。
应用并等待首次启动完成:
kubectl apply -f jenkins.yamlkubectl -n jenkins rollout status deployment/jenkins --timeout=10mkubectl -n jenkins get pod,pvc,svc,ingress -o wide设置 Jenkins 时区
Jenkins 的时间由 JVM 时区决定。若未显式设置,容器通常使用 UTC,页面中的构建时间、计划任务和日志会比中国标准时间早 8 小时。为统一显示 UTC+8,在 Controller 容器中同时设置系统时区变量和 JVM 参数:
env: - name: TZ value: Asia/Shanghai - name: JAVA_OPTS value: -Duser.timezone=Asia/Shanghai已运行的 Deployment 可以直接更新并等待滚动发布:
kubectl -n jenkins set env deployment/jenkins \ TZ=Asia/Shanghai \ JAVA_OPTS='-Duser.timezone=Asia/Shanghai'kubectl -n jenkins rollout status deployment/jenkins --timeout=5m验证容器时区:
kubectl -n jenkins exec deployment/jenkins -- \ date '+%Y-%m-%d %H:%M:%S %Z %z'预期结果包含 CST +0800。将环境变量写入 Deployment 清单,后续重建或升级 Jenkins 时不会恢复为 UTC。
初始化与验证
Jenkins 初次启动会在 PVC 中生成初始管理员密码。它只应显示在受控终端中,绝不能写入文章、Git 仓库或构建日志:
kubectl -n jenkins exec deployment/jenkins -- \ cat /var/jenkins_home/secrets/initialAdminPassword完成初始化向导、创建正式管理员后,验证入口和日志:
curl -I http://jenkins.hyperbola.cc/curl -I https://jenkins.hyperbola.cc/loginkubectl -n jenkins logs deployment/jenkins --tail=100前者应由入口返回 308 并跳转至 HTTPS;后者正常应返回 200。若 Jenkins 未 Ready,不要急于调大 liveness 探针:优先检查 PVC 是否 Bound、镜像是否已拉取、fsGroup 是否解决了卷权限,再查看 Controller 日志。
配置 Kubernetes 动态 Agent
Controller 负责调度和保存状态,不适合直接执行构建。安装 Jenkins Kubernetes 插件后,进入 Manage Jenkins → Clouds → New cloud → Kubernetes,使用以下关键值:
| 配置项 | 值 |
|---|---|
| Name | kubernetes |
| Kubernetes URL | https://kubernetes.default.svc.cluster.local |
| Kubernetes Namespace | jenkins |
| Credentials | 留空,使用 Controller 的 ServiceAccount |
| Jenkins URL | http://jenkins.jenkins.svc.cluster.local:8080 |
| WebSocket | 启用 |
| Container Cap | 5 |
先确认所授予的权限确实受限于 jenkins 命名空间:
kubectl -n jenkins get deployment jenkins \ -o jsonpath='{.spec.template.spec.serviceAccountName}{"\n"}'
kubectl auth can-i create pods \ --as=system:serviceaccount:jenkins:jenkins \ -n jenkins下面的 Pipeline 会创建一个临时 Alpine Pod。任务结束后 Kubernetes 插件会回收 Pod:
podTemplate(yaml: '''apiVersion: v1kind: Podspec: serviceAccountName: jenkins containers: - name: shell image: alpine:3.22 command: [sleep] args: [99d] tty: true''') { node(POD_LABEL) { container('shell') { sh 'echo "Agent Pod: $(hostname)" && uname -a' } }}运行时观察资源:
kubectl -n jenkins get pods -w构建 Pod 通常包含 jnlp 和工作容器两部分。若 Pipeline 需要 Maven、Node.js 或 Kaniko,应按任务在 Pod Template 中声明相应镜像;不要为了某个构建扩大 Controller 的 Role。需要访问 Kubernetes API 的构建也应使用另一个权限更小的 ServiceAccount。
接入 WAF 时的精确排除
若 Jenkins Ingress 接入 ModSecurity OWASP CRS,Pipeline 编辑器和 Stapler 的特殊媒体类型可能触发误报。正确处理方式是依据审计日志的 Host、Method、Path、参数与规则号创建精确排除,而不是关闭 Jenkins 整站 WAF。
Jenkins checkScript 的 932110/932115 精确排除
以下示例仅放行 Stapler render 请求的 920420,以及 Pipeline checkScript 中 oldScript/value 参数被 RCE 规则误判的情况:
SecRule REQUEST_HEADERS:Host "@streq jenkins.hyperbola.cc" \ "id:1001001,phase:1,pass,nolog,chain" SecRule REQUEST_METHOD "@streq POST" "chain" SecRule REQUEST_URI "@rx ^/\$stapler/bound/[0-9A-Fa-f-]+/render$" \ "ctl:ruleRemoveById=920420"
SecRule REQUEST_HEADERS:Host "@streq jenkins.hyperbola.cc" \ "id:1001002,phase:1,pass,nolog,chain" SecRule REQUEST_METHOD "@streq POST" "chain" SecRule REQUEST_URI "@rx ^/job/[^/]+/descriptorByName/org\.jenkinsci\.plugins\.workflow\.cps\.CpsFlowDefinition/checkScript$" \ "ctl:ruleRemoveTargetById=932100;ARGS:oldScript,ctl:ruleRemoveTargetById=932100;ARGS:value,ctl:ruleRemoveTargetById=932105;ARGS:oldScript,ctl:ruleRemoveTargetById=932105;ARGS:value,ctl:ruleRemoveTargetById=932110;ARGS:oldScript,ctl:ruleRemoveTargetById=932110;ARGS:value,ctl:ruleRemoveTargetById=932115;ARGS:oldScript,ctl:ruleRemoveTargetById=932115;ARGS:value,ctl:ruleRemoveTargetById=932130;ARGS:oldScript,ctl:ruleRemoveTargetById=932130;ARGS:value,ctl:ruleRemoveTargetById=932150;ARGS:oldScript,ctl:ruleRemoveTargetById=932150;ARGS:value"一次实际排障中,Request ID 关联的审计事务确认 932110 与 932115(Windows Command Injection)继续扫描 checkScript 的 oldScript/value,两条规则累计 20 分后由 949110 阻断。原先的 1001002 只收窄了 932100、932105、932130 与 932150,漏掉这两个规则,因而修复必须同步补齐它们。
该排除仍被严格限制为 jenkins.hyperbola.cc、POST、CpsFlowDefinition/checkScript 精确路径,以及两个指定参数;其他 Jenkins 路径、参数与 RCE 规则不受影响。每次新增排除都应先以服务端 dry-run 验证清单,再验证正常编辑、恶意请求和相邻路径。修改通过 subPath 挂载的 ConfigMap 后必须滚动重启 WAF Deployment,单纯 kubectl apply 不会让运行中的容器读到新文件。
用 Pipeline Graph View 替换 Blue Ocean
Blue Ocean 已停止功能演进。可以安装 pipeline-graph-view,在 Pipeline 构建详情中使用 Pipeline Overview 查看嵌套 Stage、并行分支和实时日志。
清理 Blue Ocean 前先检查其反向依赖;不要直接删除运行中容器的插件文件。确认无其他插件依赖后,停机、备份插件目录,再删除所有 blueocean* .jpi、.bak 和展开目录:
kubectl -n jenkins scale deployment/jenkins --replicas=0kubectl -n jenkins wait --for=delete pod -l app=jenkins --timeout=5m
# 在 PVC 实际挂载位置执行;先核对输出,再删除。find /var/jenkins_home/plugins -mindepth 1 -maxdepth 1 \ -name 'blueocean*' -print
kubectl -n jenkins scale deployment/jenkins --replicas=1kubectl -n jenkins rollout status deployment/jenkins --timeout=10m恢复后检查日志没有插件加载错误,并确认原有 Pipeline、Kubernetes Cloud 与 HTTPS 入口仍可用。
维护与小结
日常维护命令如下:
kubectl -n jenkins get allkubectl -n jenkins logs -f deployment/jenkinskubectl -n jenkins rollout restart deployment/jenkinskubectl -n jenkins rollout status deployment/jenkins这套方案把 Jenkins 状态固定在 PVC,把构建计算交给短生命周期 Agent,并将 Kubernetes API 权限限制在一个命名空间内。最容易被忽略的三个边界是:TLS Secret 不能跨命名空间直接引用、local-path 不是高可用存储、WAF 误报必须精确收窄。把这三点处理好,Jenkins 才能成为可维护的 K3s CI 基础设施,而不只是一个能打开登录页的容器。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
湘公网安备43010402002198号