1. 为什么我选择用 Docker 来搭 Hadoop 集群
1.1 从“装三台虚拟机”到“拉三个容器”的转变
早些年搭一套 Hadoop 集群,标准动作是开三台虚拟机,一台当主节点,两台当从节点,然后挨个装系统、配网络、改主机名、装 Java、解压 Hadoop、改一堆 XML 配置文件,最后还要祈祷 SSH 免密登录别出岔子。整个过程下来,快则半天,慢则两天,中间任何一个环节配错,排查起来都够喝一壶。更难受的是,这套环境一旦搞坏了,想重来一遍又得从头折腾。
用 Docker 来搭 Hadoop 集群,本质上解决的就是“环境复现”和“资源开销”这两个痛点。容器共享宿主机内核,启动一个容器通常只要一两秒,三个节点的集群在内存充足的机器上跑起来毫无压力。镜像一旦构建好,配置文件全部固化在里面,换台机器照样一键起集群,不用再担心“在我电脑上是好的”这种经典问题。对于需要反复做实验、验证配置、学习分布式机制的人来说,这套方案的可重复性价值极高。
我个人的判断是:如果你只是想跑通一个 WordCount 示例、理解 HDFS 的块存储和副本机制、观察 YARN 的资源调度过程,那么 Docker 方案是性价比最高的选择。它把“环境搭建”这个与学习目标无关的体力活压缩到了最低,让你能把精力放在分布式系统本身的行为上。
1.2 这套方案适合谁,不适合谁
先说适合的人群。第一类是正在学习大数据基础的学生或转行者,需要一套能随时起停、随意折腾的实验环境;第二类是开发人员,想在本地快速验证一个 MapReduce 任务或者 Hive 查询,不想连远程集群;第三类是运维或平台工程师,想用低成本方式模拟多节点拓扑,做配置验证和故障演练。
不适合的场景也要说清楚。Docker 容器本质上还是单机上的进程隔离,所有“节点”共享同一块物理磁盘和同一张网卡,所以它无法真实模拟跨机器的网络延迟、磁盘 IO 竞争和硬件故障。如果你要做的是性能压测、大规模数据量的基准测试,或者需要验证机架感知这类依赖真实物理拓扑的特性,那还是老老实实用真实机器或者云上的多节点实例。另外,生产环境部署 Hadoop 集群也不会用这种单机多容器的方式,这一点要心里有数,别把实验环境当生产方案。
1.3 整体架构长什么样
这套集群的拓扑很经典:一个主节点容器,承担 NameNode 和 ResourceManager 的角色;两个从节点容器,各自跑 DataNode 和 NodeManager。三个容器通过 Docker 的自定义桥接网络互联,彼此能用容器名直接解析到 IP,省去了手动配 hosts 的麻烦。主节点通过 SSH 免密登录到两个从节点,这是 Hadoop 启动脚本分发进程的前提条件。
镜像层面,我选择基于一个轻量的 Linux 基础镜像,在里面装好 JDK 和 Hadoop,把配置文件提前写好。这样每次启动容器,环境就是就绪状态,不需要在容器里再执行安装命令。数据持久化方面,HDFS 的元数据和数据目录通过数据卷挂载到宿主机,容器删了数据还在,方便反复实验。
2. 镜像构建:把环境一次性固化下来
2.1 基础镜像与依赖选型
基础镜像我倾向于选一个体积小、包管理方便的发行版。体积小意味着拉取快、启动快,也减少不必要的攻击面。JDK 版本要和 Hadoop 版本匹配,Hadoop 3.x 系列官方推荐 JDK 8,用 JDK 11 也能跑但个别组件会有兼容性提示,所以稳妥起见还是 JDK 8。Hadoop 本身选 3.3.x 的稳定版本,这个版本对容器环境比较友好,配置项也成熟。
这里有个容易被忽略的点:Hadoop 的 native 库。默认发行包里的 native 库是针对特定 glibc 版本编译的,在某些精简基础镜像上会报“Unable to load native-hadoop library”的警告。这个警告本身不影响功能,但如果你在意压缩性能(比如用 Snappy 或 LZO),就需要自己编译 native 库或者选一个 glibc 版本匹配的基础镜像。我实测下来,选一个常规的、glibc 较新的基础镜像,这个警告基本可以忽略,功能不受影响。
2.2 Dockerfile 的编写要点
Dockerfile 的核心任务就三件事:装 JDK、装 Hadoop、配环境变量和 SSH。我把它拆成几个层次来写,方便利用构建缓存。先装 JDK 和 SSH 服务端,这两个变化频率低,放前面;再解压 Hadoop 并配置环境变量,这层变化频率稍高;最后拷贝配置文件,这层每次改配置都会重建,放最后最合理。
FROM ubuntu:20.04 ENV DEBIAN_FRONTEND=noninteractive ENV JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 ENV HADOOP_HOME=/opt/hadoop ENV HADOOP_CONF_DIR=$HADOOP_HOME/etc/hadoop ENV PATH=$PATH:$JAVA_HOME/bin:$HADOOP_HOME/bin:$HADOOP_HOME/sbin RUN apt-get update && apt-get install -y \ openjdk-8-jdk \ openssh-server \ openssh-client \ vim \ net-tools \ && rm -rf /var/lib/apt/lists/* COPY hadoop-3.3.6.tar.gz /tmp/ RUN tar -xzf /tmp/hadoop-3.3.6.tar.gz -C /opt/ \ && mv /opt/hadoop-3.3.6 /opt/hadoop \ && rm /tmp/hadoop-3.3.6.tar.gz COPY config/ $HADOOP_CONF_DIR/ RUN ssh-keygen -t rsa -P '' -f ~/.ssh/id_rsa \ && cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys \ && chmod 600 ~/.ssh/authorized_keys RUN mkdir -p /data/hdfs/name /data/hdfs/data /data/hdfs/tmp EXPOSE 9870 8088 9000 9864 9866 9867 CMD ["/usr/sbin/sshd", "-D"]这段 Dockerfile 里有几个细节值得展开说。DEBIAN_FRONTEND=noninteractive是为了避免 apt 安装过程中弹出交互式配置界面卡住构建。SSH 密钥在镜像构建阶段就生成好,这样所有基于这个镜像启动的容器共享同一套密钥,主节点登录从节点时不需要再手动分发公钥,省事。数据目录提前创建好,配合后面的数据卷挂载,避免权限问题。
注意:把私钥固化在镜像里在实验环境没问题,但如果你要把镜像推到公共仓库,记得别把带私钥的镜像公开,虽然这只是实验用途,但养成好习惯没坏处。
2.3 配置文件的关键改动
Hadoop 的配置文件有一堆,但真正需要改的核心就那么几个。我把它们列成表格,方便对照。
| 配置文件 | 关键参数 | 作用 |
|---|---|---|
| core-site.xml | fs.defaultFS | 指定 HDFS 的访问地址,设为 hdfs://master:9000 |
| core-site.xml | hadoop.tmp.dir | 临时目录,指向挂载的数据卷路径 |
| hdfs-site.xml | dfs.replication | 副本数,两从节点就设 2 |
| hdfs-site.xml | dfs.namenode.name.dir | NameNode 元数据目录 |
| hdfs-site.xml | dfs.datanode.data.dir | DataNode 数据目录 |
| yarn-site.xml | yarn.resourcemanager.hostname | 指定 ResourceManager 所在主机 |
| yarn-site.xml | yarn.nodemanager.aux-services | 设为 mapreduce_shuffle |
| mapred-site.xml | mapreduce.framework.name | 设为 yarn |
| workers | 从节点主机名列表 | 告诉启动脚本往哪些节点分发进程 |
fs.defaultFS里的主机名用master,这个master就是容器的名字,Docker 自定义网络里的容器名可以直接被 DNS 解析,所以不需要在 hosts 文件里写死 IP。这一点是容器方案相比虚拟机方案最舒服的地方,IP 变了也不影响配置。
dfs.replication设成 2 而不是默认的 3,是因为我们只有两个 DataNode,设 3 会导致 HDFS 一直处于副本不足的告警状态,虽然不影响读写,但看着闹心。这个参数要根据实际从节点数量来定,节点数少于副本数时,HDFS 会尽力而为但始终报 under-replicated。
hadoop.tmp.dir一定要指向挂载出来的数据卷目录,否则容器一删,HDFS 的元数据全丢,下次启动 NameNode 会因为找不到 fsimage 而进入安全模式或者直接格式化失败。
3. 集群编排与启动实操
3.1 用 Docker Compose 定义三个节点
手写docker run命令起三个容器也能做,但参数多、容易漏,用 Compose 文件描述更清晰,也方便一键起停。核心是定义一个自定义网络,让三个容器在同一个网段里,然后用容器名互相访问。
version: "3.8" services: master: image: hadoop-cluster:3.3.6 container_name: master hostname: master networks: - hadoop-net ports: - "9870:9870" - "8088:8088" - "9000:9000" volumes: - ./data/master/name:/data/hdfs/name - ./data/master/data:/data/hdfs/data - ./data/master/tmp:/data/hdfs/tmp tty: true slave1: image: hadoop-cluster:3.3.6 container_name: slave1 hostname: slave1 networks: - hadoop-net volumes: - ./data/slave1/data:/data/hdfs/data - ./data/slave1/tmp:/data/hdfs/tmp tty: true slave2: image: hadoop-cluster:3.3.6 container_name: slave2 hostname: slave2 networks: - hadoop-net volumes: - ./data/slave2/data:/data/hdfs/data - ./data/slave2/tmp:/data/hdfs/tmp tty: true networks: hadoop-net: driver: bridgehostname和container_name都设成一样的值,是为了让 Hadoop 配置文件里写的主机名和容器实际的主机名一致,避免出现“配置里写 master,容器里 hostname 是随机串”这种对不上的情况。端口映射只给 master 做,因为 Web UI 和 HDFS 服务入口都在 master 上,从节点不需要对外暴露。
数据卷的挂载路径按节点分开,./data/master/name存 NameNode 元数据,./data/slave1/data存第一个 DataNode 的数据。这样即使把整个 Compose 环境 down 掉再 up,数据依然在,集群状态能延续。
3.2 启动顺序与格式化 NameNode
第一次启动有个关键动作:格式化 NameNode。这个操作只能在集群第一次初始化时做一次,重复格式化会导致 clusterID 不一致,DataNode 无法注册到 NameNode。
启动流程我习惯这样走:先docker compose up -d把三个容器拉起来,然后进入 master 容器执行格式化,再启动 HDFS 和 YARN。
docker compose up -d docker exec -it master bash # 在 master 容器内执行 hdfs namenode -format start-dfs.sh start-yarn.shstart-dfs.sh这个脚本会读取 workers 文件,通过 SSH 登录到 slave1 和 slave2 上启动 DataNode,同时在本地启动 NameNode 和 SecondaryNameNode。start-yarn.sh同理,在 master 启动 ResourceManager,在从节点启动 NodeManager。
提示:如果
start-dfs.sh报“Permission denied (publickey)”,说明 SSH 免密没配好。检查 master 容器的~/.ssh/authorized_keys是否包含自己的公钥,以及从节点的 SSH 服务是否在运行。镜像里已经把密钥固化好了,正常情况下不会出问题,但如果你的基础镜像 SSH 配置有差异,可能需要额外调整sshd_config里的PermitRootLogin和PubkeyAuthentication。
3.3 验证集群状态
启动完成后,验证分三步走。第一步看进程,在 master 上执行jps,应该能看到 NameNode、SecondaryNameNode、ResourceManager;在从节点上执行jps,应该看到 DataNode 和 NodeManager。第二步看 Web UI,浏览器访问http://localhost:9870是 HDFS 的界面,能看到两个 Live DataNode;访问http://localhost:8088是 YARN 的界面,能看到两个 NodeManager 节点。第三步用命令行验证,hdfs dfsadmin -report会输出集群的容量、已用空间和每个 DataNode 的状态。
我踩过的一个坑是:容器起来了,jps也有进程,但 Web UI 里 DataNode 显示为 0。排查下来发现是hadoop.tmp.dir指向的目录权限不对,DataNode 启动时无法写入,进程虽然活着但注册失败。解决办法是确保挂载目录对容器内用户可写,或者在 Dockerfile 里提前chmod好。
4. 常见问题排查与避坑经验
4.1 高频问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| start-dfs.sh 报 SSH 连接失败 | 免密登录未生效 | 进容器手动 ssh slave1 测试,检查 authorized_keys 权限是否为 600 |
| DataNode 启动后立即退出 | 数据目录权限不足或磁盘满 | 查看 DataNode 日志,确认目录可写,清理空间 |
| Web UI 显示 0 个 DataNode | 格式化后 clusterID 不一致 | 删除所有节点的数据目录,重新格式化并重启 |
| NameNode 一直处于安全模式 | 副本数配置大于实际节点数 | 调低 dfs.replication,或执行 hdfs dfsadmin -safemode leave |
| YARN 任务卡在 ACCEPTED | NodeManager 资源不足 | 检查 yarn.nodemanager.resource.memory-mb 配置,适当调大 |
| 容器重启后 HDFS 数据丢失 | 数据目录未挂载到宿主机 | 检查 Compose 文件 volumes 配置,确保路径映射正确 |
4.2 几个容易忽略的细节
第一个是内存分配。Hadoop 默认的 JVM 堆内存配置偏大,三个容器同时跑起来,如果宿主机内存只有 8G,很容易触发 OOM。我一般会在hadoop-env.sh里把HDFS_NAMENODE_OPTS和YARN_RESOURCEMANAGER_OPTS的-Xmx调小到 512M 或 1G,够实验用就行。这个调整在容器环境里尤其重要,因为容器本身还有额外开销。
第二个是时间同步。Hadoop 对节点间时间偏差有一定容忍度,但偏差过大时会出现各种奇怪问题。容器默认继承宿主机时间,一般没问题,但如果你在容器里手动改过时间或者宿主机时间本身不准,记得校准。实验环境里这个通常不是问题,但知道有这回事,排查时能少走弯路。
第三个是workers文件的维护。这个文件在$HADOOP_CONF_DIR下,内容就是从节点主机名,一行一个。新增或删除从节点时,改这个文件然后重新执行start-dfs.sh和start-yarn.sh即可。注意workers文件里不要写 master 自己,否则会在 master 上也启动 DataNode,虽然不报错但不符合“主从分离”的预期拓扑。
4.3 数据持久化与重建策略
实验环境里,我建议把“重建集群”当成常规操作。因为容器方案的最大优势就是可重建,配置改坏了、数据搞乱了,直接docker compose down,删掉数据目录,再up一遍,几分钟就是一个干净的新集群。所以数据目录的挂载路径要规划好,./data下按节点分目录,重建时想保留数据就留着,想彻底重来就整个删掉。
但有一个例外:如果你在验证某个需要保留中间状态的场景,比如 HDFS 的追加写或者 YARN 的任务历史,那就不能随便删数据目录。这时候数据卷的价值就体现出来了,容器生命周期和数据生命周期解耦,容器随便重启,数据安然无恙。
5. 这套环境还能怎么用
集群跑起来只是起点,这套环境真正的价值在于它是个可以随意折腾的沙盒。你可以往 HDFS 里传数据、跑 MapReduce 示例、装 Hive 做 SQL 查询、装 Spark 做内存计算,甚至模拟节点宕机观察 HDFS 的副本恢复行为。因为容器起停成本极低,做故障演练特别方便,docker stop slave1就相当于拔了一台机器的网线,观察 NameNode 如何标记节点失效、如何重新复制副本,比看文档直观得多。
我个人的习惯是把这个 Compose 环境当成一个“分布式系统观察箱”,每次学一个新组件或者新特性,就在里面搭起来跑一遍,跑完就删,不留垃圾。配置文件用 Git 管起来,改了什么、为什么改,都有记录,下次复现或者分享给别人,直接给仓库地址就行。这种工作方式比维护一堆虚拟机快照要清爽得多。
最后分享一个小技巧:如果你觉得每次进容器执行启动脚本麻烦,可以在 master 的 Dockerfile 里把start-dfs.sh && start-yarn.sh && tail -f /dev/null写成启动命令,容器一起来集群就自动就绪。但第一次格式化 NameNode 还是得手动做,所以更稳妥的做法是写一个入口脚本,判断数据目录是否为空,为空就格式化再启动,不为空就直接启动。这个脚本不长,但能省掉每次手动初始化的步骤,适合需要频繁重建环境的场景。