最近有个大数据环境相关的测试需求:要快速验证一套三节点的Hadoop集群,还要跑通经典的WordCount用例。按老思路开几台虚拟机的话,内存和磁盘都是问题;买云主机又没必要,毕竟只是临时测试。最后我选择用Docker在单机上通过Compose编排一主两从的结构,整个过程花了一个小时左右就跑起来了,WordCount也顺利出结果。
这篇文章就把整个流程从头到尾拆开讲,包括基础镜像怎么打造、容器网络怎么铺、Hadoop的四个核心配置文件怎么改、NameNode格式化有什么坑、WordCount怎么提交怎么验结果,最后是一份常见问题速查表。适合两类人看:一类是刚开始接触Hadoop、想有个真实集群环境练手的入门开发者;另一类是已经在用Docker、但没试过把Hadoop集群容器化的后端工程师。
1. 整体方案设计:为什么选择Docker Compose而不是虚拟机
1.1 我的目标场景
我要的是一个最少三节点的Hadoop集群:一个节点承担NameNode和ResourceManager,另外两个节点承担DataNode和NodeManager。这样虽然说是“分布式”,但所有容器都落在同一台宿主机上,既保留了多节点之间的网络通信和任务调度逻辑,又不会像真机集群那样需要维护多套系统环境。
数据规模也不大,主要为了跑MapReduce作业,验证HDFS的上传下载、YARN的资源调度、容器间通信这些核心机制。既然目标是“快速搭建、用完即丢”,那么可重复性是硬指标。Docker正好把环境打包成镜像,任何机器上拉下来就能跑,完全不用重新配一遍JDK和Hadoop。
1.2 方案对比
用虚拟机建集群是很多教程里的经典方案,但虚拟机吃资源太厉害。三台CentOS虚拟机加在一起,内存轻松占掉6GB以上,启动还要等操作系统完完整整跑起来,整体体验非常沉重。云主机方案同样面临成本和等待问题,虽然性能隔离更好,但对本地测试来说有点杀鸡用牛刀。
Docker方案在资源占用和启动速度上有天然优势。每个容器只携带JDK、Hadoop运行环境,总共也就一两个GB的空间占用,启动速度以秒计。再加上Compose可以把网络、端口、环境变量、挂载目录全部声明式写清楚,任何新机器上执行完命令就能得到一模一样的集群,这正好命中测试环境最需要的“可重置、可复现”能力。当然代价也有:容器不完全等同于物理机器,比如内核参数、磁盘IO隔离会弱一些,但跑WordCount这种轻量级作业完全够用。
2. 基础镜像制作:JDK11 + Hadoop3.3.6 + SSH免密
2.1 版本选型
Hadoop 3.3版本对JDK的要求是Java 8或Java 11。我选了JDK 11,因为新一些的组件生态对JDK 11支持更友好,而且Ubuntu 20.04的软件源里直接有openjdk-11-jdk-headless,安装省事。Hadoop版本选了3.3.6,这是目前比较稳定的小版本,Common、HDFS、MapReduce都包含在同一个tar包里,用起来最顺手。
另外很重要的一点:Hadoop脚本通过SSH远程连接到各节点启动守护进程,所以镜像里必须装好OpenSSH Server,同时做好root用户的免密登录。很多从零搭建教程会把这步藏在细节里,但恰恰是集群启动失败的高发区。
2.2 Dockerfile完整解析
我直接在Ubuntu基础镜像上定义当前环境的构建文件,先把JDK和SSH装上,再下载Hadoop安装包解压到/opt,最后配置SSH免密。
FROM ubuntu:20.04 ENV DEBIAN_FRONTEND=noninteractive ENV JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 ENV HADOOP_VERSION=3.3.6 ENV HADOOP_HOME=/opt/hadoop ENV PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin RUN apt-get update && apt-get install -y --no-install-recommends \ openjdk-11-jdk-headless \ openssh-server \ openssh-client \ curl \ vim \ && rm -rf /var/lib/apt/lists/* RUN curl -fSL \ "https://archive.apache.org/dist/hadoop/common/hadoop-${HADOOP_VERSION}/hadoop-${HADOOP_VERSION}.tar.gz" \ -o /tmp/hadoop.tar.gz \ && tar -xzf /tmp/hadoop.tar.gz -C /opt \ && ln -s /opt/hadoop-${HADOOP_VERSION} ${HADOOP_HOME} \ && rm /tmp/hadoop.tar.gz RUN ssh-keygen -q -t rsa -N "" -f /root/.ssh/id_rsa \ && cat /root/.ssh/id_rsa.pub >> /root/.ssh/authorized_keys \ && chmod 600 /root/.ssh/authorized_keys \ && printf "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile=/dev/null\n LogLevel ERROR\n" > /root/.ssh/config RUN mkdir -p /opt/hadoop/data/name /opt/hadoop/data/data /opt/hadoop/logs RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config \ && sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config EXPOSE 22 8020 9000 9870 8088 19888 CMD ["/usr/sbin/sshd", "-D"]这里有几个细节需要注意。首先,ssh-keygen生成密钥后必须把公钥写入authorized_keys,而且权限不能错,否则从master节点SSH到worker节点时会卡在密码确认上。其次,StrictHostKeyChecking一定要禁用,否则第一次连接会出现一个交互式确认,而Hadoop的启动脚本是无人值守的,只要出现这个确认,进程就会一直挂着。最后,/opt/hadoop/data下的name和data目录在format后会自动生成内容,但目录本身需要提前创建好。
2.3 构建镜像与基础验证
镜像构建命令很简单:
docker build -t my-hadoop:3.3.6 .构建完成后可以顺手进去验证一下基础环境:
docker run --rm -it my-hadoop:3.3.6 bash进入容器后执行:
java -version hadoop version ssh-keygen -l -f /root/.ssh/id_rsa.pub三条命令都能正常输出,说明JDK、Hadoop、SSH密钥三件套已经就绪。这里强调一下:镜像里的authorized_keys是所有容器共用的,也就是说每个容器内都有其他容器的公钥,互信关系天然成立。这比容器启动之后再互相拷贝公钥要省事得多。
3. 用Compose编排一主两从:网络与容器配置
3.1 自定义桥接网络与主机名解析
Docker容器各自有独立IP,但IP是动态分配的,重启后可能变化。为了避免在配置文件里写死IP,我选择让Docker Compose创建自定义网络,并显式设置每个容器的hostname。在自定义网络中,容器之间可以直接通过hostname互相访问,这就解决了Hadoop集群里最基础的主机名解析问题。
我的规划是三个容器,分别叫master、worker1、worker2。master对外暴露HDFS的Web界面9870端口、YARN的Web界面8088端口,方便在宿主机上直接用浏览器查看集群状态。worker节点不映射端口到宿主机,只在Docker网络内部提供数据存储和计算资源。
3.2 docker-compose.yml完整配置
编排文件如下:
version: "3" services: master: image: my-hadoop:3.3.6 container_name: hadoop-master hostname: master networks: - hadoop-net ports: - "9870:9870" - "8088:8088" - "19888:19888" volumes: - ./hadoop-conf:/opt/hadoop/etc/hadoop environment: - HDFS_NAMENODE_USER=root - HDFS_DATANODE_USER=root - HDFS_SECONDARYNAMENODE_USER=root - YARN_RESOURCEMANAGER_USER=root - YARN_NODEMANAGER_USER=root command: ["/usr/sbin/sshd", "-D"] worker1: image: my-hadoop:3.3.6 container_name: hadoop-worker1 hostname: worker1 networks: - hadoop-net environment: - HDFS_NAMENODE_USER=root - HDFS_DATANODE_USER=root - HDFS_SECONDARYNAMENODE_USER=root - YARN_RESOURCEMANAGER_USER=root - YARN_NODEMANAGER_USER=root command: ["/usr/sbin/sshd", "-D"] worker2: image: my-hadoop:3.3.6 container_name: hadoop-worker2 hostname: worker2 networks: - hadoop-net environment: - HDFS_NAMENODE_USER=root - HDFS_DATANODE_USER=root - HDFS_SECONDARYNAMENODE_USER=root - YARN_RESOURCEMANAGER_USER=root - YARN_NODEMANAGER_USER=root command: ["/usr/sbin/sshd", "-D"] networks: hadoop-net: driver: bridge这里最需要关注的是environment里的那批HDFS_*_USER和YARN_*_USER变量。因为容器默认使用root用户,而Hadoop 3.x版本的脚本出于安全考虑,默认不允许root直接启动守护进程。如果不把这些环境变量声明为root,执行start-dfs.sh时会直接报“Attempting to operate on hdfs as root”这种错误。这是很多新手第一次启动失败的头号原因。
另外,我把Hadoop配置目录挂载到了宿主机的./hadoop-conf上。这样一来,改配置不需要进容器编辑,直接在宿主机上用vim改完,再在容器里重启服务即可。当然前提是镜像内的/opt/hadoop/etc/hadoop目录存在,如果挂载本地目录为空,需要先手动把镜像里已有的配置文件复制出来。
3.3 启动容器后的基础自检
执行启动命令:
docker compose up -d刚启动时先别急着配置和格式化,进master容器确认三台机器的网络连通性:
docker exec -it hadoop-master bash ping -c 2 worker1 ping -c 2 worker2能ping通worker1、worker2,说明Docker网络内的hostname解析生效了。接着检查SSH双向免密:
ssh master echo "connect master ok" ssh worker1 echo "connect worker1 ok" ssh worker2 echo "connect worker2 ok"如果三条命令都直接打出对应的输出,没有要求输入密码,说明SSH互信没有问题,后面启动Hadoop脚本时就不会卡在连接环节。
4. Hadoop配置与NameNode格式化
4.1 四个配置文件的核心逻辑
Hadoop集群的配置可以拆成三个层次:文件系统层、调度层、作业执行层。core-site.xml负责定义集群入口,也就是默认的HDFS地址;hdfs-site.xml负责NameNode和DataNode的存储路径以及副本数;yarn-site.xml负责ResourceManager的位置和NodeManager的资源参数;mapred-site.xml决定MapReduce跑在什么框架上。
理解了这层关系之后,配置过程就不会只是机械地抄XML。四个文件各管一段,组合起来才是一个完整的集群。
4.2 配置文件完整内容
我把下面的四个文件放到宿主机的./hadoop-conf目录下,因为已经挂载到所有容器中,所以不用分别进容器修改。
core-site.xml:
<configuration> <property> <name>fs.defaultFS</name> <value>hdfs://master:9000</value> </property> <property> <name>hadoop.tmp.dir</name> <value>/opt/hadoop/tmp</value> </property> </configuration>hdfs-site.xml:
<configuration> <property> <name>dfs.namenode.name.dir</name> <value>file:///opt/hadoop/data/name</value> </property> <property> <name>dfs.datanode.data.dir</name> <value>file:///opt/hadoop/data/data</value> </property> <property> <name>dfs.replication</name> <value>2</value> </property> <property> <name>dfs.permissions.enabled</name> <value>false</value> </property> </configuration>yarn-site.xml:
<configuration> <property> <name>yarn.resourcemanager.hostname</name> <value>master</value> </property> <property> <name>yarn.nodemanager.aux-services</name> <value>mapreduce_shuffle</value> </property> <property> <name>yarn.nodemanager.aux-services.mapreduce_shuffle.class</name> <value>org.apache.hadoop.mapred.ShuffleHandler</value> </property> <property> <name>yarn.nodemanager.resource.memory-mb</name> <value>2048</value> </property> <property> <name>yarn.nodemanager.resource.cpu-vcores</name> <value>2</value> </property> </configuration>mapred-site.xml:
<configuration> <property> <name>mapreduce.framework.name</name> <value>yarn</value> </property> <property> <name>mapreduce.application.classpath</name> <value>$HADOOP_MAPRED_HOME/share/hadoop/mapreduce/*:$HADOOP_MAPRED_HOME/share/hadoop/mapreduce/lib/*</value> </property> </configuration>还有关键文件workers(Hadoop 3.x用这个名字,老版本叫slaves):
worker1 worker2dfs.replication为什么设成2而不是默认的3?因为我们只有两个DataNode。设成3的话,任何一个文件都会因为找不到第三个副本而触发健康告警,虽然WordCount多半还能跑完,但集群状态看起来始终不健康,没必要给自己添堵。
yarn.nodemanager.resource.memory-mb是NodeManager最多可用的物理内存。如果不设置,默认会按照宿主机物理内存检测,但容器里的NodeManager看到的可能是宿主机的大量内存,而容器本身又有cgroup内存限制,两者的信息不一致很容易导致容器被OOM杀掉。显式限制为2048MB,更稳妥。
4.3 格式化NameNode与“集群ID不一致”的坑
配置完成后,进入master容器执行:
hdfs namenode -format这一步会生成NameNode的数据目录和集群ID,并且会打印一行类似“Storage directory /opt/hadoop/data/name has been successfully formatted”的输出。看到这行才能确认格式化成功。
格式化最经典的坑是“集群ID不一致”。如果集群启动过一次,NameNode和DataNode的数据目录里都保存了clusterID。当你在不清理DataNode数据的情况下重新格式化NameNode,新的NameNode会生成新的clusterID,而DataNode还在用旧ID,结果就是DataNode连不上NameNode,日志里一直报Incompatible clusterIDs。
解决思路说穿了很简单:清掉所有节点的/opt/hadoop/data/name和/opt/hadoop/data/data目录,再重新格式化。但要注意,必须三个容器都清理干净,不是只清master。实操上我通常直接删掉容器再重建,再执行format,整个环境回归纯净状态。
5. 启动集群与验证进程
5.1 启动HDFS与YARN
在master容器内按顺序启动:
start-dfs.sh start-yarn.shstart-dfs.sh会通过SSH连接所有worker节点,启动相应的HDFS进程。由于我们在镜像里已经配置好免密登录,这里不会出现交互确认。第一次启动时日志里会有较多INFO信息,不用紧张,只要最后没有ERROR或异常堆栈,基本就是正常启动。
启动YARN同理。ResourceManager在master节点,NodeManager会在两个worker节点启动。如果之前设置的环境变量没有生效,这里会报“Attempting to operate on yarn as root”,所以上面的YARN_*_USER环境变量必须确保已经注入。
5.2 用jps和Web UI确认集群健康
分别在三个容器执行jps:
master节点应该看到:
NameNode SecondaryNameNode ResourceManager Jpsworker1和worker2节点应该看到:
DataNode NodeManager Jps如果某个MessageNode没起来,优先看对应日志,不要急着重启整个集群。比如DataNode起不来,访问/opt/hadoop/logs/hadoop-root-datanode-master.log看报错,比盲目重复尝试有效得多。
浏览器访问宿主机上的两个Web界面:
- HDFS界面:http://localhost:9870
- YARN界面:http://localhost:8088
在HDFS界面上看“Datanodes”标签,两个节点应该都是Live状态。在YARN界面上看“Active Nodes”,两个NodeManager都应该被列出来。Web UI都正常,说明集群的基础服务已经就绪。
6. WordCount完整测试流程
6.1 准备测试数据并上传HDFS
我习惯先准备一个很小的文本文件,内容设计得能清楚看到单词频次是否准确:
mkdir -p /tmp/wc-input echo "hello world hadoop hello docker docker hadoop" > /tmp/wc-input/input.txt这个文件里出现了hello两次、hadoop两次、docker两次、world一次,正好作为校验结果的标准答案。
把文件上传到HDFS:
hdfs dfs -mkdir -p /wc/input hdfs dfs -put /tmp/wc-input/input.txt /wc/input/查看上传是否成功:
hdfs dfs -ls /wc/input确认input.txt已经在HDFS上之后,才能提交MapReduce任务。
6.2 提交MapReduce任务
WordCount是Hadoop自带的示例程序,不用自己写代码。直接调用examples jar:
hadoop jar $HADOOP_HOME/share/hadoop/mapreduce/hadoop-mapreduce-examples-3.3.6.jar \ wordcount /wc/input /wc/output作业提交后,终端会滚动输出Map和Reduce的进度信息。每条map/reduce的百分比都会显示,如果有任务失败,会在日志中看到具体错误。整个作业完成时间通常在一分钟内,因为这个输入文件非常小。
执行完之后检查输出目录:
hdfs dfs -cat /wc/output/part-r-00000最终结果应该和标准答案完全一致:
docker 2 hadoop 2 hello 2 world 1这里有一个很容易犯的错:输出目录/wc/output必须事先不存在。MapReduce框架要求输出目录是全新的,如果重复运行同一个输出路径,作业会直接报错。所以每次做验证时,要么先删掉旧输出目录:
hdfs dfs -rm -r /wc/output要么换个新路径。
6.3 结果解读与验证
看到正确的统计结果,说明整条链路已经通了:HDFS能正常读写、YARN能正常调度Container、MapTask和ReduceTask能正确执行、Shuffle过程把相同key聚合到了一起。
我还习惯额外验证一个点:故意把输入文件复制成多个,制造出多次分片的效果,比如再创建两个同样内容的文件重新上传,然后重新跑一次WordCount。最终结果应该还是单词频次相同,但数据量变成三倍。这样可以顺便观察MapTask的数量是否增加了。这种小实验对加深MapReduce执行流程的理解很有帮助。
7. 常见问题排查与避坑经验
7.1 常见错误速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 启动脚本报“Attempting to operate on hdfs as root” | 未设置HDFS_NAMENODE_USER等环境变量 | 在Compose的environment中声明root用户 |
| DataNode日志提示Incompatible clusterIDs | NameNode重新格式化但DataNode数据未清理 | 清空所有节点/opt/hadoop/data目录后重新format |
| WordCount输出目录报FileAlreadyExists | 输出目录已存在 | 先删除输出目录或换新路径 |
| YARN界面Active Nodes为0 | NodeManager内存配置未显式指定 | 设置yarn.nodemanager.resource.memory-mb |
| SSH连接卡在主机指纹确认 | StrictHostKeyChecking未关闭 | 在/root/.ssh/config中配置no |
| Web界面无法打开 | 容器端口未映射或映射冲突 | 检查docker-compose ports配置 |
| 容器重启后HDFS状态异常 | DataNode IP变化且NameNode地址写死 | 使用hostname沟通,不要写容器IP |
7.2 排查顺序与我的实操心得
遇到集群异常,我有一套固定的排查顺序。先看docker ps确认容器都活着,再进master执行jps看进程是否存在,然后看对应日志的尾部,最后才考虑改配置重启。这个顺序可以避免很多瞎猜。
整个过程踩过最深的坑就是格式化数据目录不彻底,导致DataNode反复横跳。后来我总结出一个习惯:只要涉及NameNode重新格式化,必定先删掉所有容器里的name和data目录,再执行format。宁可多花几秒钟清理,也不要赌它没问题。
还有一个容器环境特有的注意点:容器内存和宿主机的内存感知不一致。如果你的机器内存不多,建议把yarn.nodemanager.resource.memory-mb调小,比如1024或512,同时mapreduce的作业内存参数也跟着调小,否则作业可能因为内存申请失败被反复重试。测试集群里资源配得松一点不是坏事,毕竟目的是验证功能和流程。
最后分享一个小技巧:把所有配置文件、docker-compose.yml、启动脚本放进同一个项目目录,再用一个脚本完成“清理容器、构建镜像、启动集群、格式化、启动服务”的一键流程。这样每次环境搞坏了,跑一遍脚本就能恢复到干净状态,WordCount测试也能反复跑,不用每次手动清理。Docker方案的真正价值也就在这里:环境是代码的一部分,随时可以重建,而不是一个运行了很久、状态不可复现的黑盒。