首页 / 资讯中心 / 文章详情

在Nomad上托管ClickHouse定时聚合任务:从job设计到踩坑实录

在Nomad上托管ClickHouse定时聚合任务:从job设计到踩坑实录 ★ FEATURED ARTICLE
上个月我接到一个活把一套每天凌晨跑一次的ClickHouse统计聚合从手工维护的cron里捞出来放到Nomad集群上托管。任务本身不复杂——读原始事件表聚合后写回结果表没有实时链路也没有分布式计算。但越是不复杂的事越容易在细节上翻车。我前前后后改了四版clickhouse-job的配置踩了资源限制、优雅退出、日志不落盘和连接抖动几个坑。这篇我把整个设计过程和操作细节串起来写一遍内容包括为什么选batch job、job文件怎么拆、怎么部署和调参、还有问题排查实录给正好在用Nomad跑周期任务的同学做个参照。1. 为什么这个批量任务选Nomad而不是Cron或K8s1.1 任务画像它就是一个典型batch job先把这个任务说清楚每天凌晨3点从ClickHouse的原始行为表里把两小时级别的明细聚合成天级结果表供次日看板使用。数据量大概几千万行聚合后输出不到几十万行单机跑完基本在20分钟以内。这种任务丢给Spark太浪费单独搭一套K8s去托管又显得重。如果团队里已经有一套稳定运行服务任务的Nomad集群最轻的方案就是在同一套集群上加一个batch类型的job。Nomad对这类短生命周期任务的支持比很多人想象中要完整。type batch表示任务进程退出即视为完成配合periodic块可以做内置的定时调度失败重试、跨节点重新调度、资源隔离、Web UI都是原生能力。整套东西不过一个HCL文件不用额外维护Cron跑在哪台机器上也不用担心哪台机器挂了任务就断档。这里有个容易混淆的点Nomad的batch job和periodic job不是二选一的关系而是两个维度。batch是调度类型描述这个job是一次执行而不是常驻服务periodic是定时触发方式描述这个job要不要按cron周期生成运行实例。两者可以叠加使用叠加之后就是你熟悉的“cron 一次性容器”效果只是由Nomad统一管调度和下发。1.2 选型对比Nomad、Cron、K8s怎么选既然题目是“组件部署”那第一步一定绕不开选型。我用一张表把常见的三个方案说一下纯从这种定时批量任务的视角去看方案部署复杂度故障恢复资源隔离适合场景单机Cron最低无机器挂了任务就断无不同任务互相影响只有一两台机器、任务可互相干扰且可容忍失败K8s CronJob较高好但集群运维成本高强团队已有成熟K8s底座任务种类杂、需要自动扩缩容Nomad batch job中低好client故障可重调度中强CPU/内存/磁盘可限制已有Nomad集群任务以容器脚本为主不想维护另一套调度器我最后选了Nomad不是因为它比K8s更强而是因为它对这个场景足够且轻。K8s CronJob要处理镜像拉取策略、并发策略、重启策略、Pod驱逐后的状态概念一多就会把简单任务复杂化。Nomad的job模型直接对齐“运行一个或多个容器直到它们退出”batch job的失败重试逻辑也比较符合批任务直觉。1.3 job设计上的第一原则把查询做成外置脚本写job文件之前先定了一个规则SQL不写死在job里而是放到挂载目录或制品库通过脚本调用。原因很简单——调SQL频率远高于调job文件本身把SQL抽出来之后改查询条件只需要替换文件不需要重新提交job。同时脚本本身也承担了连接参数、错误重试、清理动作这些职责job文件保持精简。这样一来clickhouse-job实际上分成了三层Nomad job负责容器生命周期和资源约束shell脚本负责执行SQL和失败处理ClickHouse侧负责查询与写入。任何一层出问题都能单独替换不会一锅端。2. Job文件拆解一个能跑的clickhouse-job长这样2.1 从无到有job、group、task三层结构Nomad的job文件就是HCL语法结构上分三层最外层是整个job定义调度类型、区域、周期中间是group定义多少实例、什么重启策略最内层是task定义具体跑什么镜像、什么命令、多少资源。这个层级关系理解了后面填参数就不容易乱。先给一个完整示例。这是我这边经过几轮修改后稳定下来的版本几乎覆盖了这类任务90%的配置需求job clickhouse-job { type batch datacenters [dc1] periodic { cron 0 3 * * * time_zone Asia/Shanghai prohibit_overlap true } group ch-etl { count 1 restart { attempts 2 delay 30s mode fail } reschedule { attempts 3 delay 1m unlimited false } task ch-query { driver docker config { image clickhouse/clickhouse-server:24.8 command sh args [-c, /app/run_query.sh] network_mode host volumes [/opt/ch-job:/app] } env { CH_HOST 127.0.0.1 CH_PORT 9000 CH_USER reporter CH_PASSWORD ${var.ch_password} SQL_FILE /app/agg_trend.sql } resources { cpu 200 memory 1024 memory_max 2048 } kill_timeout 60s } } } variable ch_password { type string default change-me }这里有一点先说明我用了clickhouse/clickhouse-server镜像来跑查询脚本因为官方server镜像里自带clickhouse-client二进制不需要再单独准备客户端镜像或额外安装依赖。如果你需要在同一节点上跑很多不同版本的客户端也可以用带特定版本号的server镜像做适配本质思路一样。2.2 这五个参数最容易写错提交前盯一遍第一组是periodic块。cron表达式本身不算容易出错真正容易错的是time_zone。Nomad默认按UTC计算周期如果不显式设置时区每天凌晨3点实际跑的是北京时间上午11点。对报表任务来说这个偏差通常要等到第二天看数据才会发现排查成本很高。我习惯在job文件里把time_zone直接写成Asia/Shanghai。另外一定要开prohibit_overlap true避免上一个实例还没结束、下一个实例又启动了同一套查询造成ClickHouse并发压力叠加和结果数据互相覆盖。第二组是restart和reschedule。这两个概念容易混。restart指的是同一个分配里任务容器被重启比如脚本因为网络抖动退出容器会在同一台机器上重新拉起reschedule指的是整个分配失败后调度器把任务安排到其他节点重新跑一遍。批处理任务通常把restart的mode设成fail防止没完没了地在原地重启reschedule次数设成3到5次给瞬时故障一个缓冲。第三组是CPU和内存的数值。Nomad里cpu单位是MHzmemory单位是MB。cpu 200意味着限制容器最多使用200MHz不是0.2核。这类SQL任务其实很吃内存几千万行的聚合在排序和group by阶段会临时占用大量内存所以内存上要给得比日常预估更宽裕。memory和memory_max分开设置时集群需要开启内存超卖特性才会按最大值兜底否则调度只看memory。如果集群没开oversubscription更稳妥的写法是memory和memory_max设成同一个值。第四组是kill_timeout。默认值是5秒对普通服务足够但对ClickHouse这种正在跑聚合查询的进程5秒内往往来不及完成当前查询并安全退出容易被直接SIGKILL。我把kill_timeout调到60秒给脚本留出清理时间。后面章节会专门展开讲这个参数和“任务停不下来”的关系。第五组是network_mode host。ClickHouse客户端连接通常走TCP 9000端口用host模式可以避免容器网络带来的DNS和服务发现负担。不过要注意这要求Nomad client节点本身能直连ClickHouse节点如果你的ClickHouse在专有网络里且客户端节点不通就需要换成overlay网络或者把连接地址改成负载均衡地址。2.3 密码怎么传别把账号密码写进job文件示例里的CH_PASSWORD通过${var.ch_password}引用变量而不是硬编码。提交时用-var传进去nomad job run -var ch_password实际密码 clickhouse-job.hcl推荐把密码、连接地址这类环境差异收敛到变量或本地的env文件里job文件本身可以提交到Git做版本管理。密码这种东西一旦进job文件就会出现在分配信息和Web UI里能少暴露一处就少暴露一处。如果团队已经有Vault直接用Nomad的Vault集成注入密码会更干净这里不展开。2.4 文件里没写但你要心里有数的调度行为periodic job第一次提交时不会立刻执行。想让job马上跑一次做验证需要这样的命令nomad job run -periodic clickhouse-job.hcl-periodic标志会忽略cron调度时间立即生成一次运行。我在调试阶段通常先不加periodic块用普通batch job验证脚本和资源全部稳定之后再补上periodic块正式注册。这个流程能少看很多莫名其妙的日志。还有一个容易被忽略的点batch job完成后分配状态是complete不是stopped。这不代表任务出问题只是说明它正常结束了。查看状态时看到complete不要慌确认退出码是0、日志里SQL正常执行完就可以了。3. 部署实操从命令行到定时自动跑3.1 提交前先手工跑一遍SQL不管job文件写得多漂亮第一件事永远是先在Nomad client节点上手工执行一遍SQL。我见过太多人直接提交job发现失败后又在job和SQL之间来回猜浪费大量时间。手工跑的时候注意三点确认SQL语义正确、确认数据源账号有权限、确认客户端到服务端网络通。以我的场景为例先在目标节点上用clickhouse-client直接跑一次聚合语句clickhouse-client --host 10.0.0.12 --port 9000 \ --user reporter --password $CH_PASSWORD \ --query SELECT date, city, count(), sum(amount) FROM event_raw WHERE date today() GROUP BY date, city \ --format Pretty跑通之后再把这条SQL放到/opt/ch-job/agg_trend.sql文件里脚本会调用它。这样做的好处是后续job失败时可以很确定地把SQL从嫌疑名单里划掉直接排查容器、资源和网络层。3.2 提交、看状态、查日志的标准操作手工SQL确认没问题就可以准备提交job。先在工作目录准备好HCL文件然后执行nomad job run clickhouse-job.hcl提交完先看总览nomad job status clickhouse-job这个命令会显示job的当前状态、还有没有在跑的分配、以及总运行次数。batch job跑完后再看一次就能看到Complete状态。如果分配一直处于running继续往下查nomad alloc status alloc-id分配状态里能看到实际跑在哪个节点、镜像有没有拉取成功、资源使用峰值、标准的exit code等信息。最后是日志这个是最常用的nomad logs -f alloc-id-f是follow模式看任务实时输出。clickhouse-client执行查询时的进度和错误信息都会打到stdout/stderr日志位置不对时先从分配状态里的日志路径确认一下。如果发现脚本有问题需要现场进容器看可以用alloc execnomad alloc exec alloc-id sh我在实际使用中发现alloc exec在某些配置下不可用这时可以直接到client节点上用docker命令替代docker ps | grep clickhouse docker exec -it container-id sh进去后第一件事是看挂载目录和进程是否正常ls -l /app ps -ef echo $CH_HOST $CH_PORT这一步能同时确认volume挂载、环境变量和命令执行情况大多数“容器起了但没干活”的问题在这一步就能定位。3.3 周期任务与时间问题验证完成、脚本稳定后把periodic块加回job文件正常重新提交nomad job run clickhouse-job.hcl这之后job会按cron表达式生成调度。可以用status看下一次运行时间nomad job status clickhouse-job输出里会有一行类似Next Periodic Run的信息确认它记录的时区和时间是否符合预期。这里再额外提醒一次如果看到的时间跟你本地时间差8小时去job文件里检查time_zone字段这是批量任务中最隐蔽的问题之一。还有一个实践经验如果任务在计划时间没跑优先检查Nomad client节点的系统时钟而不是怀疑Nomad本身。集群里任何节点时间偏差超过几十秒都会影响periodic job的触发严重时还会干扰心跳、让调度器误判节点失联。给所有节点统一配置好NTP同步是跑这类任务前就该完成的基础工作。3.4 资源限制与并发调整我最初给这个job分的是512MB内存跑了三天之后在alloc status里看到内存峰值接近1.4GB部分实例被OOMKilled。后来把内存调整到示例中的memory 1024, memory_max 2048再也没有出现过被杀的情况。内存的调整依据不是拍脑袋。观察一段时间之后用以下命令看分配资源nomad alloc status -verbose alloc-id | grep -A5 Resources查看Memory Usage的峰值和驻留值把memory设置在峰值以上留出20%到30%余量。CPU方面同理ClickHouse查询默认会按宿主机的CPU核数推导并发线程数如果你的容器CPU限制比较小查询线程太多反而会让CPU忙不过来、查询变慢。我习惯在SQL文件头部加一句SET max_threads 2;这样即使宿主机是32核ClickHouse在这个任务里也只开两个线程跑出来的时间反而更稳定。线程数太多除了抢CPU还会放大内存峰值两个问题一并解决。4. 踩坑实录任务卡住、停不了、OOM这些事4.1 任务一直不结束先在Nomad层面查三处症状是日志里SQL已经执行完但分配状态一直是running几十分钟也不变成complete。遇到这种情况我的排查顺序固定在三个点。第一看容器里是否还有残留进程。SQL执行完后如果脚本里启动过其他后台命令、或者子进程持有shell的stdout没有退出Docker容器就不会正常退出。用alloc exec或docker exec进去执行ps -ef确认是否只剩sh本身在等待。排查到是后台进程导致的话在脚本里去掉nohup ... 这类写法或者用wait明确等待子进程结束。第二看容器有没有被Docker挂住。在client节点上执行docker ps找到对应容器后看状态和创建时间。有时镜像用的entrypoint本身就包含长驻进程配合command和args反而把entrypoint顶掉了导致预期之外的进程存在。我的做法很简单统一用sh -c作为入口脚本内部只跑同步命令不做任何daemon化。第三看任务是否在等一个永远不来的信号。这种情况多出现在kill_timeout被调得很大、而任务本身又卡在某个不可中断的系统调用上。此时分配状态会显示为stopping日志停在最后一行。处理办法是缩小脚本里单个查询的执行时长或者对clickhouse-client设置查询级别的超时参数避免无响应连接把整个容器拖死。4.2 “can not stop with a savepoint job”到底卡在哪里最近在运维群里看到不少人问clickhouse任务在停止时卡住尤其是带状态保存的流任务会一直卡在保存状态那一步。虽然这跟纯批处理里的“停不下来”不完全是一回事但本质都是停止流程不优雅。对Nomad批任务来说nomad job stop发出后调度器会向容器发送SIGTERM等待kill_timeout时间后如果还没退出再强杀SIGKILL。如果脚本没有处理SIGTERM、或者正在执行的ClickHouse查询不响应中断就会出现“等很久才退”甚至看似卡死的状态。我的解决方式是在脚本里主动捕获退出信号让清理动作可控#!/usr/bin/env bash set -euo pipefail cleanup() { echo [cleanup] clickhouse job stopping, releasing temp state # 这里可以写通知状态中心、清理临时锁文件、关闭已建立的连接 } trap cleanup EXIT clickhouse-client --host $CH_HOST --port $CH_PORT \ --user $CH_USER --password $CH_PASSWORD \ --multiquery $SQL_FILE echo [done] clickhouse aggregation finished把kill_timeout设置成60秒后nomad job stop发出的SIGTERM会让脚本走完dump和清理逻辑再退出实际观察下来从触发停止到分配进入complete都控制在几秒内不会再出现“停不下来”的情况。如果这个任务本身是一个流式计算任务使用savepoint做状态保存那么正确的停止姿势不是直接对容器kill而是先调用框架自身的stop接口让它把状态落到检查点目录再让容器正常退出。这个操作用在流式场景下批处理job不需要这么复杂但理解了信号和超时的关系排查问题会顺很多。4.3 ClickHouse连接偶发失败先分清是网络还是连接数现象任务不是每次都失败但每周总有几次报Code: 210. DB::NetException: Connection refused。一开始我以为是SQL问题后来看日志发现失败时间点集中在多个任务同时启动的窗口基本断定是连接数或瞬时负载问题。排查路径分成两条。第一条是网络层在client节点上用telnet clickhouse-ip 9000验证端口通不通如果通继续看ClickHouse服务端的max_connections参数。默认4096这个值看着够用但当所有定时任务集中在同一时间触发时并发连接数会快速上涨一旦达到上限新连接直接被拒。第二条是增加重试。批任务偶发失败通常不值得上复杂的重试框架shell写个循环就够for i in 1 2 3; do if clickhouse-client --host $CH_HOST --port $CH_PORT \ --user $CH_USER --password $CH_PASSWORD \ --multiquery $SQL_FILE; then break fi echo [retry] attempt $i failed, sleep 5s sleep 5 done重试有一个前提SQL本身是幂等的重复执行不会产生重复数据。我的聚合语句是“删当天分区后重新写入”天然幂等所以可以放心重试。如果你的任务不是这种写法要么先确保结果表写入逻辑可覆盖要么把重试改成失败后告警避免数据翻倍。4.4 OOMKilled与大查询的资源边界ClickHouse聚合查询的内存峰值往往比SQL本身的逻辑更难预估。排序、group by、临时结果集都会占用内存特别是几千万行的明细表做高基数group by内存峰值可能呈非线性上涨。被OOMKilled的表现很明确alloc状态里显示exit code为137或者日志里直接出现内存杀手的记录。不要觉得“client server镜像只有几百MB分1GB内存肯定够”实际上运行时的内存大头在查询执行本身而不是容器镜像。我调优顺序是这样的先给一个偏保守的值跑几天通过alloc status观察峰值然后定期把历史资源使用拉出来用峰值而不是平均值作为分配基准最后在ClickHouse查询里限制max_memory_usage或max_threads从源头压住峰值。注意max_memory_usage默认单位是字节设置太大等于没设设置太小又会频繁报内存超限需要结合聚合数据量做一次实测。5. 加上监控和告警这job才算真正落地5.1 状态监控不能等第二天才发现没跑定时任务最怕的是沉默失败——昨天没跑第二天看板数据缺失才知道。job本身能稳定运行还不够我把状态感知也一并接上了。第一层是Nomad自带的信息通过nomad job status能看到最近运行结果。接到告警平台的做法是把这个命令的输出汇总到脚本周期性检查是否存在failed状态有就发一条告警。第二层是任务内部的成功标记我在脚本最后一步把执行完成的标记写入一个状态文件或发送到内部消息通道下游系统靠这个标记判断当天聚合是否就绪。还有一个细节值得提batch job的日志不会永久保留分配GC之后日志就没了。如果任务失败在第二天才被发现想回看前一天的日志都不知道去哪找。建议把关键输出主动写到挂载的共享目录或者至少确保所有日志在job内转发到统一日志采集系统。5.2 用变量和模板一个job跑多个环境线上环境可能不止一套。脚本和SQL可以共用但连接地址、账号密码、结果表名可能每套环境都不同。我习惯把HCL里的环境差异全部抽成variablevariable ch_host { type string default 127.0.0.1 } variable result_table { type string default agg_trend_daily }提交时针对不同环境传不同变量nomad job run -var ch_host10.0.0.12 -var result_tableagg_trend_daily clickhouse-job.hcl这样生产job和预发job共用同一个job文件模板差异全在参数里不会出现某个环境改了一行配置其他环境忘记同步的情况。5.3 从批处理走向流式任务注意边界如果后续需求从“每天跑一次”变成“每半小时增量跑一次”甚至变成持续消费的流任务建议重新评估任务形态而不是简单把periodic的cron改成*/30 * * * *。批任务每次启动都是干净环境没有状态残留天然适合重复执行。流任务则需要持久化的状态、检查点和恢复机制任务停止的方式也会从“退出即完成”变成“保存状态后退出”。这也是为什么会有“stop with savepoint”这类讨论出现——不是调度器做得不好而是任务类型变了之后操作方式必须跟着变。把这两类任务放在同一个调度平台上是合理的但job文件的结构、资源模型、停止策略都需要分开设计。我个人在实际操作中最深的体会是clickhouse-job这类批任务真正难的从来不是写SQL也不是把job文件提交上去而是想清楚任务退出前状态怎么处理、资源怎么给、失败之后怎么退。上面这些坑基本覆盖了我这次全流程遇到的大部分问题。如果你也是第一次在Nomad上跑类似任务建议先把SQL手工跑通再提交job最后上周期和告警一步一步来能省掉一半以上的排障时间。
阅读完成 · 觉得有帮助?
咨询建站