XXL-JOB 容器化部署验证:K8s 调度中心、执行器与优雅停机避坑

发布时间:2026/10/23 19:53:37
XXL-JOB 容器化部署验证:K8s 调度中心、执行器与优雅停机避坑

简介:面向需要在 Kubernetes 集群中落地 XXL-JOB 的运维与开发人员,这份 YAML 部署清单可用于快速验证容器化任务调度服务的集群部署流程,减少从零编写资源清单、反复排查配置错误的时间成本。压缩包共 1 个文件,为 YAML 类型的 Kubernetes 资源定义,体积约 782B,内容集中精炼,便于直接应用、对照修改或纳入版本管理。已有 647 人学习下载,说明该部署方式在同类场景中具有一定参考价值。读者可从中获取 XXL-JOB 在 K8s 环境下的资源组织示例,理解镜像、服务暴露及部署参数的基本配置思路,并以此为基础完成部署验证、环境适配和后续扩展;对于正在搭建测试集群、需要一份可运行 YAML 模板的读者,也能提供直接的起点与排错参照。

1. XXL-JOB 部署到 K8s:Pod 全绿只是开始,验证要走完三步

清单 apply 下去,三个 Pod 全绿,浏览器能打开调度中心登录页,很多人到这儿就收工了。可第一次点“执行一次”,控制台弹出java.net.ConnectException,执行器管理页里机器那一栏是空的——这才是 Kubernetes 上跑 XXL-JOB 真正的分水岭。这份验证版 YAML 的价值不在于“能启动”,而在于把调度中心、执行器、库表初始化、探针路径、时区、优雅停机这些默认值提前固定下来,让一套集群部署可以重复 apply、可以被别人接手复现。它适合已经在虚拟机上跑过 XXL-JOB、准备迁到集群的团队,也适合接手别人清单、需要快速判断“这套东西到底能不能直接用”的从业者。

2. 拆开这份 YAML 清单:调度中心、执行器、数据库各自占哪一格

拿到一套号称“直接部署成功”的清单,我一般不会先 apply,而是先数 kind。数完你就知道这套东西的边界在哪:它替你处理了哪些默认值,哪些还得自己填。XXL-JOB 在容器化场景下被拆成三个进程,它们对 K8s 的诉求完全不同,混在一个清单里写,后面排查会非常难受。

2.1 三个进程与三类 K8s 资源的对应关系

先把对应关系摆清楚,后面的所有参数才有落点。

进程典型 K8s 资源状态性关键约束
xxl-job-admin 调度中心Deployment + Service,按需加 Ingress无状态,可多副本多副本必须共享同一个 MySQL,注册信息走库不走内存
执行器(内嵌 xxl-job-core 的业务应用)Deployment + Service无状态,但注册地址必须可预期端口要能被 admin 直连,注册上去的地址不能随 Pod 重建而漂移
MySQL外部实例,或 StatefulSet + PVC有状态库丢了,任务配置、执行器分组、调度日志全丢

调度中心是整个体系里唯一“看起来无状态、实际上强依赖数据库”的组件。它自己不做任务存储,所有执行器分组、任务定义、调度记录都落在xxl_job_group、xxl_job_info、xxl_job_log这几张表里。所以 admin 扩容到 2 个副本是安全的,前提是两个副本连的是同一个库,并且xxl.job.accessToken完全一致。

执行器这块最容易出问题。xxl-job-core 启动时会向调度中心注册自己,注册内容是一个 HTTP 地址。在虚拟机上,这个地址就是固定的内网 IP,写死也行;在 K8s 里,Pod 每次重建 IP 都变,如果让框架自动取本机 IP,注册表里留下的就是一条会过期的记录。这也是后面第 4 章要重点讲的地方。

数据库放哪,决定了这份清单是一份还是两份。用云上的托管实例,清单里就只剩 admin 和执行器;用集群内自建,就得多一个 StatefulSet、一个 headless Service 和一份初始化 SQL。

2.2 部署前必须定死的常量

以下这几个值,在写第一行 YAML 之前就要定下来。中途改一个,往往要连带改探针、Service、Ingress、执行器注册地址四处。

常量建议值影响面
admin 端口8080Service targetPort、探针、Ingress backend
admin context-path/xxl-job-admin探针路径、注册地址、Ingress path,三个地方必须一字不差
执行器端口9999Service port、注册地址、admin 侧连通性
执行器日志目录/data/applogs/xxl-job/jobhandlerPVC 挂载点、磁盘容量规划、日志清理策略
时区Asia/Shanghaicron 触发时间、调度日志时间戳
accessToken自定义强随机串admin 与所有执行器必须完全一致

context-path 这一项坑最多。XXL-JOB 的 admin 默认带/xxl-job-admin前缀,很多人写探针时按习惯写/或/actuator/health,容器起来后探针一直 404,kubelet 判定不健康,Pod 被反复重启,日志里却干干净净写着Started XxlJobAdminApplication。这不是应用的问题,是探针路径的问题。

时区同样别省。容器基础镜像默认 UTC,JVM 跟着系统时区走,你配的0 0 2 * * ?会在北京时间上午十点触发,而且调度日志里的时间戳全部差 8 小时,排查的时候会以为自己看错了表。

提示:这几个常量建议直接写进清单顶部的注释块,改的时候一眼能看到影响范围,比散落在十几个文件里强。

2.3 数据库为什么不建议和执行器挤在同一份清单

有人图省事,把 MySQL、admin、执行器写成一个大 YAML,apply 一次全起来。演示环境没问题,一旦进入滚动更新就会出事:MySQL 的 Pod 重启期间,admin 的数据库连接池全部失效,健康检查失败,被判定为不健康;执行器此时心跳注册也失败,注册表里的记录在清理周期后被摘掉,等数据库恢复,调度中心那边已经认为“这台机器下线了”。

更合理的组织方式是按职责拆文件,并且固定 apply 顺序:

# 按依赖顺序 apply,顺序错了会出现 admin 连不上库、执行器注册到不存在的 admin kubectl apply -f 00-namespace.yaml kubectl apply -f 01-configmap.yaml # admin 与执行器的 application.properties kubectl apply -f 02-secret.yaml # 数据库密码、accessToken kubectl apply -f 03-mysql.yaml # 用外部托管库时跳过这一步 kubectl apply -f 04-admin.yaml # Deployment + Service kubectl apply -f 05-executor.yaml # 业务应用 + Service

00到05这个编号本身就是文档。新人接手时不用问“先起哪个”,按编号敲下去就行。这套顺序里唯一可以乱的是 ConfigMap 和 Secret,它们只是被挂载,不参与启动依赖;但 admin 必须晚于数据库就绪,执行器必须晚于 admin 的 Service 可解析,这两条不能违背。

另外,把数据库和执行器放同一份清单还有个隐性代价:kubectl delete -f的时候会一起删。生产环境里手滑一次,任务定义全没。分开写,至少多一道确认。

3. 调度中心落地:ConfigMap 拆配置、Job 灌库表、Deployment 配探针

调度中心是这套体系的门面,它的部署质量直接决定了后面执行器能不能顺利挂上来。这一章按“配置外置 → 库表初始化 → 工作负载与探针”三步走,每一步都给可直接抄的片段。

3.1 配置外置:把 application.properties 拆进 ConfigMap 与 Secret

不建议把配置打进镜像。同一个镜像要在测试、预发、生产三套环境跑,唯一的差别就是数据库地址和 token,打进镜像意味着要构建三次。

apiVersion: v1 kind: ConfigMap metadata: name: xxl-job-admin-config namespace: xxl-job data: application.properties: | server.port=8080 # 这个前缀会同时影响探针路径、注册地址和 Ingress 转发规则 server.servlet.context-path=/xxl-job-admin spring.datasource.url=jdbc:mysql://mysql.xxl-job.svc.cluster.local:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false spring.datasource.username=xxl_job spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver xxl.job.accessToken=change-me-please xxl.job.i18n=zh_CN # 调度日志保留天数,配合调度中心自身的清理线程 xxl.job.logretentiondays=30

数据库密码不进 ConfigMap,走 Secret:

apiVersion: v1 kind: Secret metadata: name: xxl-job-admin-secret namespace: xxl-job type: Opaque stringData: # 键名按 Spring 的宽松绑定规则写,容器里会自动映射到 spring.datasource.password SPRING_DATASOURCE_PASSWORD: "replace-with-your-password" MYSQL_ROOT_PASSWORD: "replace-with-root-password"

这里有两个细节值得说。第一,属性文件名必须是application.properties,挂到容器的/config目录后,还得通过SPRING_CONFIG_ADDITIONAL_LOCATION=file:/config/application.properties显式告诉 Spring Boot 去哪儿读,否则它会按默认的 classpath 顺序找,你挂进去的文件根本不会被加载。第二,xxl.job.accessToken在 ConfigMap 里是明文,这在实际项目里通常不能接受,稳妥做法是同样放进 Secret,用环境变量XXL_JOB_ACCESSTOKEN注入——Spring 的宽松绑定会把xxl.job.accessToken映射到这个变量名,配置文件里就不用再写一行。

注意:改完 ConfigMap 后 Pod 不会自动重启。要么kubectl rollout restart deployment/xxl-job-admin,要么在 Deployment 的 podTemplate 上打个配置版本号的 annotation 触发滚动更新。

3.2 库表初始化:用一个一次性 Job 灌 SQL,而不是塞进 admin 的启动脚本

XXL-JOB 的库表结构来自官方提供的tables_xxl_job.sql。容器化部署里常见的两种做法:塞进 MySQL 镜像的/docker-entrypoint-initdb.d,或者用 initContainer。前者只在数据目录为空时执行一次,云上托管库根本用不上;后者要求 admin 和 MySQL 在同一个 Pod 里,也不现实。

这两种做法都有个共同风险:那份 SQL 脚本开头是DROP TABLE IF EXISTS,重复执行会把线上任务配置清空。所以初始化必须是一次性的、幂等的、可人工确认的。

apiVersion: batch/v1 kind: Job metadata: name: xxl-job-dbinit namespace: xxl-job spec: backoffLimit: 0 # 失败不重试,避免反复执行 DROP template: spec: restartPolicy: Never containers: - name: dbinit image: mysql:8.0 command: ["/bin/sh", "-c"] args: - > mysql -h mysql.xxl-job.svc.cluster.local -u root -p"$MYSQL_ROOT_PASSWORD" -e "CREATE DATABASE IF NOT EXISTS xxl_job DEFAULT CHARACTER SET utf8mb4;" && mysql -h mysql.xxl-job.svc.cluster.local -u root -p"$MYSQL_ROOT_PASSWORD" xxl_job < /sql/tables_xxl_job.sql env: - name: MYSQL_ROOT_PASSWORD valueFrom: secretKeyRef: name: xxl-job-admin-secret key: MYSQL_ROOT_PASSWORD volumeMounts: - name: sql mountPath: /sql volumes: - name: sql configMap: name: xxl-job-init-sql

SQL 文件本身通过 ConfigMap 挂进去,建 ConfigMap 的命令只有一行:

# 把官方库表脚本挂成 ConfigMap,注意单文件不能超过 1MiB kubectl -n xxl-job create configmap xxl-job-init-sql \ --from-file=tables_xxl_job.sql=./tables_xxl_job.sql

backoffLimit: 0是这里最关键的一个参数。默认值是 6,一旦 SQL 执行到一半失败,Job 会重试六次,而脚本里的DROP TABLE每次都会先执行,如果库中已经有数据,就是六次清空。设成 0 之后,失败就停在那里,人上去看日志再决定手动重跑。

跑完之后记得把 Job 删掉:kubectl -n xxl-job delete job xxl-job-dbinit。留着它不影响运行,但下次有人kubectl apply -f整个目录时会重新跑一遍,那就真出事了。我一般会把这份 Job YAML 单独放在init/子目录里,不跟常规清单混在一起。

3.3 Deployment 与 Service:探针路径必须带 context-path

工作负载这一块,新手最容易翻车的是探针。先看清单:

apiVersion: apps/v1 kind: Deployment metadata: name: xxl-job-admin namespace: xxl-job spec: replicas: 1 selector: matchLabels: app: xxl-job-admin template: metadata: labels: app: xxl-job-admin spec: containers: - name: admin # 换成你自己仓库里的镜像地址,不要直接引用公网 tag image: your-registry/xxl-job/xxl-job-admin:2.x ports: - containerPort: 8080 env: - name: TZ value: Asia/Shanghai - name: JAVA_OPTS value: "-Xms512m -Xmx512m -Duser.timezone=Asia/Shanghai" - name: SPRING_CONFIG_ADDITIONAL_LOCATION value: "file:/config/application.properties" - name: SPRING_DATASOURCE_PASSWORD valueFrom: secretKeyRef: name: xxl-job-admin-secret key: SPRING_DATASOURCE_PASSWORD volumeMounts: - name: config mountPath: /config - name: logs mountPath: /data/applogs # 启动慢的应用先用 startupProbe 兜底,避免 liveness 过早介入 startupProbe: httpGet: path: /xxl-job-admin/ port: 8080 failureThreshold: 30 periodSeconds: 5 readinessProbe: httpGet: path: /xxl-job-admin/ port: 8080 periodSeconds: 10 timeoutSeconds: 3 livenessProbe: httpGet: path: /xxl-job-admin/ port: 8080 periodSeconds: 20 failureThreshold: 3 volumes: - name: config configMap: name: xxl-job-admin-config - name: logs persistentVolumeClaim: claimName: xxl-job-logs --- apiVersion: v1 kind: Service metadata: name: xxl-job-admin namespace: xxl-job spec: selector: app: xxl-job-admin ports: - name: http port: 8080 targetPort: 8080

逻辑说明:三种探针分工不同。startupProbe只管启动阶段,成功一次之后就不再执行,failureThreshold: 30配合periodSeconds: 5给了 150 秒启动窗口,JVM 冷启动加上数据库连接池初始化通常 40 到 90 秒,够用。readinessProbe决定 Pod 是否接流量,探针失败只是从 Endpoints 里摘掉,不会重启。livenessProbe才会重启容器,所以它的阈值要放宽,failureThreshold: 3配 20 秒间隔意味着连续 60 秒不可用才动手。

如果镜像的启动脚本不吃JAVA_OPTS,上面的 JVM 参数会被忽略。验证办法很简单:kubectl -n xxl-job exec deploy/xxl-job-admin -- ps -ef | grep java,看进程命令行里有没有带上-Duser.timezone。没有的话,用args直接覆盖 entrypoint,或者把参数写进JAVA_TOOL_OPTIONS环境变量——后者被 JVM 无条件读取,兼容性最好。

Service 这一层没什么花样,port和targetPort都写 8080 就行。真正要注意的是名字:xxl-job-admin这个 Service 名会被执行器写进注册地址里,改名字等于改所有执行器的配置。

4. 执行器接入:注册地址写 Service DNS,还是让框架自己取 IP

调度中心起来只是把台子搭好,执行器注册不上,一切都是空转。这一章是整套部署里信息密度最高的部分,也是“虚拟机思维”和“集群思维”差别最大的地方。

4.1 执行器的三个必填参数

执行器本质上是你自己的业务应用,加一个xxl-job-core依赖。它的核心配置项不多,但每一项都有默认值和优先级,理解优先级比记住字段名更重要。

apiVersion: v1 kind: ConfigMap metadata: name: xxl-job-executor-config namespace: xxl-job data: application.properties: | server.port=8081 # 调度中心地址,多副本时用逗号分隔多个地址 xxl.job.admin.addresses=http://xxl-job-admin.xxl-job.svc.cluster.local:8080/xxl-job-admin # 必须与调度中心完全一致,不一致会报 access token 校验失败 xxl.job.accessToken=change-me-please # 执行器分组名,调度中心的任务按这个名字找机器 xxl.job.executor.appname=xxl-job-executor-sample # 关键:显式声明对外暴露地址,不要让框架自己取 Pod IP xxl.job.executor.address=http://xxl-job-executor.xxl-job.svc.cluster.local:9999/ xxl.job.executor.port=9999 xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler xxl.job.executor.logretentiondays=30
参数作用不填的后果
xxl.job.admin.addresses注册目标,多个用逗号分隔执行器完全不注册,调度中心看不到机器
xxl.job.executor.appname执行器分组标识任务绑定的分组找不到机器,报“执行器地址为空”
xxl.job.executor.address显式对外地址,优先级最高退回 ip:port,再退回自动取本机 IP
xxl.job.executor.port内置 Netty 服务端口默认 9999,与 Service 端口不一致会导致连不通
xxl.job.accessToken双向校验报 “The access token is wrong”,注册被拒

address、ip、port三者的优先级是固定的:address非空就直接用address;否则用ip:port拼接;两者都没有才走NetUtils.getIp()自动获取。K8s 里 Pod IP 是动态分配的,自动获取等于把一个会过期的值写进注册表。

4.2 为什么要把地址钉在 Service DNS 上

注册表xxl_job_registry里存的是registry_key(执行器分组)、registry_value(注册地址)、update_time(心跳时间)。执行器约 30 秒发一次心跳,调度中心有个清理线程,把超时未更新的记录删掉。整个机制在虚拟机上是成立的,因为 IP 不变;在 K8s 里就变成了“和清理线程赛跑”。

设想一个典型场景:执行器 Deployment 有 3 个副本,Pod IP 分别是 10.244.1.7、10.244.1.9、10.244.2.3。某次发布后,三个 Pod 全部重建,新 IP 是 10.244.2.11 到 10.244.2.13。如果执行器配置的是自动取 IP,注册表里很快会变成新地址,看起来没事——但如果调度中心在旧记录还没过期的那一刻下发任务,请求就会打到已经不存在的 IP 上,报连接超时。更糟的情况是 Pod IP 被复用:旧 IP 被分配给了集群里另一个服务,任务就发到完全不相干的地方去了。

把xxl.job.executor.address写成 Service 的全限定域名,等于让注册表存一个永久有效的逻辑地址:

http://<service-name>.<namespace>.svc.cluster.local:<port>/

Service 的 ClusterIP 在整个生命周期内不变,kube-proxy 负责把请求转发到当前健康的 Pod。执行器重建多少次,注册表里的值都是同一个,调度中心的视角里这台机器只是在持续心跳而已。

注意:这种写法要求调度中心和执行器在同一个集群内,且能解析集群 DNS。跨集群或者执行器部署在集群外时,得换成 NodePort、Ingress 或专线可达的地址,别硬套.svc.cluster.local。

还有一个边界:address写成一个 Service 名之后,注册表里就只能看到一个地址,即使你有 10 个执行器副本。这对调度来说是好事——调度中心会按地址做负载均衡;但对排查来说是坏事——你没法从控制台的“在线机器”列表里区分是哪个 Pod 在跑。折中方案是用 headless Service 配合xxl.job.executor.address留空、改配xxl.job.executor.ip指向固定网络标识,代价是运维复杂度上升,一般项目用不到。

4.3 滚动更新与优雅停机:terminationGracePeriodSeconds 怎么给

默认的优雅停机时间是 30 秒。执行器收到 SIGTERM 后,会停止接受新任务,但正在跑的任务线程不会立刻中断——它没有强制熔断机制,JVM 退出时线程直接被杀,调度中心那边看到的就是任务执行到一半失联,日志表里状态卡在“运行中”。

apiVersion: apps/v1 kind: Deployment metadata: name: xxl-job-executor namespace: xxl-job spec: replicas: 2 strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 # 保证发布期间至少有一个可用副本 maxSurge: 1 template: metadata: labels: app: xxl-job-executor spec: # 给足时间让在跑的任务收尾,按你最长任务的耗时来定 terminationGracePeriodSeconds: 90 containers: - name: executor image: your-registry/your-app/xxl-job-executor:2.x ports: - containerPort: 8081 # 业务 HTTP 端口 - containerPort: 9999 # xxl-job 内置服务端口 lifecycle: preStop: exec: # 先从 Service 上摘流量,再让应用自己停 command: ["/bin/sh", "-c", "sleep 20"]

三段逻辑要配合起来看。maxUnavailable: 0保证滚动更新时始终有副本在服务;preStop里的 20 秒是给 kube-proxy 更新 Endpoints 留的窗口,避免新请求还打到正在关闭的 Pod;terminationGracePeriodSeconds: 90是从发出 SIGTERM 到 SIGKILL 的总时长,preStop的 20 秒也算在这个总量里,所以实际留给业务收尾的是 70 秒。

这个数字不是拍脑袋定的。先看调度中心里配置的任务超时时间,再取最长任务的正常执行耗时,往上加 30 秒缓冲。如果某个任务本身就设了 300 秒超时,那 90 秒肯定不够,得往上调;反过来说,所有任务都在 5 秒内结束,给 90 秒只是让发布变慢,没必要。

提示:调度中心侧还有一个保险——任务配置里可以开“失败重试”,配合“路由策略”里的故障转移,能在执行器被 kill 后重新路由到别的副本。但重试是补偿手段,不能代替优雅停机,重复执行带来的幂等问题更麻烦。

5. 避坑与排查:上线后最容易翻车的五个现场

下面这五条,是我在实际环境里遇到过的、也是验证版清单最容易漏掉的地方。每条按现象、原因、解决三段写,遇到的时候可以直接对照。

5.1 执行器列表显示在线,任务点下去还是连接失败

现象:调度中心的“执行器管理”页面能看到地址和在线机器,点“执行一次”后控制台报ConnectException,日志表里记录状态为失败。

原因:注册表是有缓存的。执行器心跳失败后,注册记录并不会立刻消失,清理线程按周期扫超时项,这中间有个窗口期。Pod 重建后 IP 变了,旧记录还在表里,调度中心按旧地址下发,自然连不上。另一种情况是xxl.job.executor.address只写了ip:port而没写协议头,调度中心拼出来的 URL 不合法。

解决:先把address改成 Service 全限定域名并加上http://前缀和结尾的/;然后手动查一下残留记录,确认清理线程在正常工作:

-- 找出心跳超过 90 秒没更新的注册记录,正常情况应该为空 SELECT registry_key, registry_value, update_time FROM xxl_job_registry WHERE update_time < DATE_SUB(NOW(), INTERVAL 90 SECOND);

5.2 探针把健康的 Pod 反复重启

现象:kubectl get pods里 RESTARTS 一路涨到几十,kubectl logs里明明是正常启动的日志,没有异常堆栈。

原因:九成是探针路径不对。admin 的 context-path 是/xxl-job-admin,有人按习惯写/或/actuator/health。Spring Boot 默认不暴露 actuator 端点,health 路径直接 404;路径写成/则返回 302 跳转,kubelet 把 3xx 当作失败处理。剩下的一成是initialDelaySeconds太短,JVM 还没起完 liveness 就开始探测。

解决:探针路径统一写/xxl-job-admin/,末尾的斜杠别省;liveness 前面挂一个 startupProbe;如果临时排查,可以先把 liveness 整段注释掉,确认应用本身没问题再加回来。

5.3 cron 配的凌晨两点,实际上午十点才触发

现象:任务配置0 0 2 * * ?,日志显示的执行时间是 10:00 左右,调度日志的时间戳也整体偏移。

原因:容器基础镜像是 UTC 时区,JVM 启动时读系统时区,Spring 的@Scheduled和 Quartz 表达式都按这个时区解释。TZ环境变量没设,或者设了但 JVM 在之前就读过默认时区,都可能导致偏移。

解决:环境变量TZ=Asia/Shanghai和 JVM 参数-Duser.timezone=Asia/Shanghai两个都加,前者影响系统调用,后者影响 JVM 内部时间。数据库连接串里的serverTimezone=Asia/Shanghai也补上,否则写入xxl_job_log的时间戳还会再偏一次。改完在容器里执行date和kubectl exec ... -- jshell -q -e 'System.out.println(java.time.ZoneId.systemDefault())'双向确认。

5.4 日志把节点磁盘写满,Pod 被驱逐

现象:节点状态变成DiskPressure,其他不相干的 Pod 被驱逐,kubectl describe node显示镜像和日志占满了根分区。

原因:xxl.job.executor.logpath落在容器可写层,也就是/data/applogs/xxl-job/jobhandler没挂卷。日志按天分目录写,每个任务每次执行一个文件,高频任务一天能写几个 G。容器重启后日志虽然在,但节点上的空间已经被占了,而且logretentiondays的清理逻辑依赖执行器自己的线程,容器被删掉后这些文件没人管。

解决:把/data/applogs挂到 PVC 或 hostPath,并在节点上配一层日志轮转做双保险;xxl.job.executor.logretentiondays按业务频率调小,高频任务建议 7 天;如果日志要长期留存,加个 sidecar 把文件投到日志平台,本地只留三天。

5.5 滚动更新期间部分任务中断

现象:发布过程中调度日志里出现几条失败记录,错误信息是连接被重置或者响应超时,重试后成功。

原因:默认 30 秒优雅期太短,preStop没配,kube-proxy 更新 Endpoints 有延迟,SIGTERM 发出去的时候应用还在处理任务。另外maxUnavailable默认 25%,两副本的 Deployment 在更新时可能同时摘掉一个,剩余容量不够扛。

解决:按第 4.3 节的配置改terminationGracePeriodSeconds、preStop和maxUnavailable: 0;同时在调度中心给任务打开失败重试,把重试次数设成 1,业务侧保证幂等。这三件事缺一件都还会出问题。

6. 验证闭环:三条命令确认这次部署真的成了

Pod 全绿是必要条件,不是充分条件。我习惯用三步确认,每一步都能独立证伪。

验证项操作位置期望结果
调度中心可达浏览器或 curl 访问/xxl-job-admin/返回 200,登录页正常渲染
网络路径通畅从 admin 容器内 curl 执行器地址返回 200 或 405,不是超时
注册记录新鲜查xxl_job_registry表update_time在 60 秒内

第二步是最有价值的一步,因为它把“服务发现”和“应用逻辑”隔离开了:

# 从调度中心容器里反向验证执行器地址通不通 # 这一步能排除 DNS 解析、Service 选择器、NetworkPolicy 三类问题 kubectl -n xxl-job exec deploy/xxl-job-admin -- \ curl -s -o /dev/null -w "%{http_code}\n" \ http://xxl-job-executor.xxl-job.svc.cluster.local:9999/ # 确认注册表里的心跳是新鲜的,而不是几天前的残留记录 kubectl -n xxl-job exec -it mysql-0 -- \ mysql -uxxl_job -p"$MYSQL_PASSWORD" xxl_job \ -e "SELECT registry_key, registry_value, update_time FROM xxl_job_registry;"

如果第二步返回 000,说明根本没连上,去查 Service 的 selector 是否匹配 Pod 标签;返回 404 说明连上了但路径不对,检查xxl.job.executor.address结尾有没有斜杠;返回 200 就说明网络链路完全打通,剩下的问题只可能在 token 校验或者分组名上。

第三步经常被忽略。注册表里的旧记录不会自己消失得那么快,看到一条记录就以为注册成功,实际可能是几天前留下的。update_time必须落在心跳周期内,才是真的活着。

最后说一个习惯:从那以后我每次改完 YAML,都会先把 admin 的 Deployment 缩到 0 再拉起来,模拟一次完整的冷启动,因为滚动更新掩盖了太多初始化顺序问题——数据库连接、注册表重建、日志目录创建,这些只在冷启动时才会暴露。真正的验证不是“它能跑”,而是“把它全删了重来一遍,它还能跑”。希望帮到你。

本文还有配套的精品资源,点击获取