Docker 部署 Hadoop 集群
从单容器到 Compose 编排三节点集群
Docker 部署 Hadoop 集群
三月份学 Hadoop 的时候,我在部署笔记里用 VMware 起了三台 Ubuntu 虚拟机,装 JDK、配 SSH 免密、改五六个 XML,一台一台敲过来。
能跑,但每次重装环境都要半个下午。
这次换个思路:用 Docker 把同样的三节点集群装进容器。这篇笔记记录从单容器 docker run 到 Compose 编排的过程,外加环境变量注入的几个坑和六个典型故障。
全程命令可复制、状态可丢弃、坏了就 down 重来。
动手版本:Docker ⇄ Compose 互转、运维速查、环境变量解析器。
先用单容器撞一遍墙
Hadoop 到底要容器提供什么
动手之前值得先把账算清楚。
Hadoop 集群对运行环境有一串硬性要求,把它们列出来,才知道容器要补哪些课。
一是稳定的主机名。core-site.xml 里 fs.defaultFS 写着 hdfs://master:9000,workers 文件里列着 slave1、slave2,主机名一变全套配置作废。
二是节点间 SSH。Hadoop 的启停脚本(start-dfs.sh 这类)本质是用 SSH 向各节点远程发命令。
三是 JDK。JVM 是一切进程的载体。
四是网络互通。NameNode 的 9000 端口(RPC)和 DataNode 的回报通道必须双向可达。
五是持久化。NameNode 的元数据磁盘结构(fsimage 与 edits)掉了就丢整个文件系统的目录树。
有意思的是,这份清单里有一项在容器里天然免费:时间同步。
虚拟机时代每台机器要配 NTP,否则节点间时钟漂移会让 HDFS 误判心跳超时。而同一台宿主机上的所有容器共享内核时钟,时钟天然一致。
伪分布式的舒适区
Hadoop 官方文档的入门路径是伪分布式:一台机器上同时跑 NameNode、DataNode、ResourceManager、NodeManager。
放进容器就是一条命令:
docker run -d --name hadoop-standalone \ -h master \ -p 9870:9870 -p 8088:8088 \ my-hadoop:3.3.6 /usr/sbin/sshd -D-h 指定主机名(Hadoop 的 XML 配置里到处引用主机名,这步不能省),端口映射把 NameNode 的 Web UI(9870)和 YARN 的页面(8088)露出来。
进容器 start-dfs.sh,jps 一看,进程都在,浏览器里 HDFS 页面正常。
伪分布式的舒适区就是这样,半小时能跑通。
多节点才是真正的墙
但伪分布式终究不是集群:没有真实的节点间通信,没有 DataNode 掉线的场景,学不到 Hadoop 的骨架。
真要三个节点,最直觉的做法是把这条 docker run 复制三份,改名字改网络:
docker network create hadoop-netdocker run -d --name hadoop-master -h master --net hadoop-net my-hadoop:3.3.6 /usr/sbin/sshd -Ddocker run -d --name hadoop-slave1 -h slave1 --net hadoop-net my-hadoop:3.3.6 /usr/sbin/sshd -Ddocker run -d --name hadoop-slave2 -h slave2 --net hadoop-net my-hadoop:3.3.6 /usr/sbin/sshd -D问题从第二条命令开始显形。
第一是参数漂移。三条命令手工维护,改镜像版本时漏改一处、忘写一个 --net,三个节点就不在一个网络里。排查半天发现 slave2 根本没加进自定义网络。
第二是状态不可复现。容器删了重建,SSH 主机密钥、NameNode 的格式化数据全部归零,而之前敲进容器的每一处配置改动都消失了。
第三是顺序要人管。先起谁后起谁、谁没起来导致 workers 文件里的主机名解析失败,全靠肉眼盯 docker ps。
撞完这面墙的结论很清楚:多容器场景需要一份声明式的清单,把「要哪些节点、什么参数、什么网络、什么卷」写成一个文件,让工具去对账。
这就是 Compose 的位置。
Compose 的映射
三个 service 对应三台节点
Compose 的心智模型在这类场景里异常直白:一个 service 就是一台节点。
上面的三条命令翻译成一份 docker-compose.yml:
services: master: image: my-hadoop:3.3.6 hostname: master networks: [hadoop] ports: ["127.0.0.1:9870:9870", "127.0.0.1:8088:8088"] volumes: - nn-data:/opt/hadoop/data/nn command: /usr/sbin/sshd -D slave1: image: my-hadoop:3.3.6 hostname: slave1 networks: [hadoop] volumes: - dn1-data:/opt/hadoop/data/dn command: /usr/sbin/sshd -D slave2: image: my-hadoop:3.3.6 hostname: slave2 networks: [hadoop] volumes: - dn2-data:/opt/hadoop/data/dn command: /usr/sbin/sshd -Dnetworks: hadoop:volumes: nn-data: dn1-data: dn2-data:docker compose up -d 一条命令,三个节点按清单拉起,参数不再漂移。
集群要哪些节点、什么参数,都以这份文件为准。
端口的写法有个细节:Web UI 端口绑到 127.0.0.1 前缀上,只允许宿主机本机访问,实验集群没必要向局域网开管理页面。
而 9000 这类节点间 RPC 端口根本不做映射,集群内部走自定义网络直连,暴露出去反而危险。
自定义网络把 hosts 文件变没了
这里面最舒服的一个收获是:自定义 bridge 网络自带 DNS。
同一网络里的容器可以用服务名互相解析,master 这个名字直接指向 master 容器的 IP。
回想虚拟机时代的做法:三台机器各改一份 /etc/hosts,IP 一变三处同步。而在 Compose 里这一步整个消失,workers 文件里写服务名,NameNode 直接靠内置 DNS 找到两个 DataNode。
容器重启换 IP 也没关系,名字不变。
depends_on 值得单独提醒:它只保证启动顺序,不保证就绪。
写上 depends_on: [master] 只意味着 slave 的容器在 master 容器启动后启动,但 master 里的 sshd 可能还没监听。就算配上 healthcheck,检验的也只是「容器进程活着」,不是「HDFS 集群就绪」。
所以 Hadoop 的启动顺序不指望 Compose 管理。让 master 里的 start-dfs.sh 去拉起所有 DataNode(Hadoop 本来就是中心化启动的设计),Compose 只负责把三台机器都开好、网络通好。
顺带说一句 --scale:Compose 可以一条命令把某个 service 横向扩到 N 个实例,对无状态的 Web 服务这是福利,但对 Hadoop 套不上。
多出来的实例没有主机名规划、不在 workers 文件里,NameNode 不认识它们。
有状态集群的「扩容」是配置变更,不是实例复制。容器编排和大数据这两套生态,对扩容的理解确实不一样。
从零写 entrypoint
容器里跑什么、怎么分成 master 和 slave,靠一个入口脚本统一裁决。
骨架不过几十行:
#!/bin/bashset -e# 1. 渲染配置模板(占位符换成真实环境变量)render_config() { for tpl in core-site hdfs-site mapred-site yarn-site; do sed -e "s/__NAMENODE_HOST__/${NAMENODE_HOST}/g" \ -e "s/__REPLICATION__/${DFS_REPLICATION}/g" \ /opt/templates/$tpl.xml.tpl > $HADOOP_HOME/etc/hadoop/$tpl.xml done}render_config# 2. 起本机 sshd(Hadoop 启停脚本依赖它)/usr/sbin/sshd# 3. 按角色分支if [ "$(hostname)" = "master" ]; then # 首次启动格式化(用标记文件防重复格式化) [ -f /opt/hadoop/data/nn/FORMATTED ] || { hdfs namenode -format && touch /opt/hadoop/data/nn/FORMATTED; } start-dfs.sh && start-yarn.shelse # 等 master 的 SSH 就绪再挂起,等待 start-dfs.sh 远程连入 until timeout 2 bash -c "echo > /dev/tcp/master/22" 2>/dev/null; do sleep 2; done tail -f /dev/nullfi几个设计决定值得展开。
渲染配置放在脚本最前。不管容器重启几次,XML 永远由当前环境变量生成,改环境变量等于改配置,幂等。
格式化要有护栏。hdfs namenode -format 在已有数据的目录上再执行会把集群搞成「精神分裂」,用标记文件或检查数据目录非空来防重复,是新手事故榜第一名。
slave 分支用 tail -f /dev/null 挂住。容器的主进程退出就整体退场,slave 的宿命是「活着、等 master 来连」,一个无限 tail 是最朴素的保活。
这套 entrypoint 加上 Dockerfile 里十来行的安装步骤,就是整个镜像的全部「魔法」,加起来不到一百行。
比虚拟机时代散落在三台机器上的操作记录好维护得多。
环境变量注入的坑
同名变量的两副面孔
这次踩坑最多的是环境变量。
第一个坑是 ${VAR} 插值的双重语义。
Compose 文件里写的 image: my-hadoop:${TAG},插值发生在宿主机侧。Compose 读的是 shell 环境变量和 compose 文件同目录的 .env 文件,跟容器内的环境变量毫无关系。
同一个名字,宿主机的 .env 管插值、容器的 env_file 管注入,两套来源互不知道对方存在。
我曾把版本号写进容器的 env_file 以为模板里能引用,结果插值拿到的是空串,镜像 tag 直接变成 my-hadoop: 报错。
解法是约定分层:宿主机 .env 只放 Compose 自用的值(镜像 tag、端口、卷路径),容器业务变量全走 env_file,两边不共用名字。
插值语法里倒是有两条自救规则值得记下:${TAG:-latest} 允许变量缺失时落到默认值,${TAG:?err} 则在缺失时直接报错退出。把「必须显式提供」的变量用 :? 守住,能把「插值拿到空串」这类事故从运行期提前到启动期。
反方向的教训也要记:env_file 里的值不做插值、按字面读入,想把一个变量拼进另一个变量只能在 entrypoint 里做。两边语法能力不同,跨侧迁移配置时最容易想当然。
第二个坑是优先级。同一个变量同时出现在 environment 和 env_file 里时,environment 赢。
这本来是文档里的规则,实际项目里却容易变成事故。environment 里残留一行调试用的旧值,把 env_file 里精心维护的配置悄悄盖掉。
定期把两处的变量合并对比一遍,是这类项目的固定保养。
第三个坑是 Hadoop 特有的:它的配置全是 XML,不认环境变量。
core-site.xml 里 fs.defaultFS 要写 hdfs://master:9000,容器里改环境变量不会传导到 XML。
解法就是上一节 entrypoint 里的模板渲染:XML 写成含占位符的模板,启动时用 sed 或 envsubst 把真实环境变量灌进去。
这套「env 进来、XML 出去」的逻辑一旦写好,换集群规模只改环境变量。
.env 文件本身多了以后也有趁手的维护方式:用环境变量解析器把它解析成键值视图、对比两份配置的差异、或者按模板生成新的 .env,比肉眼 diff 靠谱。
一份值得参数化的变量清单
哪些值值得被抽成变量,随手记一份。
镜像 tag(升级 Hadoop 版本只改一处)、DFS_REPLICATION(副本数,三节点集群默认 3,若哪天缩到两节点必须同步改成 2,否则写入持续报「副本不足」)、NameNode 堆大小(HDFS_NAMENODE_OPTS 里的 -Xmx,文件数上千万前先把它加大)、各组件的主机名。
反过来的教训也有:不要把所有东西都抽成变量。
像 Hadoop 安装路径这类整个生命周期不变、且改了必然连带动手脚本路径的值,写死比抽出来更安全。
参数化的边界是「变更有真实场景」,为了对称抽出来的变量,之后没人敢动。
镜像、密钥与数据卷
一个基础镜像加角色脚本,还是三个 Dockerfile 各自烘焙角色?
我选了前者。三个节点的差异其实只有主机名和启动分支,JDK、Hadoop 解压、依赖包这些镜像层完全一致,构建三份纯属浪费。把差异收敛到 entrypoint 的 if 里,维护面最小。
SSH 免密的处理:构建期生成一对密钥并烘焙进镜像(公钥追加进 authorized_keys),私钥挂载覆盖以便复用。
实验环境这么干省掉首启的等待,生产上密钥管理是另一套严肃话题,不能照搬。
所有容器里 sshd 必须在跑,因为 Hadoop 的启动脚本本质是 SSH 远程命令。
数据卷是这次迁移最大的收益。
NameNode 的元数据和 DataNode 的块数据都挂在命名卷上,于是 docker compose down 删掉容器后,up 回来集群状态原样保留。NameNode 不用重新格式化,免密和配置也不用重做。
反过来这里的经典事故也要记一笔:如果第一次格式化 NameNode 之后又换了个卷或删了卷再格式化,DataNode 卷里残留的旧 clusterID 和新 NameNode 对不上,DataNode 日志里刷 Incompatible clusterIDs,拒绝注册。
六个典型故障里的头号常客,解法在下一节。
另外分清两个命令的语义:stop/start 保留容器只是停进程,down 删容器但命名卷还在,down -v 连卷一起删。最后这个等于「恢复出厂设置」,敲之前想一下格式化没格式化。
内存限制也建议写进清单:NameNode 把文件系统元数据全放堆里,DataNode 容器抢内存会把 YARN 的 NodeManager 挤出问题。mem_limit 给 master 多分一些、slave 少分一些,比系统 OOM 后看日志猜体面得多。
SSH 互信的实现比虚拟机时代多一个讲究:主机密钥的稳定性。
容器每次重建都会重新生成 SSH 主机密钥,客户端的 known_hosts 立刻失配,严格校验模式下连接直接被拒。
实验环境的简化做法是镜像构建期固定生成一套主机密钥(ssh-keygen -A 的产物烘进镜像层),所有容器共用。风险由实验属性兜底。
同时把客户端的 StrictHostKeyChecking 设为 accept-new 或干脆 no,免得每次重建都要手工清理 known_hosts。
另一个差异是免密的粒度。虚拟机时代是「三台机器互相免密」,容器里可以收窄成「master 免密登录两个 slave」单向关系。
因为发起 SSH 的只有 Hadoop 启停脚本,slave 从不主动连 master,权限收窄不影响功能,还少维护两组密钥。
这类「按实际通信方向裁剪权限」的思路,在容器环境里值得养成习惯。
常见故障六连
这轮搭建里遇到或替人排过的高频故障,按「症状—原因—处置」归档,下次直接对号入座:
- Web UI 里 Live Nodes 是 0。原因几乎必然是 clusterID 不匹配(见上节),DataNode 日志会直说。处置:停集群,清空两个 DataNode 的数据卷,重新格式化 NameNode,再起。
ssh: connect to host master port 22: Connection refused。master 容器里 sshd 没起来,多半是 entrypoint 里/usr/sbin/sshd没执行或缺主机密钥(首次运行要ssh-keygen -A)。处置:docker compose exec master sshd -t看配置报错,手动补密钥。- workers 里的主机名解析失败。某个 slave 没加进自定义网络(命令式时代的残留),或服务名拼错。处置:
docker compose exec master getent hosts slave1逐个验证,缺失的回 yml 里补networks。 - NameNode 卡在 safe mode 不出来。常见于数据卷残留或 DataNode 上线率低于阈值。观察期等待即可。开发环境等不及用
hdfs dfsadmin -safemode leave强制出组,但要明白这是跳过检查而不是修复。 jps里没有 NameNode/DataNode 进程。十有八九是hadoop-env.sh里JAVA_HOME没配好。Hadoop 不一定继承容器的环境变量,脚本里显式指向 JDK 路径最稳。日志里的JAVA_HOME is not set一行就招了。- Web UI 一切正常,客户端上传却超时。这是一个隐蔽度最高的问题:DataNode 向 NameNode 注册时报的是自己的主机名,客户端写文件时从 NameNode 拿到 DataNode 位置列表后,要按主机名去连 DataNode。如果发起请求的宿主机解析不了这些容器主机名,写入就在「分块副本」一步卡死。读(走 NameNode 元数据)正常、写(直连 DataNode)失败,症状不对称是这个故障的指纹。处置:要么在发起访问的机器上补 hosts 映射,要么确认 DataNode 注册用可解析的名字。
把流水线接上
这套环境要长期活着,还得解决两件事:镜像怎么构建、改完怎么验证。
镜像分层按「变化频率从低到高」堆:系统依赖与 JDK 在最底层(几个月不变),Hadoop 解压与目录结构在中间(随版本变),配置模板与 entrypoint 在最上层(天天改)。
这样改一次 entrypoint,重建镜像只重新执行最后一层,缓存让构建从几分钟缩到几秒。
Dockerfile 里指令的排序就是缓存策略,顺序错了构建速度差一个量级。
版本标记别用裸 latest,my-hadoop:3.3.6-20260907 这种「版本加日期」的 tag 让任何一次回滚都有名有姓。
验证侧为集群写一条冒烟测试脚本,五步走完即认为环境健康:docker compose up -d 起集群;轮询 master 容器直到 hdfs dfsadmin -report 能列出三个节点(设总超时,防死等);hdfs dfs -put 一个小文件再 get 回来比对校验和,验证读写闭环;跑一个极小的 MapReduce 作业或 hdfs dfs -rm 清理痕迹;down 收场。
这条脚本最大的价值在「改了配置之后」:entrypoint 动了一行、XML 模板换了个字段,人力手动验证很容易漏,脚本三十秒给出裁决。
更进一步把它接进 CI,镜像构建加冒烟测试就成了每次提交的固定关卡。实验环境自动化的终点不是「能跑」,是「每次都能跑」。
数据备份是这个体系的最后一块拼图。
命名卷里的 NameNode 元数据是整个集群的「账本」,丢了就要重建全部目录结构。
备份用一条一次性容器命令就能完成:起一个临时容器把数据卷和宿主机目录同时挂进来,里面打一个 tar 包,用完即删。
整套环境的迁移因此收敛为三件行李:compose 文件加模板(文本,进 Git)、镜像(一个 tar 包)、数据卷备份(按需)。
在任何一台装了 Docker 的机器上,三条命令就能恢复整个实验集群。这也是它对比虚拟机方案最直观的胜利。
日常操作清单
集群跑起来之后的日常高频操作,无非这几条:
docker compose up -d 按清单拉起或补齐节点。docker compose exec master jps 看各进程在不在。docker compose logs -f master 追 NameNode 日志。改了 yml 后 up -d 会增量重建有变化的 service。停机用 stop,重置用 down,毁天灭地用 down -v。
排查网络时进容器 getent hosts slave1 确认 DNS,ping 不通先看两边是否真的在同一网络。
这些命令我最后都收进了运维速查里现查。它把 Shell、systemd、Docker、nginx 这类常用命令按场景分好页,写清单文件的时候随手翻,比翻官方文档快。
如果手上已经有一条调好的 docker run 长命令想迁移,可以用Docker ⇄ Compose 互转把它转成 compose 片段再对账。端口、卷、环境变量三类参数最容易在手工翻译时漏项。
最后一点反思,关于这套方案的边界:容器化 Hadoop 集群是绝佳的实验与学习环境。可重复、可丢弃、配置即文档,学 HDFS 架构、跑 MapReduce 作业、练运维命令,效率远高于虚拟机方案。
但它不是「迷你生产环境」:所有节点共享一台宿主机的内核与资源,单机宕机等于全集群宕机,网络分区、节点掉电这类真正的分布式故障在这里根本无法模拟。
想练故障注入,还得回到多台虚拟机或多台云主机。
工具各管一段,清楚边界才不会把实验环境错当成生产演练场。
两种方案的账本摆在一起看更直白:启动速度上,容器全集群秒级、虚拟机按十分钟计。资源开销上,三个桌面级虚拟机要固定吃掉几十 GB 磁盘与十余 GB 内存,容器集群空闲时几乎不占。快照与迁移上,一份 yml 加一个镜像 tar 包就能在任何机器复现,虚拟机要整盘导出几十 GB。而故障仿真与内核级实验上,虚拟机完胜。
学习架构选容器,仿真故障选虚拟机。这条分界线本身比任何一种方案都值得写进笔记。
至于集群装好之后的下一步:上手 HDFS 的日常命令,记录在 Hadoop学习笔记 -2 里。
回头看这次迁移,最大的收获是分清了声明式与命令式的分界。
docker run 是命令式的,做到哪算哪。Compose 是声明式的,先写下期望状态,剩下的差距由工具对账。
Hadoop 的多节点恰好是检验这个分界的绝佳教材。它对主机名、网络、启动顺序、持久化样样挑剔,任何一处含糊都会在日志里还回来。