半夜收到告警,说某个 Flink 集群的可用 Slot 数从 16 掉到 8,Web UI 上少了一台从节点。登上去一查,进程还在,日志停在一行Starting TaskExecutor,再往下就没有了;另一台更干脆,jps里连TaskManagerRunner都找不到,只有flink-daemon.sh留下的一个 pid 文件。这种场景跟作业逻辑、SQL、状态后端统统没关系,问题全部落在 TaskManager 这个从节点的启动链路上:脚本怎么把它拉起来、JVM 吃多少内存、起来之后怎么向 JobManager 里的 ResourceManager 报到、报到失败了谁来兜底。
这篇就把 standalone 模式下 TaskManager 从零到可用的全过程拆开讲。读者对象是已经能跑起单机 Flink、但一遇到"从节点起不来""Slot 显示不全""TaskManager 刚注册就 lost"就不知道从哪下手的人。我会从 shell 脚本层一路讲到 RPC 注册层,中间把内存账本算清楚,最后给出一套我自己常用的分层排查顺序。文中涉及版本差异的地方会标注清楚,因为 Flink 在 1.11、1.14、1.19 这几个节点上改过配置文件名和内存模型,照抄网上的老文章很容易踩空。
1. 一次从节点拉起到底发生了什么:脚本层三段接力
很多人以为在从节点上执行bin/taskmanager.sh start就完事了,其实这条命令背后是三段脚本接力,每一段都可能成为失败点。搞清楚这三段各自干什么,排查时才能知道该去看哪个文件、哪条日志。
1.1 config.sh 先把配置文件翻译成环境变量
所有bin/下的启动脚本第一件事都是source config.sh。它做的事很朴素:定位FLINK_CONF_DIR,把配置目录里的flink-conf.yaml(1.19 起主文件名换成了config.yaml,但为了兼容仍会读旧名)逐行解析成key: value,然后把 key 转成大写、把.和-换成_、前面统一加FLINK_,导出为环境变量。比如taskmanager.memory.process.size会被导出成FLINK_TASKMANAGER_MEMORY_PROCESS_SIZE。
这个设计带来两个直接后果,都挺实用。第一,你可以在命令行前缀里直接覆盖某一项,比如FLINK_TASKMANAGER_MEMORY_PROCESS_SIZE=8g bin/taskmanager.sh start,做临时验证时不用改配置文件。第二,脚本层的解析是"逐行文本解析",不是真正的 YAML 解析器。这意味着配置文件里出现嵌套结构、多行数组、或者值里带冒号(典型是 IPv6 地址、JDBC URL),老版本脚本用cut -d':' -f2取值时会被截断。我在一台双栈机器上就遇到过taskmanager.host填了 IPv6 地址,结果被截成半截,进程起来后绑定到一个不存在的地址,日志里报Cannot assign requested address。
注意:配置文件里带空格的值(最典型的是
env.java.opts.all)建议用引号包起来。不加引号时大部分情况也能跑,但一旦脚本版本变化、解析规则调整,就容易出现 JVM 参数被拆散、传了半截给 java 命令的情况,而这类问题在日志里毫无痕迹,只有看/proc/<pid>/cmdline才能发现。
1.2 start-cluster.sh 与 taskmanager.sh 的分工
start-cluster.sh只在集群统一启动时用。它先检查配置目录是否可读、FLINK_HOME是否正确,然后依次调用jobmanager.sh start和taskmanager.sh start。关键在于:它不是自己本地起 TaskManager,而是读conf/workers文件(1.11 之前叫slaves),拿到主机列表,然后对每一台执行ssh <host> "<FLINK_HOME>/bin/taskmanager.sh start"。
这就解释了一个高频问题:workers文件里加了机器,start-cluster.sh却报Connection refused或者卡住不动。原因几乎总是从节点没配免密、或者FLINK_HOME在从节点上的路径和主节点不一致、又或者登录 shell 加载的环境变量里JAVA_HOME是空的。用非交互式 ssh 执行命令时不会加载.bashrc里靠前的部分(有的发行版.bashrc开头就有[ -z "$PS1" ] && return),JAVA_HOME自然拿不到。
实际生产里我更推荐跳过start-cluster.sh,直接在每台从节点上单独执行bin/taskmanager.sh start。standalone 模式的 ResourceManager 是接受动态注册的,新节点起来就自动加入,不需要重启 JobManager,也不需要动已有节点。扩缩容时这个特性非常好用:加机器就是装好包、改好taskmanager.host、执行一条命令;减机器就是taskmanager.sh stop,等 RM 判定超时后 Slot 自动回收。
taskmanager.sh本身只做参数分发,支持start、start-foreground、stop、stop-all。调试阶段我强烈建议用start-foreground,所有日志直接打到终端,进程一退出你立刻能看到退出码和最后的堆栈,比翻日志文件快得多。
1.3 flink-daemon.sh 拼出那条能复现的 java 命令
真正干活的是flink-daemon.sh。它根据DAEMON参数决定主类:taskmanager对应org.apache.flink.runtime.taskexecutor.TaskManagerRunner,jobmanager对应org.apache.flink.runtime.entrypoint.StandaloneSessionClusterEntrypoint。然后它组装FLINK_TM_CLASSPATH(包含lib/下的所有 jar、plugins/目录、以及HADOOP_CLASSPATH),拼上 JVM 参数,最后exec java ...,并把标准输出重定向到log/下的.out文件。
这里有个细节值得记住:脚本启动后会生成两个日志文件,一个是flink-<用户名>-taskmanager-<主机名>-<端口>.log(log4j 写的业务日志),另一个是同前缀的.out(进程的 stdout/stderr)。启动早期就失败的情况,错误信息往往只在.out里。比如 JVM 参数写错了(Unrecognized VM option)、主类找不到(Could not find or load main class)、配置文件语法错误导致TaskManagerRunner在 main 方法第一行就抛异常。这些在.log里一个字都不会有,因为 log4j 还没初始化完。我排查过的"起不来"案例里,至少有四成答案在.out的最后十行。
想拿到那条真实的 java 命令,最省事的办法是在从节点上执行ps -ef | grep TaskManagerRunner,把整行复制出来。想验证某个参数是否生效,直接改这条命令手工跑一遍,比改配置文件重启快得多。也可以看/proc/<pid>/cmdline,用tr '\0' '\n' < /proc/<pid>/cmdline展开成一行一个参数,可读性好很多。
2. 那条 java 命令里的每个参数都不是摆设
把ps输出摊开看,一条典型的 TaskManager 启动命令里包含三四类参数:JVM 内存参数、-D系统属性、log4j 配置、classpath、主类、以及--configDir。每一类都有自己的来源和优先级,弄混了就会出现"我明明改了配置为什么不生效"。
2.1 -D 系统属性与 log4j 配置的落地位置
-Dlog4j.configurationFile=...指向的是conf/log4j2.properties(老版本是log4j.properties)。这个文件决定日志级别、滚动策略、输出路径。默认配置里 root logger 是 INFO,写入log/目录并按大小滚动。生产上如果为了排查问题把某个包调成 DEBUG,一定要记得在验证完后改回来——TaskExecutor 在处理大规模并发任务时 DEBUG 日志量非常恐怖,一台机器一天能写出几十 GB,把数据盘写满之后连锁反应是所有 TaskManager 一起挂。
-D里还有一批是 Flink 自己加的,比如-Djava.io.tmpdir、-Dlog.file、-Dlog4j.configuration。这些是脚本层根据配置算出来的,不建议手工覆盖。
真正需要你关心的是env.java.opts.all、env.java.opts.taskmanager这两项。它们在脚本里被拼到JVM_ARGS中,会原样出现在 java 命令里。这里有个必须知道的顺序问题:Flink 会先写入自己根据内存模型算出来的-Xmx、-Xms、-XX:MaxDirectMemorySize,然后再追加你在env.java.opts.taskmanager里写的参数。JVM 对重复参数取最后一个,所以如果你在env.java.opts.taskmanager里手写了-Xmx4g,它会覆盖掉内存模型算出来的堆大小,导致实际堆和 Flink 内部认为的堆不一致,Managed Memory 的预留就落空了,最后的表现是容器里进程内存超限被杀,而日志上完全看不出哪里配错了。
我在一个集群上就栽过这个跟头:为了临时调大堆,在
env.java.opts.taskmanager里加了-Xmx8g,taskmanager.memory.process.size还是 4g。结果是 JVM 堆 8g 加上堆外和 Metaspace,单进程实际占了 11g 左右,超过了容器限制,被内核反复杀掉,jps看到的进程时有时无。后来把-Xmx删掉、只改process.size,问题立刻消失。
2.2 classpath 拼接顺序与用户 jar 的边界
FLINK_TM_CLASSPATH的组成大致是lib/*加plugins/*,再加上HADOOP_CLASSPATH。classpath 里的顺序会影响类加载,而 Flink 又用了 child-first 的类加载器机制:用户代码的类加载器优先加载用户 jar 里的类,但有一批包的类被限定为 parent-first(配置项classloader.parent-first-patterns.default和.additional),包括java.、scala.、org.apache.flink.等前缀。
实践中最大的坑是把各种连接器 jar 直接丢进lib/。同一份flink-connector-kafka的两个版本同时出现在lib/里,类加载时取哪个完全看文件名的字典序和目录扫描顺序,可能今天正常明天就抛NoSuchMethodError。更隐蔽的是某些连接器依赖的kafka-clients版本与 Flink 自带的不一致,编译期能过,运行期报ClassNotFoundException,堆栈还指向框架内部,让人误以为是 Flink 的 bug。
我的做法是:lib/里只保留官方发行版自带的 jar,业务连接器通过-C参数或pipeline.jars在提交作业时带上。deployment 场景下用usrlib/目录,各作业的依赖互相隔离。要做到这一点,从节点上的lib/就必须保持干净——一旦某台机器的lib/被手工塞过东西,就会出现"同样的作业在 A 机器上跑得好、在 B 机器上跑不起来"的灵异现象。
2.3 环境变量覆盖配置的优先级链
配置来源不止一处,实际生效顺序大致是:Java 进程中--configDir读取的配置文件为基线,命令行-D覆盖它,某些脚本层判断又依赖环境变量。这里容易产生误判的是:有些项只被 shell 脚本读取(比如FLINK_LOG_DIR、FLINK_PID_DIR),写进flink-conf.yaml根本不生效;有些项既被脚本读又被 Java 进程读,两边解析规则还可能不一致。
下面这张表是我整理的常见配置项归属,配置改错位置时对照着看:
| 配置项 | 谁在读 | 写错位置的典型症状 |
|---|---|---|
env.java.opts.all | shell 脚本拼 JVM 参数 | 写进 Java 代码的Configuration里,毫无效果 |
taskmanager.memory.process.size | Java 进程内存模型 | 写成taskmanager.heap.size(1.10 前的老项),新版本被忽略 |
taskmanager.host | Java 进程 + 注册信息 | 只改了 hosts 文件没改这项,多网卡机器注册了错误地址 |
io.tmp.dirs | Java 进程 | 不配则用/tmp,机器重启或定时清理后运行中的作业报错 |
FLINK_PID_DIR | 仅 shell 脚本 | 写进 yaml 里,pid 文件还是落在默认目录 |
metrics.reporter.prom.port | Java 进程 | 端口冲突时 TaskManager 能起来但指标采不到 |
3. TaskManager 的内存账本:进程内存怎么被一层层瓜分
内存是 TaskManager 启动失败最高频的原因,也是误解最多的部分。很多人还停留在"设个-Xmx就行了"的阶段,而 Flink 1.10 之后的内存模型已经把进程内存切成了六七块,每一块都有自己的默认值和计算规则。
3.1 从 process.size 反推每一项的真实数值
先看整体结构。总进程内存被拆成三大部分:Total Flink Memory、JVM Metaspace、JVM Overhead。Total Flink Memory 再往下拆成 Framework Heap、Framework Off-heap、Task Heap、Task Off-heap、Managed Memory、Network Memory。
以最常用的配置方式为例,只设taskmanager.memory.process.size: 4096m,其他全部用默认值,逐项推一遍:
第一步,算 JVM Overhead。规则是process.size × jvm-overhead.fraction,再夹在 min 和 max 之间。默认 fraction 0.1、min 192m、max 1g。4096 × 0.1 = 409.6m,落在区间内,取 409.6m(实际会按 MB 向下对齐,约 409m)。
第二步,Total Flink Memory = 4096 − 409.6 − 256(默认 Metaspace)= 3430.4m。
第三步,Network Memory。按 Total Flink Memory 的network.fraction(默认 0.1)算,3430.4 × 0.1 = 343.04m,夹在 min 64m、max 1g 之间,取 343m。
第四步,Managed Memory。按managed.fraction(默认 0.4)算,3430.4 × 0.4 = 1372.16m。
第五步,剩下的给 Task Heap。3430.4 − 343 − 1372 − 128(Framework Heap)− 128(Framework Off-heap)− 0(Task Off-heap)= 约 1459m。
也就是说,你以为配了 4G,真正能给用户算子用的堆只有1.4G 左右,剩下的都被框架、网络缓冲、Managed Memory 占了。第一次算这个账的人基本都会愣一下。如果这个 TaskManager 上还要开 4 个 Slot,那单个 Slot 可用堆只有 365m 上下,稍微带点缓存或者窗口聚合就 OOM 了。
不同配置组合下的行为差异,我整理成一张表:
| 你配置了什么 | 未配置项的推导方式 | 风险点 |
|---|---|---|
只配process.size | 其余按 fraction 和默认值推导 | 最推荐,进程总内存可控 |
只配flink.size | Metaspace、Overhead 按各自默认推导后加上去 | 进程总内存 = flink + 256m + overhead,容易超容器限制 |
只配task.heap.size+managed.size | 其余按默认补齐,反推 flink 和 process | 推导链条长,改一个数会连锁改动好几项 |
process.size和flink.size同时配 | 直接抛IllegalConfigurationException | 启动即失败,日志里能直接看到 |
| 一个都不配 | 退回 JVM 默认堆,日志里有明显告警 | 生产上绝对不要这么干 |
3.2 只给 flink.size 或只给 heap 时会发生什么
只配taskmanager.memory.flink.size是很多老集群的写法。这种情况下 Flink 不再反推,而是把 flink.size 当成 Total Flink Memory,然后在它内部按 fraction 切分,最后把 Metaspace 和 Overhead 加上去,得到"预期的进程内存"。问题在于,预期值不等于限额值。如果 TaskManager 跑在容器里,容器限额是 8G,你配flink.size: 7g,加上 256m Metaspace 和约 700m Overhead,实际占用会到 7.9G 以上,再加上线程栈、JVM 自身的 native 开销、glibc 的 arena 碎片,轻轻松松突破 8G,然后被 OOM Killer 干掉。
反过来,只配taskmanager.memory.task.heap.size更麻烦,因为 Managed Memory 的 fraction 是按 Total Flink Memory 算的,而 Total Flink Memory 又依赖 task heap,形成了一层隐含反推。改一个数会影响一串,我不到万不得已不用这种方式。
3.3 slot 数量与单 slot 可用堆的换算关系
taskmanager.numberOfTaskSlots决定这台机器能同时跑几个 Task。默认值 1,生产上通常设成 CPU 核数(物理核,不数超线程)或者核数的一半。
Slot 分的是 Task Heap,公式很直白:单 Slot 堆 = Task Heap ÷ numberOfTaskSlots。Managed Memory 则是在所有 Slot 之间按权重共享的,不是平均切死——RocksDB 状态后端用多少拿多少,空闲的 Slot 不会占用。Network Memory 同样是该 TaskManager 上所有 Slot 共享的缓冲池。
这里有个容易忽略的换算:如果你把 Slot 数从 4 调到 8,Task Heap 总量没变,单 Slot 堆直接腰斩,原本跑得好好的作业立刻开始频繁 Full GC 甚至 OOM。所以调 Slot 数必须同步复核内存配置,两个参数是绑在一起看的。我一般会在扩容 Slot 之前先跑一遍:单 Slot 堆 = (process.size − metaspace − overhead) × (1 − 0.1 − 0.4 − 框架开销占比) ÷ slots,估算出来低于 800m 就得考虑加内存或者减 Slot。
还有一点:Managed Memory 的分配存在"最小可分配"限制。默认
taskmanager.memory.managed.size不设时按 fraction 算,但如果算出来的值小于managed.min(部分版本有类似约束),或者单 Slot 拿到的大小不足以启动 RocksDB 的 Block Cache,启动时会直接失败并提示需要更多 Managed Memory。这类报错信息写得还算清楚,照着提示调大process.size通常就能过。
4. 注册与心跳:TaskManager 怎么被 ResourceManager 认领
进程起来了、端口也监听了,不代表这个从节点就能用。真正让它"成为集群一员"的动作是向 ResourceManager 注册。standalone 模式下 ResourceManager 就跑在 JobManager 进程里,所以从节点的注册目标就是 JobManager 的 RPC 地址。
4.1 TaskManagerRunner 起来之后做的第一件事
TaskManagerRunner.main拿到--configDir之后,按顺序做这几件事:初始化 RPC 服务、初始化高可用服务(standalone 模式下是StandaloneHaServices,ZK 模式下是ZooKeeperHaServices)、创建 TaskExecutor 实例、最后调用start()。start()内部又分两步:先启动本地的各种服务组件(内存管理器、IO 管理器、Shuffle 环境、广播变量管理器、Slot 表),再去连接 ResourceManager。
本地服务启动阶段的失败通常跟资源有关:临时目录不可写、内存申请失败、端口被占。这个阶段的日志里能看到Starting TaskExecutor、Starting task manager services之类的行,如果日志到此为止,就不要往网络方向查了,先看本地环境。
连接 ResourceManager 是通过ResourceManagerLeaderRetriever完成的。它一开始并不知道 RM 在哪,而是监听 leader 变化——standalone 模式下这个"变化"就是读配置里 JobManager 的地址;ZK 模式下是监听 ZK 节点。拿到地址后才发起注册。
4.2 注册请求里带了什么、RM 回了什么
注册请求(TaskExecutorRegistration)里打包了几类信息,每一项都可能在后续出问题:
ResourceID:TaskManager 的唯一标识,通常是启动时生成的一个随机 ID,重启后会变。所以重启过的 TaskManager 在 Web UI 上会显示成一条新记录,旧记录要等超时才被清理。TaskExecutorConnectionInfo:包含对外可达的地址、data port、JMX 端口。这个地址是 ResourceManager 后续回连时用的,必须是从 RM 所在机器能路由到的地址。HardwareDescription:CPU 核数、内存大小、Managed Memory 大小。RM 用它做资源调度决策。ResourceProfile:这台 TaskManager 能提供的资源画像。- Slot 配置:Slot 数量和每 Slot 的资源。
RM 收到后,走SlotManager的注册流程,把 TaskManager 纳入资源池,回一个RegistrationResponse,里面带下次心跳间隔。TM 侧收到成功响应后打印类似Successfully registered at ResourceManager的日志,然后把本地 Slot 的SlotReport汇报上去,Web UI 上的 Free Slots 数字这才变成你期望的值。
如果注册被拒绝,日志里会有明确的拒绝原因。standalone 模式下最常见的是"这个 TaskManager 的 Slot 配置超出限制"或者"当前 ResourceManager 处于正在恢复状态,暂时拒收"。后者通常在 JM 重启过程中出现,等 JM 恢复完重试一次就好了。
4.3 心跳超时与重连的判定逻辑
注册成功只是开始。接下来 ResourceManager 会定期向所有已注册的 TaskManager 发心跳请求,TM 收到后回一个心跳响应。RM 侧维护一个HeartbeatManager,记录每个 TM 最后一次响应的时间。超过taskmanager.heartbeat.timeout(默认 50 秒)没收到响应,就判定该 TM 失联,释放它上面的所有 Slot,触发相关作业的 failover。
反方向还有一层超时:taskmanager.registration.timeout(默认 5 分钟)。如果在启动后 5 分钟内没能成功注册到 RM,TM 会自己退出,日志里留下TaskExecutor ... was not able to register at ResourceManager in time。这个超时就是"进程在、端口在、就是不干活"的典型原因。
把这两个超时和对应症状对照清楚,能省下大量排查时间:
| 日志关键字 | 含义 | 常见根因 |
|---|---|---|
was not able to register at ResourceManager in time | 注册阶段超时,TM 主动退出 | RM 地址不通、防火墙、JM 没起来、配置里 JM 地址写错 |
Registration at ResourceManager was rejected | 注册被明确拒绝 | Slot 配置超限、RM 处于恢复态、HA 下连到了旧 RM |
Lost connection to ResourceManager后跟重试 | 注册成功后连接断开 | JM 重启、网络抖动、心跳超时 |
TaskExecutor ... heartbeat timed out | RM 侧判定 TM 失联 | 从节点负载过高、GC 长时间停顿、网络单向不通 |
有一条经验值很值得记住:如果 TM 能被 RM 分配 Slot、作业也能提交上去,但过几分钟就整批 failover,八成是心跳超时,而心跳超时的根因又八成是从节点的长时间 JVM 停顿——比如 Full GC 卡了 40 秒,或者在等磁盘 IO。这种情况查日志找不到报错,得去看 GC 日志和节点的 iowait。
5. 从节点起不来时的分层排查链路
排查 TaskManager 问题,最忌讳一上来就翻日志全文。我自己的习惯是按"进程层 → 端口层 → 注册层 → 运行层"四层筛,每层都有明确的判据,绝大多数问题在前两层就能定位。
5.1 进程在不在:先分清"没起来"和"起来了又没了"
第一个动作永远是jps -l或者ps -ef | grep TaskManagerRunner。这里要区分三种情况:
第一种,进程根本不存在,pid 文件却留着。这说明进程曾经起来过然后退出了——flink-daemon.sh的 pid 文件不会自动清理。去看.out文件的末尾,通常能找到退出原因。
第二种,进程不存在,.out也是空的。这说明连 JVM 都没启动,问题在脚本层。检查JAVA_HOME是否设置、FLINK_HOME/lib里 jar 是否完整、执行用户对log/和tmp/目录有没有写权限。
第三种,进程在,但注册不上。这时候去看.log里的注册相关关键字。
还有一个必查项:直接在前台跑一次bin/taskmanager.sh start-foreground。前台模式会把所有日志打到终端,进程退出时你能立刻看到退出码。退出码 1 一般是配置或类加载问题;退出码 137 是 SIGKILL(几乎可以断定被 OOM Killer 杀了);143 是 SIGTERM(被人或者脚本停了)。
5.2 端口、hostname 与网卡绑定的三个典型错法
网络配置是 standalone 模式最容易出问题的地方,因为涉及三个不同的地址概念,很容易混淆:
taskmanager.bind-host决定进程绑定在哪个网卡上。默认是空,表示绑定所有地址。
taskmanager.host决定注册给 ResourceManager 的地址,也就是 RM 回连时用的地址。默认也不设,此时 Flink 用InetAddress.getLocalHost()拿本机主机名,再解析成 IP。
taskmanager.rpc.port决定监听端口,默认 6122。
最典型的错法是:机器有多块网卡,/etc/hosts里主机名映射到了127.0.0.1。TaskManager 启动时用getLocalHost()拿到127.0.0.1,注册给 RM 的也是这个地址。RM 在另一台机器上,回连127.0.0.1就是连自己,永远连不上。表现是:TM 日志显示注册成功,但 Web UI 上这台 TM 一直处于异常状态,或者 Slot 分配后马上又释放。修法很简单,在配置里显式写上taskmanager.host为对内可达的 IP,别依赖主机名解析。
第二个错法是防火墙。TM 向 RM 注册是主动出站,通常没问题;但 RM 回连 TM 的 data port 是入站,data port 默认是 0,意味着每次启动随机分配一个端口。你不可能为随机端口逐个开墙。要么在配置里把taskmanager.data.port固定下来,要么在做网络策略时整段放行。
第三个错法是端口冲突。同一台机器上如果曾经异常退出过 TaskManager,残留进程还占着 6122,新进程启动时会报Address already in use。用ss -lntp | grep 6122查一下就能确认。这也是为什么我不建议在同一台机器上跑多个 TaskManager 实例——除非你把 rpc port 和 data port 都显式分开配好。
5.3 被内核杀掉的 TaskManager 长什么样
有一类问题日志里完全看不出痕迹:进程突然消失,.log和.out都是戛然而止,没有任何异常堆栈。这时候要怀疑是被操作系统杀掉的。
第一步看退出码,如果是 137,基本坐实。第二步查内核日志:dmesg -T | grep -i "out of memory"或者journalctl -k | grep -i oom。如果能看到类似Killed process <pid> (java) total-vm:... anon-rss:...的记录,那就是内存超限。
超限的原因通常是三类:taskmanager.memory.process.size设得比物理内存或容器限额还大;堆外内存不受-Xmx控制,网络缓冲、Direct Buffer、JNI 内存加起来超出预期;或者是同一台机器上还跑了别的服务,把内存吃掉了。
判断方法很直接:TaskManager 稳定运行后,用ps -o rss= -p <pid>看常驻内存,用cat /proc/<pid>/status | grep VmRSS交叉验证,如果 RSS 已经接近你设置的上限,那任何一点波动都会触发 OOM。留 20% 到 30% 的余量是比较稳妥的做法。
还有一个隐蔽的坑:容器里的 JVM 读到的"可用内存"是宿主机的,不是 cgroup 限额。如果
taskmanager.memory.process.size不配,让 Flink 按比例推导,推导出来的值可能远超容器限额。所以容器化部署时,process.size必须显式配置,和容器 limit 保持 70% 到 80% 的比例关系。
6. 生产环境里我固定会调的几处参数
前面讲的是"怎么启动、为什么起不来",这一章说说怎么把它调到一个长期稳定的状态。下面这些是我在新集群上架时几乎必改的项,都是踩过坑之后加进清单的。
6.1 slot 与核数的配比
taskmanager.numberOfTaskSlots设多少,取决于作业的负载类型。纯 CPU 密集型任务,设成物理核数即可;如果作业大量等待外部 IO(比如频繁读写外部存储),可以设到核数的 1.5 到 2 倍,让线程在等待时把 CPU 让出来。
但要注意 Slot 共享机制:同一个 Slot 里可以跑同一个作业的不同 Task(共享 Slot 资源),所以 Slot 数并不等于能跑的作业数。一个 8 Slot 的 TaskManager,如果每个作业的并行度是 2,理论上能塞 4 个作业的同类算子。真正限制并发的是 CPU 和内存,不是 Slot 数字本身。
我的经验配置是:物理核数 16 的机器,numberOfTaskSlots: 16,process.size: 40g左右(按节点 64G 内存算),单 Slot 堆大约 1.5G。如果作业带大状态、用 RocksDB,就把 Slot 数降到 8,单 Slot 堆提到 3G 以上,同时把managed.size显式配成固定值(比如 12g),避免 fraction 随总量漂移。
6.2 临时目录与本地磁盘分离
io.tmp.dirs默认是/tmp。这个默认值在小集群上没问题,在生产上是隐患:/tmp可能在系统盘,容量小;很多发行版还会用 systemd-tmpfiles 定期清理长时间未访问的文件,正在跑的作业可能突然报No such file or directory。
我的做法是把io.tmp.dirs指向独立的数据盘,多块盘用逗号分隔,Flink 会轮询写入,起到简单的条带化效果。三块盘的机器就写/data1/flink/tmp,/data2/flink/tmp,/data3/flink/tmp。
同一台机器上的 Slot 数越多、并发越高,这个目录的 IO 压力越大,Shuffle 数据、RocksDB 的临时文件都往这儿写。把数据盘和系统盘分开,能避免日志把盘写满导致整个 TaskManager 挂掉。
6.3 日志与 ulimit 这类容易被忽略的系统项
单进程能打开的文件句柄数,默认值通常只有 1024。一个 Slot 数 16、每个 Task 又开着多个网络连接的 TaskManager,句柄数轻松破千。超了之后报Too many open files,表现形式五花八门:可能是连接建立失败,可能是写入磁盘报错,也可能是心跳发不出去导致被判失联。ulimit -n建议设到 65536 以上,并且要写进 systemd unit 或/etc/security/limits.conf,只在当前 shell 里ulimit一下是不够的,因为启动脚本可能是通过 ssh 非交互式执行的。
日志方面,log4j2.properties里默认的滚动策略是单文件 100MB、保留 10 个。这个量级在高负载下一天就转完了,出问题时想往前翻三天的日志根本找不到。我的做法是调大保留数量,同时把org.apache.flink.runtime保持 INFO,只把需要排查的特定包临时调到 DEBUG。
再补充几个系统层面的固定动作,都是长期运行中总结出来的:
| 调整项 | 建议值 | 原因 |
|---|---|---|
vm.swappiness | 1 | 减少换页,避免 GC 期间被换出导致停顿放大 |
| 透明大页 | 关闭 | 避免单次分配延迟抖动,对 JVM 堆不友好 |
net.core.somaxconn | 4096 | 高并发注册和连接建立时避免排队丢弃 |
| 时钟同步 | 强制开启 | 心跳超时判定和日志时间对齐都依赖它 |
| 文件系统挂载 | noatime | 减少元数据写入,降低临时目录 IO 压力 |
最后说一个监控上的小习惯。TaskManager 启动成功后,我会在 Web UI 上确认三件事:TaskManager 列表里出现了这台机器、Free Slots 数量等于配置值、最后心跳时间在持续更新。三个都符合,才算真正"起来了"。只看进程在不在,很容易被"注册了但没汇报 Slot"这种半成功状态骗过去。
7. 几个反复出现的老问题,直接给结论
把上面这些内容压缩成几条可以直接抄的结论,遇到对应症状时照着做就行。
症状一:start-cluster.sh卡在某台机器上不动。九成是 ssh 免密没配好,或者从节点的JAVA_HOME在非交互式 shell 里为空。先在主节点执行ssh <host> 'echo $JAVA_HOME'验证,返回空就去补环境变量。
症状二:进程起来几十秒后自己退出了。先看.out末尾和退出码。137 查内核 OOM 日志,1 查配置解析和类加载,143 查是不是有别的管理脚本把它停了。同时确认process.size和flink.size没有同时配置。
症状三:进程稳定运行,Web UI 上却没有这台机器。检查taskmanager.host是否配置成对内可达地址,从 JobManager 所在机器telnet <TM_IP> <rpc_port>试通。能通但还注册不上,就看 RM 侧日志里有没有拒绝记录。
症状四:注册上了,Slot 显示 0。检查numberOfTaskSlots是否被配成 0,或者配置项写在了错误的配置文件里。也有可能是 RM 还在恢复状态,等一会儿刷新。
症状五:运行几分钟后整批 failover。优先查 GC 停顿和节点负载。用jstat -gcutil <pid> 1000观察有没有长停顿,用iostat -x 1看磁盘是否饱和。网络侧则确认心跳包没有被中间设备丢弃。
症状六:同一份包在不同机器上行为不一致。对比两台的lib/目录内容、flink-conf.yaml的 md5、以及 JDK 版本。lib/被手工塞过 jar 是最常见的原因,用ls -l lib/ | md5sum快速比对。
8. 调试顺序上的一点个人习惯
我处理这类问题的固定顺序是:先看进程在不在,再看.out最后二十行,再看.log里注册相关关键字,最后才去算内存账。这个顺序不是随便定的——它按"信息密度从高到低、排查成本从低到高"排列。进程状态一条命令就能拿到,.out往往直接给出答案,而算内存账要翻配置、做除法、对照默认值,最费时间。很多人反过来,一上来就开始逐项核内存参数,核了半天发现是端口被占了。
另一个习惯是:任何一次"我改了配置但它没生效"的怀疑,都用ps -ef | grep TaskManagerRunner拿到的真实命令行来验证,而不是凭印象。真实命令行里能同时看到 JVM 参数、classpath、主类、--configDir路径,比对着配置文件猜快得多。这条命令我基本上是肌肉记忆,登上一台出问题的机器第一件事就是敲它。