这已经是我服务器搭建系列的第十五篇了。前面咱们把基础容器环境、持久化存储、监控告警和常用中间件都铺了一遍,这次来啃一块硬骨头:在 k8s 上部署一套 Hadoop 集群,而且直接用 2022 年的最新稳定版本。很多朋友学完 k8s 基础之后,下一步就是想把手上的大数据组件搬进去,因为公司里的大数据平台不可能永远跑在一堆裸虚拟机上,运维成本高、扩容困难、资源利用率也上不去。但 Hadoop 是个典型的有状态应用,HDFS 要管数据块、NameNode 要管元数据,YARN 还要做资源调度和任务分配,整套东西搬进容器并没有想象中那么轻松。这篇文章就把我实际操作的完整过程整理出来,包括版本选型、存储规划、StatefulSet 的写法、首次格式化的时机、功能验证方法,以及我踩过的几个坑,适合已经玩熟 k8s 基础、想在容器环境里部署大数据组件的朋友参考。
1. 先想清楚:为什么非要在 k8s 上跑 Hadoop
1.1 传统部署方式到底痛在哪
以前我们搭 Hadoop 集群,流程基本是固定的:找几台物理机或者虚拟机,每台机器装 JDK,配置 SSH 免密登录,手动分发 Hadoop 安装包,然后开始改 core-site.xml、hdfs-site.xml、yarn-site.xml 这一堆配置文件。节点少的时候还好说,一旦超过十台,每台机器改错一个参数,排查起来就是灾难级的体验。更难受的是扩容,新加一个 DataNode 节点,要把整套环境从头到尾再人工走一遍,中间只要有一个配置忘了同步,这个节点就很有可能起不来,或者起来了但数据副本一直处于 unhealthy 状态。
资源利用率也是个现实问题。正常情况下一个 Hadoop 集群是按部门或者按项目划分的,大家各自维护各自的集群,可能在同一个机房里有三套互相隔离的集群,每一套的 CPU 和内存都只有 20% 左右的利用率。我当时就在想,要是能把它们合并到一个资源池里,按需分配就好了,这其实就是 k8s 最擅长的场景。
1.2 容器化带来了什么,又添了什么麻烦
把 Hadoop 搬到 k8s 上,好处是很直接的:资源池化,所有大数据组件和业务应用共享同一个集群,资源可以动态分配;扩容变得非常简单,DataNode 想从 3 个加到 5 个,改一下 StatefulSet 的 replicas 数量就行;镜像把环境全部固化,再也不需要为每台新机器单独配 JDK 和安装包。
但坏处也同样直接。首先,HDFS 的数据是实实在在写在磁盘上的,容器一删数据就没了,所以必须引入 PV/PVC 做持久化。其次,HDFS 和 YARN 对节点的 hostname 非常敏感,DataNode 启动之后会把 hostname 上报给 NameNode,如果每次重建 Pod 之后这个名字都变了,NameNode 里会堆一堆旧的节点记录。最后,NameNode 的元数据目录一旦损坏,整个集群的数据就都读不出来了,这对 PV 的可靠性要求比普通应用高得多。
说白了,容器化解决的是“环境一致”和“资源调度”的问题,但“数据安全”和“网络标识稳定性”这两件事,需要我们自己用 k8s 的资源模型去补上。
2. 方案选型:三条路我为什么选了手动 YAML
2.1 三条主流路线的对比
目前要在 k8s 上部署 Hadoop,常见的有三条路线:直接用 Helm Chart、手动编写 YAML 文件、用现成的 Operator。我在动手之前把这三条路都调研了一遍,各有各的坑。
| 方案 | 上手难度 | 可控性 | 2022年社区成熟度 | 适合场景 |
|---|---|---|---|---|
| Helm Chart | 低 | 中 | 中,部分 Chart 停更多年 | 想跑通测试环境,不想管底层细节 |
| 手动 YAML | 高 | 高 | 完全可控 | 生产落地、想彻底搞懂原理、方便排错 |
| Operator | 中 | 中高 | 低,Hadoop 相关 Operator 还不成熟 | 探索阶段,不建议直接上生产 |
Helm Chart 看着省事,一条helm install就能拉起来,但问题在于很多 Hadoop Chart 还停留在 Hadoop 2.x 时代,镜像老、配置旧,跟 2022 年的版本搭不起来。Operator 的思路很先进,但当时 Hadoop 生态的 Operator 做得还不完善,功能覆盖有限,遇到问题连文档都没得查。所以我最终还是选了手写 YAML。虽然是三条路里最费劲的,但每一行配置都是自己写出来的,出了问题能很快定位,而且这套 YAML 后续是完全可以拿到生产环境继续用的。
2.2 版本组合怎么定
版本选型这块,我直接定了 Hadoop 3.3.x 分支。2022 年的最新稳定版是 3.3.4 和 3.3.5,相比老版本有几个关键变化:NameNode 的 RPC 端口从 8020 换成了 9820,Web UI 端口从 50070 换成了 9870,DataNode 的 Web UI 变成了 9864,并且默认要求 Java 11。这些变化意味着网上大量基于 Hadoop 2.x 的教程不能直接抄,配置参数和端口都得按新版来。
Kubernetes 这边,建议用 1.23 以上的版本,我实际操作的环境是 1.25,StatefulSet、PVC 自动供给这些核心功能都比较稳定了。镜像方面,我是自己基于 Hadoop 发行版打的,标签统一写成 3.3.4 的简略形式。你实际用的时候,直接换成自己私有仓库里的 Hadoop 镜像全名,或者用社区维护的 Hadoop 镜像都可以,核心逻辑是一样的。
2.3 存储规划:PV/PVC 是避不开的第一关
存储是整个部署里最关键的一件事。HDFS 有元数据,有数据块;NameNode 的元数据目录必须单独挂一块可靠的卷,DataNode 的数据目录则要能容纳所有业务数据。我这边环境是本地裸机集群,为了演示方便用了本地类型的存储,生产环境建议换成 NFS 动态供给或者云厂商的云盘存储类,把 PV 的可靠性托底。
我给存储做了两层规划:NameNode 的 PVC 申请 20Gi,DataNode 的 PVC 申请 50Gi,副本数设成 2,这样三个 DataNode 能冗余住一份副本的容量。最关键的是状态信息一定得写在 PV 上,Pod 被删掉、重建、漂移到别的节点,数据都还能找回来。PVC 模板直接写在 StatefulSet 里,每个 Pod 会拿到一个独立的卷,逻辑非常干净。
3. 部署前的环境准备
3.1 集群基础检查清单
开始部署之前,我习惯先把集群的底子检查一遍,省得后面排查的时候分不清是 Hadoop 的问题还是 k8s 的问题。检查项并不多,但每一条都值得过一遍:kubectl get nodes看节点是否 Ready,kubectl get pods -n kube-system看核心组件是否正常,特别是 CoreDNS,因为后面 Hadoop 组件之间全靠集群 DNS 解析服务名;再确认一下集群里有没有默认 StorageClass,没有的话先装一个。
资源方面也给个参考值:NameNode 至少 2 核 4G,DataNode 和 NodeManager 至少各 1 核 2G,整个测试环境我建议不低于三个工作节点,总内存 16G 以上。如果你的机器比较紧张,可以把副本数减少到 1,但至少保证 NameNode 和 ResourceManager 能跑起来。
3.2 创建命名空间
所有资源我统一放在hadoop这个 namespace 里,方便管理,测试完想删的时候一条命令就全清了。配置文件很简单:
apiVersion: v1 kind: Namespace metadata: name: hadoop创建命令:
kubectl apply -f namespace.yaml命名空间用独立的名字还有一个好处:后面需要给大数据团队单独开权限的时候,可以直接用 RBAC 把这个 namespace 授权出去,不会影响其他业务。
3.3 把 Hadoop 配置文件写进 ConfigMap
Hadoop 的所有行为都由那几份 XML 配置控制。容器环境下,不需要把这些文件打进镜像,直接做成 ConfigMap,挂载进 Pod 的指定路径就行。改动配置的时候只需要更新 ConfigMap 再重启 Pod,比传统方式改文件分发快非常多。
我这边的 core-site.xml 核心内容如下,注意 fs.defaultFS 直接指向 NameNode 的 StatefulSet DNS 域名:
<configuration> <property> <name>fs.defaultFS</name> <value>hdfs://hadoop-namenode-0.hadoop-namenode.hadoop.svc.cluster.local:9820</value> </property> </configuration>hdfs-site.xml 里把元数据目录、数据目录、副本数和 Web UI 监听地址都写清楚:
<configuration> <property> <name>dfs.namenode.name.dir</name> <value>/hadoop/dfs/name</value> </property> <property> <name>dfs.datanode.data.dir</name> <value>/hadoop/dfs/data</value> </property> <property> <name>dfs.replication</name> <value>2</value> </property> <property> <name>dfs.namenode.http-address</name> <value>0.0.0.0:9870</value> </property> <property> <name>dfs.datanode.http.address</name> <value>0.0.0.0:9864</value> </property> </configuration>yarn-site.xml 指定 ResourceManager 的主机和 NodeManager 的辅助服务:
<configuration> <property> <name>yarn.resourcemanager.hostname</name> <value>hadoop-resourcemanager-0.hadoop-resourcemanager.hadoop.svc.cluster.local</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> </configuration>mapred-site.xml 也要补上,否则 MapReduce 作业不知道往 YARN 上提交:
<configuration> <property> <name>mapreduce.framework.name</name> <value>yarn</value> </property> </configuration>这些内容全部塞进一个 ConfigMap,部署的时候挂到/etc/hadoop/conf目录下。具体挂载路径跟着镜像走,但大多是/etc/hadoop/conf或者/etc/hadoop。
4. 核心组件部署:从 NameNode 到 NodeManager
4.1 NameNode:StatefulSet + Headless Service
NameNode 是整个 HDFS 的大脑,必须用 StatefulSet。StatefulSet 会给 Pod 一个稳定且可预测的网络标识,比如第一个副本叫hadoop-namenode-0,配合 Headless Service 之后,其他组件可以直接通过hadoop-namenode-0.hadoop-namenode.hadoop.svc.cluster.local这个域名访问它。这个域名在 Pod 重建之后保持稳定,正好满足 Hadoop 对 hostname 敏感的毛病。
先创建一个 Headless Service 用于内部 DNS 解析:
apiVersion: v1 kind: Service metadata: name: hadoop-namenode namespace: hadoop spec: clusterIP: None selector: app: hadoop component: namenode ports: - port: 9820 name: rpc - port: 9870 name: http再创建一个 NodePort 类型的 Service,把 Web UI 暴露出来方便调试:
apiVersion: v1 kind: Service metadata: name: hadoop-namenode-ui namespace: hadoop spec: type: NodePort selector: app: hadoop component: namenode ports: - port: 9870 targetPort: 9870 nodePort: 30870NameNode 的 StatefulSet 长这样:
apiVersion: apps/v1 kind: StatefulSet metadata: name: hadoop-namenode namespace: hadoop spec: serviceName: hadoop-namenode replicas: 1 selector: matchLabels: app: hadoop component: namenode template: metadata: labels: app: hadoop component: namenode spec: containers: - name: namenode image: hadoop-namenode:3.3.4 ports: - containerPort: 9820 name: rpc - containerPort: 9870 name: http volumeMounts: - name: nn-data mountPath: /hadoop/dfs resources: requests: cpu: 500m memory: 2Gi limits: cpu: "2" memory: 4Gi volumeClaimTemplates: - metadata: name: nn-data spec: accessModes: [ "ReadWriteOnce" ] storageClassName: hadoop-local resources: requests: storage: 20Gi这里有个点要特别说:serviceName必须和 Headless Service 的名字一致,StatefulSet 才认得这个 Service,Pod 才会有稳定的 DNS 记录。PVC 模板volumeClaimTemplates会给每个副本自动创建独立卷,NameNode 的元数据就落到这个卷里,Pod 删了重建数据还在。
4.2 DataNode:网络标识稳定性是关键
DataNode 同样用 StatefulSet,副本数我设了 3。每个 DataNode 会向 NameNode 注册自己的 hostname,注册信息会存在 NameNode 的内存和元数据里。如果 DataNode 的 Pod 重启之后 hostname 变了,NameNode 里就会残留旧的节点记录,虽然 DataNode 会重新注册,但中间这段时间集群状态会非常难看。
k8s 里 Pod 的 hostname 默认就是 Pod 名,StatefulSet 保证了这个名字的稳定性:hadoop-datanode-0、hadoop-datanode-1、hadoop-datanode-2。所以 DataNode 的 StatefulSet 写起来简洁很多:
apiVersion: apps/v1 kind: StatefulSet metadata: name: hadoop-datanode namespace: hadoop spec: serviceName: hadoop-datanode replicas: 3 selector: matchLabels: app: hadoop component: datanode template: metadata: labels: app: hadoop component: datanode spec: containers: - name: datanode image: hadoop-datanode:3.3.4 ports: - containerPort: 9864 name: http volumeMounts: - name: dn-data mountPath: /hadoop/dfs/data resources: requests: cpu: 500m memory: 1Gi limits: cpu: "2" memory: 2Gi volumeClaimTemplates: - metadata: name: dn-data spec: accessModes: [ "ReadWriteOnce" ] storageClassName: hadoop-local resources: requests: storage: 50Gi同样配一个 Headless Service:
apiVersion: v1 kind: Service metadata: name: hadoop-datanode namespace: hadoop spec: clusterIP: None selector: app: hadoop component: datanode ports: - port: 9864 name: http这里要提醒一句:DataNode 向 NameNode 注册时上报的主机名必须能被 NameNode 解析。因为大家都在同一个 namespace,走的又是集群 DNS,所以hadoop-datanode-0.hadoop-datanode.hadoop.svc.cluster.local天然可以解析。如果你把 DataNode 放在别的 namespace,或者用了不同的 serviceName,记得把解析关系打通,否则数据块副本的状态永远是异常的。
4.3 ResourceManager 和 NodeManager
YARN 这部分和 HDFS 是平行的。ResourceManager 负责集群资源调度和作业管理,NodeManager 负责单节点上的任务执行。ResourceManager 我用了单副本 StatefulSet,生产环境如果做高可用,可以增加副本并配合 ZooKeeper,但这次先不展开。
ResourceManager 的 StatefulSet 和 NameNode 非常像,监听端口是 8088:
apiVersion: apps/v1 kind: StatefulSet metadata: name: hadoop-resourcemanager namespace: hadoop spec: serviceName: hadoop-resourcemanager replicas: 1 selector: matchLabels: app: hadoop component: resourcemanager template: metadata: labels: app: hadoop component: resourcemanager spec: containers: - name: resourcemanager image: hadoop-resourcemanager:3.3.4 ports: - containerPort: 8088 name: http resources: requests: cpu: 500m memory: 2Gi limits: cpu: "2" memory: 4GiNodeManager 和 DataNode 一样,需要跟着节点的资源情况分布,我这边直接和 DataNode 同数量部署,也用了 3 副本:
apiVersion: apps/v1 kind: StatefulSet metadata: name: hadoop-nodemanager namespace: hadoop spec: serviceName: hadoop-nodemanager replicas: 3 selector: matchLabels: app: hadoop component: nodemanager template: metadata: labels: app: hadoop component: nodemanager spec: containers: - name: nodemanager image: hadoop-nodemanager:3.3.4 ports: - containerPort: 8042 name: http resources: requests: cpu: 500m memory: 1Gi limits: cpu: "2" memory: 2GiResourceManager 的 Web UI 也开一个 NodePort,方便从外部浏览器访问:
apiVersion: v1 kind: Service metadata: name: hadoop-resourcemanager-ui namespace: hadoop spec: type: NodePort selector: app: hadoop component: resourcemanager ports: - port: 8088 targetPort: 8088 nodePort: 308884.4 初始化格式化:顺序错了全白搭
整套集群部署完,还不能直接用,NameNode 必须先做一次格式化,生成初始的元数据。这个操作在传统部署里是在配置好环境之后手动执行的,容器化之后同样绕不开。
我的操作顺序是这样的:先把 NameNode 的 StatefulSet 部署起来,确认 Pod 进入 Running 状态(这时候进程可能反复重启,因为元数据目录是空的),然后进入 Pod 执行格式化:
kubectl exec -it -n hadoop hadoop-namenode-0 -- hdfs namenode -format -force格式化完成后重启 NameNode Pod:
kubectl delete pod -n hadoop hadoop-namenode-0等 NameNode 稳定运行后,再部署 DataNode、ResourceManager 和 NodeManager。这个顺序是有讲究的:DataNode 启动时就要向 NameNode 注册,如果 NameNode 还没就绪,DataNode 会一直报连接失败然后不断重试。虽然现代版本的 Hadoop 会在 NameNode 恢复后自动重连,但调度顺序上先保证 NameNode 起来,整体启动会顺利很多。
有一个坑必须重点说:格式化操作非常危险,它会清空 NameNode 元数据目录里的所有内容。如果集群已经有一段时间、里面存了真实数据,千万别手贱执行这个命令。格式化只应该发生在全新的、确认没有业务数据的集群上。我这个是测试环境,所以可以放心操作。
5. 验证跑通:Web UI 和第一个 MapReduce 作业
5.1 先看 Pod 状态和日志
所有组件部署完之后,第一步先看整体状态:
kubectl get pods -n hadoop -o wide正常情况下的输出应该是六个 Running:一个hadoop-namenode-0、三个hadoop-datanode-*、一个hadoop-resourcemanager-0、三个hadoop-nodemanager-*。如果你看到CrashLoopBackOff或者ContainerCreating,先别急着查 Hadoop,优先看事件里有没有 PVC 绑定失败、镜像拉取失败或者资源不足的提示。
日志排查也很有用,比如想看 NameNode 是否正常完成启动:
kubectl logs -n hadoop -f hadoop-namenode-0正常会看到类似NameNode up at的日志,同时会打印监听地址和端口。如果日志里出现和集群 DNS 解析相关的报错,那大概率是 ConfigMap 里写的域名不对,或者 Headless Service 没有配好。
5.2 HDFS 命令行验证
Pod 状态正常不等于 HDFS 可用,还要用命令行做深度验证。进入 NameNode Pod 查看整个文件系统的报告:
kubectl exec -it -n hadoop hadoop-namenode-0 -- hdfs dfsadmin -report重点看输出里的几个关键信息:Configured Capacity应该是三个 DataNode 的存储总和,Live datanodes数量应该是 3,如果这里显示0,说明 DataNode 注册有问题,大概率是 hostname 解析或者端口问题。DFS Used在刚格式化完应该是很小的值。
接下来做几个基本操作,确认读写链路是通的:
kubectl exec -it -n hadoop hadoop-namenode-0 -- hdfs dfs -mkdir -p /test kubectl exec -it -n hadoop hadoop-namenode-0 -- hdfs dfs -put /etc/hadoop/conf/core-site.xml /test/ kubectl exec -it -n hadoop hadoop-namenode-0 -- hdfs dfs -cat /test/core-site.xml能正常创建目录、上传文件、读取文件,说明 HDFS 的主链路没问题。顺手再看一眼副本状态:
kubectl exec -it -n hadoop hadoop-namenode-0 -- hdfs fsck /test -files -blocks输出里Replicated的Expected replica count和Actual replica count应该和dfs.replication一致,出现MISSING字眼就得留意了。
5.3 跑一个 Pi 程序
HDFS 通了,还要验证 YARN 能不能调度作业。最简单的方式是跑 Hadoop 自带的 MapReduce 示例程序,算圆周率。这个程序会真正向 ResourceManager 提交一个任务,然后由 NodeManager 申请容器执行,能完整验证资源调度链路。
kubectl exec -it -n hadoop hadoop-resourcemanager-0 -- yarn jar /opt/hadoop/share/hadoop/mapreduce/hadoop-mapreduce-examples-3.3.4.jar pi 4 1000如果一切正常,最后会输出Estimated value of PI这样一行结果。运行过程中可以打开 ResourceManager 的 Web UI,地址是http://任意节点IP:30888,能看到作业从ACCEPTED到RUNNING再到FINISHED的完整状态变化。第一次跑 MapReduce 作业通常会慢一些,因为涉及多个容器启动和 JVM 拉起,耐心等就行。
跑完 Pi 之后,建议再放一个稍大一点的数据做 wordcount 验证,更能说明问题。我平时习惯用hadoop-mapreduce-examples-3.3.4.jar wordcount跑一段真实文本,看看 Map 和 Reduce 的进度曲线,确认多个 NodeManager 都能参与任务。
6. 问题排查实录:我踩过的坑
6.1 常见问题速查表
整理一下我这次部署过程中遇到的和身边朋友常遇到的问题,直接做成速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
Pod 一直ContainerCreating | PVC 无法绑定、存储类不存在 | kubectl describe pvc -n hadoop看事件 |
PodCrashLoopBackOff | 配置挂载路径不对、NameNode 没格式化 | kubectl logs -n hadoop <pod>看具体报错 |
| HDFS Web UI 打不开 | NodePort 被占用、防火墙未放行 | 检查kubectl get svc -n hadoop的端口映射 |
dfsadmin -report显示无存活 DataNode | DataNode 与 NameNode 网络不通、hostname 解析失败 | 在 NameNode 里ping hadoop-datanode-0.hadoop-datanode.hadoop.svc.cluster.local |
作业提交后一直ACCEPTED不执行 | YARN 没有可用 NodeManager、容器资源不足 | 打开 RM Web UI 看节点列表 |
| NameNode 反复重启 | 元数据目录没落在 PV 上 | 检查 volumeMounts 和 PVC 是否绑定成功 |
| 上传文件提示空间不足 | DataNode 存储卷容量不够 | 扩容 PVC 或调整副本数释放空间 |
6.2 两个印象最深的实战案例
第一个坑是 DataNode 全部注册失败。当时我把 ConfigMap 里的fs.defaultFS写成了hdfs://hadoop-namenode:9820,用的是普通 Service 名而不是 StatefulSet 的带序号域名。普通 Service 在解析上没问题,但 NameNode 的 hostname 是hadoop-namenode-0而不是hadoop-namenode,两者在 HDFS 内部逻辑里是有区别的。DataNode 启动后拿着hadoop-namenode去找 NameNode,注册请求发过去了,但 NameNode 处理的时候会用来自身份校验,结果一直不认。后来我把所有配置统一改成带序号的完整域名,重启所有组件,问题就解决了。这里的关键点是:StatefulSet 的serviceName负责 DNS 解析,但真正稳定的主机名是<pod-name>.<service-name>,配置里要用这个完整域名。
第二个坑是内存限制设置太小导致 NodeManager 频繁被杀。我一开始图省事,给 NodeManager 的limits.memory只写了 1Gi,结果每次跑作业的时候,JVM 一触发 Full GC 就被 cgroup 的 OOM Killer 干掉,日志里全是Killed字样。查了半天才发现是 YARN 在启动 Container 时申请的内存超过了 Pod 的 limit。后来我把 NodeManager 的limits.memory调到了 2Gi,同时在 yarn-site.xml 里把yarn.nodemanager.resource.memory-mb配成对应的值,让它知道自己最多能用多少内存,情况才稳定下来。容器环境里尤其要注意:YARN 的资源模型和 cgroup 限制必须对齐,否则即使容器没死,任务也会因为申请资源被拒而一直失败。
最后再分享一个操作习惯。整套集群部署完,我会把核心 YAML 文件统一归档到一个目录里,用kubectl apply -f管理变更。Hadoop 组件不像无状态应用,改完配置一定要记住 PV 里的元数据是资产而不是垃圾,不要随手把 namespace 整个删掉。我见过有人测试完直接kubectl delete ns hadoop,结果 PV 里的数据被清理策略一并删了,哭都来不及。删环境之前先确认数据是否需要保留,或者干脆手动把 StatefulSet 和 Pod 删掉、保留 PVC,给自己留一条后悔的余地。这些看起来都是小事,但踩过一次之后,就知道在大数据组件的容器化运维里,谨慎这两个字值多少钱。