☰
K8s中JupyterHub无响应排查:从资源调度到WebSocket链路
2026/9/30 8:03:05 网站建设 项目流程

1. 先把"无响应"拆清楚:是登录卡、启动卡,还是 Kernel 卡

我接手过的 JupyterHub on K8s 项目,用户报"jupyter 总是无响应"这句话的频率,高到我已经条件反射了。但说实话,"无响应"这三个字是最没信息量的描述——它可能指登录页打不开,可能指点完 Start My Server 之后一直转圈,也可能指笔记本页面打开了但 Python Kernel 压根连不上。这三种情况的排查方向完全不一样,如果上来就乱查一气,很容易在错误的方向上耗掉半天。

1.1 三种典型的"无响应",一定要先分清楚

我一般会让用户或者自己先去复现,然后快速归类:

  • 登录页都刷不出来:浏览器访问 Hub 外部地址时直接超时、502、504 或者持续 loading。问题大概率在 Ingress、Hub Proxy 或者 Hub Pod 本身。
  • 能登录,但"Start My Server"之后卡住:页面一直停在等待服务器启动的状态,过几分钟报错或者一直转圈。问题大概率在调度、镜像拉取、PVC 挂载或者 Spawner 配置。
  • 笔记本页面能打开,但 Kernel 连不上:代码格子一直显示 Connecting,或者 Kernel 反复重启。问题大概率在 WebSocket 代理链路、Token 传递、或者单用户容器内部的 Jupyter 进程。

为什么要分这么细?因为这三类问题对应的排查工具完全不同。第一类主要看 Ingress Controller 的日志和 Hub Pod 状态;第二类主要看kubectl describe pod的事件、节点资源、StorageClass;第三类要抓 Hub Proxy 和浏览器之间的网络请求,甚至要进到单用户 Pod 里去看 Jupyter 进程状态。

我遇到过最离谱的一次,用户说"总是无响应",结果查下来是他浏览器里开了十几个长时间不关的标签页,JupyterHub 的闲置回收机制(Cull)把会话回收了,他再切回去点一下以为卡死,其实是要重新 Spawn 一个新 Pod。这类"假无响应"在团队里太常见了,所以第一步永远是复现和分类,而不是猜。

1.2 第一轮必查的几个命令,先给结论

如果你也遇到同样的问题,我建议第一轮排查就固定用这套组合拳,五分钟内能确定大方向:

kubectl get pods -n <namespace> -o wide kubectl get events -n <namespace> --sort-by='.lastTimestamp' | tail -50 kubectl top nodes kubectl top pods -n <namespace>

先看 Pod 状态,再看事件,再看资源占用。这四步做完,"无响应"基本能收敛到两个方向:资源调度问题,或者网络链路问题。如果所有 Pod 都是 Running 而且资源也不紧张,那就往网络和 Jupyter 进程层走。如果看到 Pod 是 Pending、CrashLoopBackOff、OOMKilled,那资源层基本跑不掉。

接下来我会把这几种情况逐个拆开讲,包括我踩过的坑和最终验证有效的修复方案。

2. 资源配额与调度层:最常见、也最容易被忽略的"假死"

JupyterHub on K8s 和普通 Web 服务最大的不同是:用户每次启动 Notebook,实际上是调度一个全新的 Pod。这个 Pod 能不能被调度,完全取决于集群当时的资源水位。很多"无响应"的根子就在这里。

2.1 Pod 一直 Pending:不是系统坏了,是资源真不够

在 K8s 里,Pending 意味着 Pod 压根没被调度到任何节点上。你可以用kubectl describe pod <pod名>看 Events,通常会有一行0/5 nodes are available: 1 Insufficient cpu, 4 Insufficient memory之类的话。这行字翻译过来就是:集群已经没有能满足请求的节点了。

但这里有个特别隐蔽的坑——Jupyter 单用户 Pod 的资源请求往往被配置文件里的默认值卡死。

比如 JupyterHub 的 Helm Chart 里,默认的singleuser.profileList通常会为每个 profile 指定 requests 和 limits。很多团队初始配置时 requests 设得很大,比如 CPU 直接 request 2 核、内存 request 4Gi。一个节点总共才 16 核 64G,四五个用户同时启动,节点就满了。后来的用户点 Start 就永远转圈。

更麻烦的是,用户已经占了资源长期不释放,Cull(闲置回收)又没开或者没配好,于是集群资源被"僵尸会话"吃光,新用户全部进不来。从用户视角看就是"Jupyter 总是无响应",从平台视角看是"资源红线和 Cull 策略没设计好"。

我的建议是:requests 不要按"用户理想规格"设,要按"最小可用规格"设。比如一个 profile 标称 4C/8G,requests 可以只设 0.5C/1G,limits 设 4C/8G。这样 K8s 调度时只看 requests,资源利用率会高很多;真正跑起来之后再按 limits 限制它不要吃光节点。坏处是超售风险,但如果你们是内部平台,超售的收益远大于代价。

2.2 内存限制太小,Kernel 反复 OOMKilled

如果说 Pending 是"进程起不来",那 OOMKilled 就是"进程起来又被打死"。用户打开 Notebook 跑了一段数据处理,内存涨到 limit 就触发 OOM,K8s 直接干掉容器。Pod 进入 CrashLoopBackOff,页面表现为 Notebook 无响应、Kernel 反复断连。

有一次用户反馈"跑 pandas 就死",我看着 Pod 状态确实是反复重启。查kubectl describe pod看到 Last State 是OOMKilled。再往前翻,他的 profile 内存 limit 设了 2Gi,但数据加载后 pandas 吃 2.5G 左右,直接触顶。

这个问题的修复不是简单加内存,而是要做两件事:

  1. 合理地设置 limits:根据用户实际负载评估,不要随手写个 1G 就上线。Jupyter 加 numpy/pandas 这类数据栈,2G 是起步,4G 算舒服,深度学习相关至少得 8G 起。
  2. 区分 requests 和 limits 的语义:requests 决定调度和 QoS 等级,limits 决定硬上限。如果两者相等,Pod 属于 Guaranteed QoS,不会被轻易驱逐,但资源浪费也最严重。如果不想让内核被频繁杀掉,建议 requests 设得保守一点、limits 设得宽裕一点。

另外别忘了看节点层的驱逐压力。如果节点内存使用率逼近 90%,kubelet 会根据 QoS 等级开始驱逐 Pod。表现同样是 Notebook 突然断连或者无法操作。看kubectl describe node的 Conditions 部分,如果有MemoryPressure,说明节点已经进入驱逐警戒线了。

2.3 GPU 请求的坑:不是所有"无响应"都能靠重启解决

很多人部署 JupyterHub 是为了 GPU 计算,但 GPU 的"无响应"往往比 CPU 场景更莫名其妙。常见的情况是:

  • Pod 一直 Pending,因为节点上有 GPU 资源但没有对应的调度器扩展,或者驱动版本装错了,nvidia.com/gpu这个资源根本没有被上报。
  • Pod Running 了,但 Jupyter 里面torch.cuda.is_available()返回 False,或者直接报 CUDA error,用户感觉像卡死。

这类问题我建议第一眼就看节点标签和资源量:

kubectl get nodes -o json | jq '.items[].status.allocatable' kubectl describe node <node名称> | grep -A 10 "Allocated resources"

如果 allocatable 里根本没有nvidia.com/gpu,说明 Nvidia Device Plugin 没装好;如果 GPU 显示分配出去了但 Pod 内不可用,多半是 driver 和 runtime 类的问题。还有一个小坑,镜像里 CUDA 版本和节点驱动不匹配,PyTorch 加载时直接段错误崩溃,表现也是 Kernel 反复重启。

经验之谈:GPU 问题不要盯 JupyterHub 本身的日志,优先看 Kubelet 日志和 device plugin 日志。journalctl -u kubelet -f在调试 GPU 调度问题时间能省一大半。

3. 从浏览器到 Pod 的整条网络链路:一半的"无响应"藏在这里

资源层没问题、Pod 状态正常,但用户还是说无响应——这时候我建议立刻把注意力转移到网络链路上。JupyterHub 的通信方式和普通网站差别很大,它重度依赖 WebSocket 来维持 Kernel 交互,而且请求要穿透多层代理。任何一个环节掉链子,用户感知到的就是"卡死"。

3.1 一条请求到底走了哪条路

规范部署的 JupyterHub on K8s,从用户浏览器到单用户 Pod,典型链路是:

浏览器 → Ingress Controller(Nginx / Traefik) → JupyterHub Proxy(configurable-http-proxy,以 Pod 形式跑在集群里) → Hub Pod(管理登录、鉴权、spawn) → 单用户 Pod(jupyter singleuser 进程)

这条链路上有两种主要流量:一种是 HTTP 请求,比如打开页面、获取文件列表;另一种是 WebSocket 连接,Kernel 的 stdout/stderr 回传就是通过 WebSocket 实时推的。

WebSocket 有一个特点,连接建立起来后如果不定期传输数据,中间代理层的 idle timeout 可能会把它断掉。Kernel 和前端之间的心跳如果没配好或者代理层没有继承长连接,用户操作到一半页面就失去响应了。

3.2 Nginx Ingress 的超时与缓冲配置:默认值就是为普通 Web 设计的

如果你的 Ingress 用的是 ingress-nginx,那默认的proxy-read-timeout是 60 秒。意味着如果 Kernel 超过 60 秒没有任何输出(比如长时间计算的中间没有打印),Nginx 会认为连接已经死掉,直接断掉 WebSocket。用户从页面看就是"运行一个很久的任务就再也点不动了"。

Jupyter 场景必须显式地把这些参数调大:

nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" nginx.ingress.kubernetes.io/proxy-connect-timeout: "30" nginx.ingress.kubernetes.io/proxy-body-size: "0" nginx.ingress.kubernetes.io/enable-websocket: "true"

proxy-read-timeout 3600的意思是允许连接空闲 1 小时,对 Jupyter 场景比较合理。如果你的用户会跑特别久的静默任务,甚至可以更长,比如 7200 或 86400。还有proxy-body-size: "0"是取消上传大小限制,Jupyter 里经常要上传数据集,默认 1m 的限制会让上传直接失败,表现也是"无响应"。

我用过一个 traefik 做 Ingress 的场景,它也有类似的超时配置,叫traefik.ingress.kubernetes.io/service.serversscheme: h2c之类,细节不同但原理一样——必须允许长连接和 WebSocket 穿透。

3.3 集群 DNS 与 Service 解析问题

另一条隐蔽的线路是 Hub Proxy 到单用户 Pod 的通信。JupyterHub 的 Proxy 在 spawn 单用户 Pod 之后,会通过 Service 或者直接 Pod IP 去转发流量。如果集群 DNS(CoreDNS)出问题,或者 Service 的 Endpoint 没有更新,页面也会表现为无响应。

我之前遇到过一次"部分用户无响应"的奇葩故障:翻 Hub 日志看到大量upstream connect error,但 Pod 都是 Running。最后发现是单用户 Pod 在节点间迁移后,旧的 Endpoint 没有及时清理,流量打到已经不存在的 Pod IP 上。这类问题用kubectl get endpoints -n <namespace>定期看一下 Pod IP 和 Endpoint 是否对齐就能提前察觉。

另外一个很常见的 DNS 问题是用户在单用户容器内通过服务名访问其他服务时解析超时。如果kubelet的resolvConf配置不对,或者节点/etc/resolv.conf里的ndots:5导致大量 DNS 查询,API 交互就会频繁超时。表现非常像"网络卡了"。

补充一个判断工具:当页面无响应时,打开浏览器开发者工具(F12),看 Network 面板里请求的状态和耗时。如果 WebSocket 连接显示一直 pending 然后失败,那问题一定在代理层;如果 HTTP 请求能返回但 kernel 状态一直是 connecting,那问题就往 Jupyter 进程和 Token 方向查。

4. 单用户容器与 Jupyter 进程层:表象是卡死,本质是误用

网络链路查干净之后,下一个重灾区是单用户容器本身。JupyterHub 的哲学是"一个用户一个容器",但这个容器内部的复杂程度远超普通 Web 容器——里面有 Jupyter Server、Kernel 进程、各种扩展,还有可能挂载了大容量存储。任何一个内部组件出问题,用户的体验都是"无响应"。

4.1 镜像过大、启动脚本卡住:现象是"永远启动中"

现在公开的 Jupyter 深度学习镜像越来越大,动辄十几个 GB。Spawning 一个 Pod 时 K8s 需要先把镜像 Pull 到节点上。如果节点网络带宽不够,或者镜像仓库(Registry)限速,Pull 一个镜像可能要十几分钟。但 JupyterHub 前端默认给的 Spawn 等待时间是有限制的,超过时限用户看到的就是重启或者一直转圈。

排查这类问题,直接看 Pod 事件:

kubectl describe pod <单用户Pod名> | grep -A 10 "Events"

如果事件里反复出现Failed to pull image或Pulling image,那基本可以确定是镜像拉取慢。有人会选择加大 Hub 层面的 Spawn 超时,比如在values.yaml里调singleuser.startTimeout,这能缓解用户体验,但治标不治本。

更值得做的是精简镜像。拿jupyter/scipy-notebook这类官方镜像做底,自己打包必要的依赖,比直接拉一个 20GB 的全家桶镜像要明智得多。我在实际项目里把镜像从 18GB 砍到 4GB 之后,Spawn 时间从十几分钟降到两分钟,用户满意度立刻不一样了。

还有一类情况是容器启动了但内部脚本在卡住。比如镜像里自定义了start.sh,脚本里有网络请求或者等待其他服务的阻塞调用,或者执行了高延迟的文件挂载检查。这类问题用kubectl logs -f <pod>看启动日志立刻能发现——日志停在某个位置不动,就是卡在那个环节。

4.2 Jupyter 进程挂起、Token 不匹配:过河拆桥式的坑

另一个很典型的"无响应"是:Pod Running、页面也加载出来了,但就是连不上 Kernel。这时候最值得怀疑的是Token 失配。

JupyterHub spawn 单用户容器时,会通过环境变量或者命令行参数告诉容器:你该以哪个用户身份启动、Server 默认端口是多少、和 Hub 通信的认证 Token 是什么。如果镜像里的jupyterhub-singleuser启动命令没有正确接收到这些参数,或者镜像里自己改装过的 Jupyter Server 版本和 Hub 要求的协议不匹配,就会出现"页面起来了但互相不认"。

排查方法:

kubectl exec -it <pod> -- bash ps aux | grep jupyter echo $JUPYTERHUB_API_TOKEN echo $JUPYTERHUB_SERVER_USER

如果这些环境变量为空,说明镜像入口脚本覆盖了 JupyterHub 的标准环境变量,这是自定义镜像时特别容易犯的错误。另一种情况是容器里同时跑了多个 Jupyter 实例,端口冲突导致 Hub 连到错误的实例。

另外还要提一个 Jupyter 老版本特有的毛病——冷启动慢。旧版 Notebook(6.x)和 JupyterLab(3.x 之前)在某些文件系统上启动时会扫描目录、解析文件树,如果用户目录里塞了几万个文件,启动加载可能需要几分钟。用户感觉像是打不开页面,其实是 Jupyter 在加载用户文件。新版 JupyterLab 4 在文件加载方面优化了不少,但目录巨大依然会慢。

4.3 存储卷和文件系统问题:慢到像卡死

Jupyter 的场景几乎必然挂载存储卷,不然用户代码没法持久化。但存储卷恰恰是"伪无响应"的重灾区。

我遇到过一次用户报告所有写操作都卡顿,点保存要等十几秒,Kernel 也频繁中断。最后查到是挂在一个性能很差的 NFS 存储上,而且 StorageClass 配置的还是 NFS 的默认参数。K8s 调度时 PVC 挂载成功不代表性能能满足 Jupyter 的 IO 要求,NFS 的读写延迟如果在几十毫秒以上,Jupyter 的交互体验就会非常糟糕。

更严重的一种是 PVC 挂载直接挂不上。事件里会出现FailedMount,Pod 一直 ContainerCreating,用户等半天永远无响应。这种情况一般是 StorageClass 的 provisioner 配置问题,或者存储后端磁盘容量满了。注意看节点的挂载状态:

kubectl describe pod <pod> | grep -A 20 "Events"

另外,如果多用户共享同一个 NFS 目录,还要注意文件锁问题。Jupyter 会写.jupyter目录下的锁文件,多个进程同时操作时可能死锁。把每个用户的 home 目录隔离到独立子路径是基本要求。

5. 三次真实排障实录:从现象到结论的完整链路

讲完理论,我来分享几个实际项目里遇到的案例。这些案例都来自我真实参与过的 JupyterHub on K8s 运维,有时候问题很简单,有时候排查过程绕了一些弯路,希望这些弯路你能绕着走。

5.1 案例一:所有 Pod 都 Running,但页面就是转圈

那是一个内部数据平台,部署一套 JupyterHub,某天下午开始,陆续有用户说"打开 notebook 就转圈,什么也跑不了"。我第一时间看 Pod 状态,所有单用户 Pod 都是 Running,资源也正常。这就排除掉了调度和 OOM 的方向。

然后我看 Hub Proxy 的日志,发现大量这样的记录:

[INFO] Proxying http://hub-pod:8081 to http://10.244.x.x:8888 [WARN] Error opening websocket: dial tcp 10.244.x.x:8888: connect: connection refused

连接被拒绝,说明 Proxy 到单用户 Pod 的 8888 端口有问题。但 Pod 是 Running,进程也应该在。这里的关键点是,10.244.x.x是 Pod 的旧 IP。翻看单用户 Pod 的创建时间,发现它经历过一次重启,IP 变了,但 Proxy 内部的转发表没有正确清理。

再往里挖,这是 K8s Service 的 Endpoint 更新延迟导致的,在节点负载高或 network policy 复杂的环境里更容易出现。最终修复是给 JupyterHub Proxy 所在的 Deployment 增加了更合理的 readiness 探针,并且在 Node 变化时滚动重启 Proxy。这个问题在社区里也见过不少,很多人以为是 JupyterHub 的问题,其实是 K8s 层的 endpoint 收敛问题。

5.2 案例二:Kernel 动不动断线,用户要疯了

另一个项目是长期跑的集群,用户反馈 Kernel 每十几分钟就断一次,但页面本身还能交互。这个表现非常典型——页面正常、Kernel 断,九成是 WebSocket 或 Cull 的问题。

我先查了 Ingress 的超时配置,已经是proxy-read-timeout: 3600,排除。然后查 JupyterHub 的配置,发现启用了 Cull 功能,但参数是这样配的:

cull: enabled: true timeout: 600 every: 30

timeout: 600意味着空闲 600 秒(10分钟)后会话被回收。如果用户跑了一个长时间计算任务,中间 10 分钟没有和前端交互,Cull 会认为它已经闲置,直接把单用户 Pod 杀掉。从用户角度看就是"怎么突然就断线了"。

Cull 的意图是回收资源,但对 Jupyter 用户来说,"页面没操作"并不等于"任务没在跑"。修复方案是把 timeout 调到足够大(比如 3600 秒),并且设置成"仅在有交互活动时才计算闲置时间",避免误杀正在执行的长任务。此外,我还加了一个告警,当 Cull 回收的 Pod 数量突增时通知运维,防止这类问题再次偷偷发生。

5.3 案例三:GPU 节点无响应,大家第一反应是重启

还有一个 GPU 集群的案例。用户反馈在 GPU 节点上跑训练时,Notebook 会随机卡死,过几分钟或者重启之后又能继续。因为 GPU 环境复杂,排障过程经常是"重启大法"先行。

我这次没有急着重启,先看了单用户 Pod 的日志,发现 Kernel 启动阶段有一段报错:

terminate called after throwing an instance of 'c10::Error' CUDA error: device-side assert triggered

这其实是 PyTorch 在 GPU 上的一个常见崩溃——比如张量维度越界或者显存地址访问非法,CUDA 核函数触发了 device-side assert,进程直接异常退出。这种问题不是 K8s 的锅,是代码里的越界访问导致 CUDA 上下文被污染,进程只能重启。

但这里有一个值得注意的运维经验:如果在容器内使用了进程内无法恢复的 CUDA context,最好的做法是让 K8s 杀掉容器并重启;但如果只是偶发的显存不足(OOM on GPU),进程本身是可以恢复的,不该被误杀。所以我把 JupyterHub 配置里的singleuser.events做了细化,对 GPU 类的崩溃事件单独处理,减少用户任务的无意义中断。

实际的处理方案是:给单用户容器显式设置NVIDIA_VISIBLE_DEVICES环境变量,确保每个 Pod 分配到的 GPU 设备是隔离的,避免显存资源互相干扰;同时限制单用户 Pod 的 GPU 显存请求量小于节点总量,避免一卡多占、互相驱逐。

6. 让 JupyterHub 稳定"不抽风"的日常运维习惯

排查是一时的,稳定才是长期的。我在多个 JupyterHub on K8s 项目里折腾了这么久,渐渐总结出几个已经变成习惯的运维动作。这些不一定多高级,但确实能帮你避开绝大多数"无响应"问题。

6.1 一份经过实战检验的 Helm values 要点

如果你是用 Helm Chart 部署的 JupyterHub,建议把这些点都过一遍:

singleuser: defaultUrl: "/lab" image: name: jupyter/scipy-notebook tag: latest cpu: limit: 4 guarantee: 0.5 memory: limit: 8G guarantee: 1G storage: capacity: 20Gi startTimeout: 600 events: true cull: enabled: true timeout: 3600 every: 30 ingress: enabled: true hosts: - jupyter.example.com annotations: nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" nginx.ingress.kubernetes.io/proxy-body-size: "0" nginx.ingress.kubernetes.io/enable-websocket: "true"

这里几个重点:cpu.guarantee设很小(0.5 核),cpu.limit设大(4 核),保证调度容易、运行有上限;memory.guarantee至少 1G,防止内存太小连 Jupyter 界面都跑不动;startTimeout建议 600 秒,给镜像拉取留足时间;Cull 的timeout宁可大不要小。

6.2 监控什么、怎么告警

没有监控的 JupyterHub 就像没仪表盘的飞机。我的建议是至少监控这几类指标:

  • Pod 状态变化率:如果单用户 Pod 频繁重启或频繁 Cull,用户一定在骂人。
  • Spawn 耗时:单用户 Pod 从创建到 Ready 的平均时长。超过 5 分钟就该告警,说明镜像太大或资源紧张。
  • 节点内存和磁盘压力:kubectl top nodes定期采集,MemoryPressure 或 DiskPressure 事件要第一时间通知。
  • Ingress Controller 的 5xx(包括 502/504)比例:这个指标能直接捕捉到 Proxy 链路异常。
  • Hub 自身进程的 CPU/内存:Hub Pod 如果 OOM,整个平台直接挂掉,比单用户无响应严重得多。

有了 Prometheus + Grafana 以后,把告警拉到钉钉或企业微信群里,至少能在用户报障前提前察觉到异常。

6.3 升级、变更之间的红线

最后提醒几个我在变更时踩过的坑:

第一,升级 JupyterHub 之前一定要先在一个测试命名空间里跑一遍 smoke test——登录、启动 Notebook、跑一个简单计算、保存文件,全通过再上生产。有一次我只改了镜像 tag 没看兼容性,新镜像里 JupyterLab 版本升级后和旧的 jupyterhub-singleuser 协议不匹配,所有人点了 Start 永远起不来,非常惨。

第二,改资源配额一定要先看当前集群资源的真实水位。不要看节点总容量就拍脑袋定 quota,要留出 15% 以上的 buffer 给系统组件、日志采集、监控组件。

第三,NFS 或者共享存储变更一定要先做备份和读写压测,再批量切换。Jupyter 的文件 IO 模式和传统 Web 应用差别很大,很多小文件、并发写锁,共享存储性能不达标时体验会直接崩塌。

回头看看这些年的排障经历,"JupyterHub on K8s Jupyter 总是无响应"这个问题的答案从来都不是一句"重启 Pod"或者"加大内存"。它拷问的是整个链路——从资源调度、镜像、存储、Ingress、WebSocket,到 Cull 策略和镜像内部的 Jupyter 进程配置,每一环都有可能是"卡死"的来源。把这一整条链路理顺了,配好监控和资源红线,你会发现"无响应"这个症状会慢慢彻底消失。如果读者朋友最近也在和这个麻烦纠缠,可以从我上面提到的分类法开始——先分清是登录卡、启动卡,还是 Kernel 卡,再顺着对应链路往下挖,方向对了,问题就解决一半了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询