为了方便各类课程给学生提供开箱即用的 Python 环境,我在教学实验室的 Kubernetes 上(由杰哥强力驱动)部署了一套 JupyterHub,并进行了一些必要的自定义。本文介绍整个部署过程,并记录一些经验和踩过的坑。
JupyterHub 官方提供了一个比较成熟的 Helm chart:Zero to JupyterHub with Kubernetes,文档写得比较好,基本上照着做就能跑起来。
服务基本配置
首先安装 Helm Chart:
helm repo add jupyterhub https://jupyterhub.github.io/helm-chart/
helm repo update
然后新建 config.yaml,文件由以下的部分拼接(以及合并)而成。
OAuth 认证
我们已有一个统一的 OAuth 认证门户(感谢喵喵!),JupyterHub 通过 GenericOAuthenticator 接入:
hub:
baseUrl: /jupyterhub
config:
Authenticator:
admin_users:
- admin1
- admin2
auto_login: true
allow_all: true
JupyterHub:
authenticator_class: generic-oauth
GenericOAuthenticator:
client_id: jupyterhub
client_secret: <your_oauth_client_secret>
oauth_callback_url: https://example.com/jupyterhub/hub/oauth_callback
authorize_url: https://example.com/portal/api/authorize
token_url: https://example.com/portal/api/token
userdata_url: https://example.com/portal/api/self
username_key: user_name
部分需要注意的点:
baseUrl设为/jupyterhub可以与其他服务共享域名。auto_login: true配合allow_all: true,用户访问时会自动跳转 OAuth 登录,登录成功即可创建新用户,无需手动维护白名单(注:JupyterHub 5.0 开始必须有allow_all,否则新用户无法登录)。username_key需要根据 OAuth Provider 返回的用户信息字段来设置。
网络与 Ingress
由于我们的 JupyterHub 在子路径下,需要把 proxy 的 Service 类型设为 ClusterIP(而不是默认的 LoadBalancer),然后通过公共的 Ingress 暴露:
proxy:
service:
type: ClusterIP
对应的 Ingress 资源 jupyterhub-ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: jupyterhub-ingress
labels:
app: jupyterhub-ingress
spec:
rules:
- http:
paths:
- path: /jupyterhub
pathType: Prefix
backend:
service:
name: proxy-public
port:
number: 80
这里的 proxy-public 是 Helm chart 中自动创建的服务名称,通常不需要更改。
用户环境与 Profile
JupyterHub 的一大优势就是可以提供多种预配置环境供用户选择,通过 profileList 定义和覆盖:
singleuser:
image:
name: quay.io/jupyter/scipy-notebook
tag: python-3.13
profileList:
- display_name: "数据分析环境 (Python 3.13)"
description: "安装了常用的数据分析包,如 numpy, scipy, sklearn, statsmodel, sympy, matplotlib, pandas"
default: true
- display_name: "最小环境 (Python 3.13)"
description: "仅包含 Python 和必要的工具"
kubespawner_override:
image: quay.io/jupyter/minimal-notebook:python-3.13
默认使用 scipy-notebook 镜像,对于大多数数据分析相关的课程场景已经够用。
资源限制与调度
由于服务器资源有限(甚至 k8s 是跑在 VMware 上的),我给每个用户 Pod 设了比较宽松的 limit 和较低的 guarantee,适合教学场景下大量用户同时在线,但不会持续高负载的情况:
singleuser:
cpu:
limit: 4
guarantee: 0.05
memory:
limit: 3G
guarantee: 512M
调度方面,启用了 userPlaceholder 来预热节点,这样用户启动 Pod 时不用等节点调度和拉取镜像:
scheduling:
userScheduler:
enabled: false
podPriority:
enabled: true
userPlaceholder:
enabled: true
replicas: 5
userPods:
nodeAffinity:
matchNodePurpose: prefer
关闭了 userScheduler 是因为我们集群的默认调度器已经足够。matchNodePurpose: prefer 则是让用户 Pod 优先调度到标记了 purpose: user 的节点上,但在资源不足时,也允许调度到其他节点。
存储选择
用户的持久化存储使用我们自建的 rook-cephfs,每人 5GB 空间:
singleuser:
storage:
capacity: 5Gi
dynamic:
storageClass: rook-cephfs
选择 CephFS 而不是 RBD 的原因是 CephFS 支持 ReadWriteMany,在需要共享数据时更灵活。实际上我们也用到了这一特性:下文的共享字体就是通过一个 ReadWriteMany 的 PVC 挂载到所有用户 Pod 中的。
资源回收
为了避免用户忘记关闭 Notebook(真的有人会记得吗?)导致资源浪费,启用了 culler:
cull:
enabled: true
timeout: 3600
every: 300
空闲 1 小时后自动关闭用户 Pod,每 5 分钟检查一次。
Hacks
额外依赖
由于某些课程需要额外的 Python 依赖,而我并不想维护额外的镜像。经过多次测试,可以通过覆盖启动命令,在容器启动时 pip install 额外的包,然后再启动镜像中给 JupyterHub 用的 single-user server。这样做的代价是启动总要装包,慢一两分钟,好处是不用跟着主镜像版本升级而重新构建镜像。毕竟维护镜像的活最后还是会落到我头上。
singleuser:
profileList:
- display_name: "XXXX 课程环境 (Python 3.13)"
description: "在数据分析环境基础上安装了课程所需依赖(可能需要 1-2 分钟启动)"
kubespawner_override:
cmd:
- /bin/bash
- "-c"
- "pip install jupyterlab_rise jupyter-archive ipywebrtc 'ipywidgets<8'; jupyterhub-singleuser"
注:不像下面一样使用 postStart hook 的原因是,容器是没有状态的,每次启动都是空的;而部分包必须在 Jupyter 本身启动之前安装(比如 jupyterlab_rise),因此只能覆盖启动命令。
中文字体
官方的 JupyterLab 镜像里不包含中文字体,用 matplotlib 画图时中文会变成方块。当然,重新打镜像也能解决此问题,但我显然还是不想这么做。
偷懒问了一下 Claude,解决方案是创建一个共享的字体 PVC,挂载到每个用户 Pod 中:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jupyterhub-fonts
spec:
accessModes:
- ReadWriteMany
storageClassName: rook-cephfs
resources:
requests:
storage: 1Gi
使用 kubectl apply -f 应用此文件,然后在配置中把这个 PVC 挂载到用户 Pod,并通过 postStart hook 刷新字体缓存和 matplotlib 的字体管理器缓存(matplotlib 有自己的缓存,如果不刷新还是看不到新安装的字体,不知为何有这么糟糕的设计):
singleuser:
storage:
extraVolumes:
1-extra-fonts:
name: extra-fonts
persistentVolumeClaim:
claimName: jupyterhub-fonts
readOnly: true
extraVolumeMounts:
1-extra-fonts:
name: extra-fonts
mountPath: /usr/share/fonts/extra
readOnly: true
lifecycleHooks:
postStart:
exec:
command:
- "sh"
- "-c"
- "fc-cache -f /usr/share/fonts/extra && python -c \"import matplotlib.font_manager; matplotlib.font_manager._load_fontmanager(try_read_cache=False)\" || true"
末尾的 || true 是为了防止在 matplotlib 未安装的环境(如最小环境)中,容器因为找不到包而启动失败。
往 PVC 里增加字体的方式比较朴素——启动一个临时 Pod 挂载这个 PVC,然后用 kubectl cp 把字体文件拷进去。临时 Pod 资源描述 temp-font-pod.yaml 如下:
apiVersion: v1
kind: Pod
metadata:
name: font-loader
spec:
containers:
- name: loader
image: busybox
command: ["sleep", 3600]
volumeMounts:
- name: fonts
mountPath: /fonts
volumes:
- name: fonts
persistentVolumeClaim:
claimName: jupyterhub-fonts
执行完后记得删除:
kubectl -n jupyterhub apply -f temp-font-pod.yaml
kubectl -n jupyterhub cp /path/to/your/fonts font-loader:/fonts/
kubectl -n jupyterhub delete pod font-loader
部署脚本
上文的所有配置可以写成一个部署脚本,方便后续升级和回滚:
#!/bin/bash
set -x
set -e
NAMESPACE=jupyterhub
helm upgrade --cleanup-on-fail \
--install jupyterhub jupyterhub/jupyterhub \
--namespace $NAMESPACE \
--create-namespace \
--version=4.3.2 \
--values config.yaml
kubectl -n $NAMESPACE apply -f shared-fonts-pvc.yaml
kubectl -n $NAMESPACE apply -f jupyterhub-ingress.yaml
小结
总体来说,Zero to JupyterHub 这个 Helm chart 做得还不错,上课用起来体验比较好。当然,为了实现额外需求还是需要一些 hack,也需要在文档缺乏的地方自己摸索。