上篇文章介绍了 Jupyter 生态及重要组件的原理。基于之前的内容,本文介绍 Jupyter 二次开发的思路。首先介绍项目的需求,接着进一步介绍架构设计,进行demo的实现,最后进行总结。
需求
实现图数据管理分析 BI 平台的 Notebook Service,具备数据的探索、执行分析任务、sql 操作、spark 操作等功能。这个平台目前是单租户的架构,是 to b 的。一般是公司的数据分析团队使用,一个团队一般是十几个人,写代码用到 Notebook 的可能也就那么几个人。所以对性能和可用性没有像 to c 的产品要求那么高。
总结下来,我们的 Notebook Service 应该具备下面的功能:
- 提供给用户开箱即用的环境
- 隔离用户的环境,避免互相污染
- 接入 spark、adb、oss 等平台资源供用户进行探查、分析
目前打算先快速把架构搭起来,先使用自带的 IPython 内核,后面再接入平台的对象存储、数据库和 spark 集群等资源。
架构设计
经过调研,决定采用下面组件来组合实现:
- 前端 使用经典的 Jupyter Notebook 组件,因为项目不需要太多的功能,只需要实现简单的 Notebook 功能就行。
- 服务端 使用 Jupyter Server 来转发前端的请求给内核执行。
- 内核 负责执行代码,将结果返回服务端。暂时使用 IPython Kernel,后面再接入平台的对象存储、数据库和 spark 集群等资源。
- 部署 使用 Jupyter Hub 组件,用于为实现多用户提供 Notebook,即不同用户使用不同的 Jupyter Server 和 Kernel。
- Authenticators:实现自定义登录鉴权,可以通过自定义 Authenticator 类并在配置文件中指定来实现。
- Spawners:用户登录时,Jupyter Hub 会用用户启动一个新实例(Jupyter Server + Kernel)。启动实例是通过 Spawner 实现的。官方提供了多种 Spawner 的实现,包括:本机新的Notebook Server进程、本机启动Docker实例、K8s系统中启动新的Pod、YARN中启动新的实例等等。这些实现本身是可配置的。如果不符合需求,也可以自己开发全新的 Spawner。后续我们需要接入 spark 和 数据库等资源,可以基于官方提供的 Spawner 进行定制,来接入资源。
整个架构如下图所示。不同的客户端通过 JupyterHub 进行登录验证后,可以通过 Jupyter Notebook 前端,访问对应的实例。每个实例即 K8s 的 pod,不同 pod 之间的资源是隔离的。
![notebook-architecture](https://img-blog.csdnimg.cn/img_convert/5e055c6ea1eaeff296b58b41bac665c8.png)
安装部署
下面在本地部署一个 Jupyter Hub,从各个组件的源码进行编译。虽然各个组件都可以通过扩展的方式去开发,但是后期如果有复杂的架构的话,可能需要修改相关的源码,所以通过在本地去编译各个组件的源码,去部署 Jupyter Hub。在本地完成开发后,可以打包成镜像,然后参考 社区的教程 部署到 K8s 上。
# 安装 IPyKernel
pip install ipykernel -i https://pypi.tuna.tsinghua.edu.cn/simple
# 编译 Jupyter Server
git clone https://github.com/jupyter-server/jupyter_server.git
cd jupyter_server
pip install -v -e .
cd ../
# 编译 Jupyter Lab 前端
# 需要注意的是,直接编译 Jupyter Lab,会自动安装 IPyKernel 和 Jupyter Server 等依赖
# 我们将这些步骤放在前面先编译安装了,方便理解
git clone https://github.com/jupyterlab/jupyterlab.git
cd jupyterlab
# 如果执行 pip install -v -e . 报错,就先尝试执行 yarn install 安装前端的依赖
yarn install
pip install -v -e .
# build 前端资源
jupyter lab build
cd ../
# 安装 Jupyter Hub 的 proxy,它的作用是将用户的请求路由给对应的 Jupyter Server
npm install -g configurable-http-proxy --registry=https://registry.npmmirror.com
# 编译 Jupyter Hub
git clone https://github.com/jupyterhub/jupyterhub.git
cd jupyterhub
pip install -v -e .
cd ../
回顾一下之前的简化架构图,会发现上述命令将需要用到的重要组件都安装到了。 JupyterHub 管理多个用户,每个用户都有独立的单用户服务器实例,包括 Jupyter Lab 界面、Jupyter Server 和 IPython 内核。用户通过 JupyterLab 交互界面编写和执行代码,Jupyter Server 处理请求并与内核通信执行代码,最终将结果返回给用户,实现了多用户的交互式计算环境。
![Jupyter 架构简化图](https://img-blog.csdnimg.cn/img_convert/b428e7636cdce2d6ba52956e8ee46288.png)
上面安装好了 Jupyter 的本地环境后,接下来介绍如何跑起来。
# 创建一个文件夹用来存放配置文件
mkdir jupyterhub-config && cd jupyterhub-config
# 生成配置文件
jupyterhub --generate-config
按下面的配置进行修改,各个配置注释都有解释,也可以参考这里。
# 使用 PAM 进行用户身份验证,用户通过操作系统中的有效用户名和密码进行验证
c.JupyterHub.authenticator_class = 'jupyterhub.auth.PAMAuthenticator'
# 让能通过验证的用户成功访问 Hub
c.Authenticator.allow_all = True
然后执行下面命令。
# 以 jupyterhub_config.py 中的配置运行jupyterhub
jupyterhub -f jupyterhub_config.py
控制台会类似输出下面的内容。
需要注意的是 8081 端口是 Hub API 的监听端口,用于与其他组件(如 Proxy 和 Spawner)进行通信的接口,只对 JupyterHub 的内部通信开放,不直接对外提供服务。
8000 端口是 JupyterHub Proxy 的监听端口,负责将用户的请求从这个端口转发到对应的 Jupyter Server 服务,这个端口通常对外开放,用户通过这个端口访问 JupyterHub 的 Web 界面。所以我们在浏览器打开 http://127.0.0.1:8000
,输入电脑的登录账号和密码就可以登录成功。
[I 2024-08-15 18:25:27.339 JupyterHub app:3307] Running JupyterHub version 5.2.0.dev
[I 2024-08-15 18:25:27.339 JupyterHub app:3337] Using Authenticator: jupyterhub.auth.PAMAuthenticator-5.2.0.dev
[I 2024-08-15 18:25:27.339 JupyterHub app:3337] Using Spawner: jupyterhub.spawner.LocalProcessSpawner-5.2.0.dev
[I 2024-08-15 18:25:27.339 JupyterHub app:3337] Using Proxy: jupyterhub.proxy.ConfigurableHTTPProxy-5.2.0.dev
[I 2024-08-15 18:25:27.342 JupyterHub app:1882] Writing cookie_secret to /jupyterhub_cookie_secret
[I 2024-08-15 18:25:27.764 alembic.runtime.migration migration:215] Context impl SQLiteImpl.
[I 2024-08-15 18:25:27.764 alembic.runtime.migration migration:218] Will assume non-transactional DDL.
[I 2024-08-15 18:25:27.768 alembic.runtime.migration migration:623] Running stamp_revision -> 4621fec11365
[I 2024-08-15 18:25:27.827 JupyterHub proxy:556] Generating new CONFIGPROXY_AUTH_TOKEN
[I 2024-08-15 18:25:27.844 JupyterHub app:3376] Initialized 0 spawners in 0.003 seconds
[I 2024-08-15 18:25:27.846 JupyterHub metrics:373] Found 0 active users in the last ActiveUserPeriods.twenty_four_hours
[I 2024-08-15 18:25:27.846 JupyterHub metrics:373] Found 0 active users in the last ActiveUserPeriods.seven_days
[I 2024-08-15 18:25:27.846 JupyterHub metrics:373] Found 0 active users in the last ActiveUserPeriods.thirty_days
[W 2024-08-15 18:25:27.846 JupyterHub proxy:748] Running JupyterHub without SSL. I hope there is SSL termination happening somewhere else...
[I 2024-08-15 18:25:27.846 JupyterHub proxy:752] Starting proxy @ http://:8000
18:25:28.383 [ConfigProxy] info: Proxying http://*:8000 to (no default)
18:25:28.384 [ConfigProxy] info: Proxy API at http://127.0.0.1:8001/api/routes
18:25:28.763 [ConfigProxy] info: 200 GET /api/routes
[I 2024-08-15 18:25:28.763 JupyterHub app:3690] Hub API listening on http://127.0.0.1:8081/hub/
18:25:28.765 [ConfigProxy] info: 200 GET /api/routes
[I 2024-08-15 18:25:28.765 JupyterHub proxy:477] Adding route for Hub: / => http://127.0.0.1:8081
18:25:28.767 [ConfigProxy] info: Adding route / -> http://127.0.0.1:8081
18:25:28.768 [ConfigProxy] info: Route added / -> http://127.0.0.1:8081
18:25:28.768 [ConfigProxy] info: 201 POST /api/routes/
[I 2024-08-15 18:25:28.768 JupyterHub app:3731] JupyterHub is now running at http://:8000
如果运行的时候发现没有 Kernel,那么可以通过如下命令来安装 Kernel,然后重启
# 查看有没有安装内核
jupyter kernelspec list
# 如果没有的话,添加 IPyKernel
# 这条命令将当前 Python 环境作为一个新的内核安装到 Jupyter 中,安装在用户级别目录
python -m ipykernel install --user --name 内核名称 --display-name "内核显示名称"
# 注意,这条命令是用来删除已经安装的内核。如果需要删除内核的话,执行下面命令。
jupyter kernelspec remove 内核名称
自定义认证
调试
首先介绍如何在 Pycharm 中将 Jupyter 跑起来,方便进行开发调试(如果你不是 Python 开发的话,相信对你会有帮助😄)。
添加如下面图片中的运行配置,需要注意的需要是以 module 的方式去运行项目的入口 jupyter/app.py 文件,然后设置以下命令参数 -f xx,最后设置项目的工作目录为 jupyterhub 项目的根目录。然后就可以愉快的运行调试了👍。
![Pycharm run/debug configuration](https://img-blog.csdnimg.cn/img_convert/bbc64dbd83dd7dd2fe5ab4a70d8b90a8.png)
下面解释一下为什么需要以 module 的方式去运行入口文件。
-
以 scripy 方式运行文件相当于直接使用
python xxx
。 -
以 module 方式运行文件相当于使用
python -m xxx
。 -
路径处理: 运行 script 时,Python 会根据文件的物理路径直接执行,而运行 module 时,Python 会处理模块路径并将模块的顶级目录添加到
sys.path
。 -
导入方式: 在 module 运行时,Python 可以处理相对导入,而 script 运行时则不能直接处理相对导入(因为脚本是作为独立的
__main__
执行的,没有包的上下文)。 -
适用场景: script 方式适合单文件或测试文件的简单执行,而 module 方式则适合复杂的包结构,尤其是涉及包内相对导入或模块内部的命名空间管理时。
这两种方式的选择取决于你的项目结构和需要执行的代码位置。在大型项目或需要调试模块化代码时,使用模块方式通常更合适。如果喜欢通过命令行进行调试的话,也可以使用 pdb (类似 gdb)来调试项目。
实现
由于我们对 Jupyter Notebook 进行了二次开发,需要将其作为一个服务集成到我们的图数据分析平台中。因此,我们需要自定义JupyterHub 的认证机制,使其与平台中的认证机制一致。目前,我们的图数据分析平台采用基于 JWT 的令牌认证方式。因此,我们需要在 JupyterHub 中实现基于 JWT 的自定义认证机制。
下面是认证过程的时序图,过程如下所述:
-
用户访问入口: 用户通过浏览器访问 Jupyter Hub,最初的请求到达
Proxy
。 -
跳转 SSO: 如果验证 token 失败,
Proxy
会将请求重定向到 SSO 系统进行认证。 -
SSO 登录: 用户完成 SSO 登录,SSO 系统将结果回调到
Hub
。 -
实例申请和创建:
Hub
接收回调后,为用户请求在 本地创建一个新的 Jupyter Server 实例。 -
实例创建: 创建实例后,返回相关信息给
Hub
。 -
配置和跳转:
Hub
设置转发规则,然后将用户的请求重定向到<username>
路径。 -
访问 JupyterLab: 用户浏览器访问
<username>
路径,加载 Jupyter Lab 界面。 -
代码执行和结果返回: 用户在 Jupyter Lab 中请求执行代码,转发给 Jupyter Server 执行,最终返回执行结果给用户浏览器。
![notebook-login-sequence-chart](https://img-blog.csdnimg.cn/img_convert/dfebc880de037909f5325e0a961537c7.png)
下面将介绍如何实现 Jupyter Hub 的自定义认证。
我们使用 DockerSpawner 来创建新的 Jupyter Server 实例,会为每个用户创建一个容器来运行 Jupyter Server。所以,Jupyter Hub 会和用户容器里的 Server 通信。如果 Jupyter Hub 部署在本地的话,和容器通信是不方便的,所以我们将 Jupyter Hub 放在容器里面部署,将 Jupyter Hub 容器 和 用户实例容器放在一个 network 里面,这样这些容器就可以相互通信。也许你会有疑问,Jupyter Hub 容器里面没有安装 docker,DockerSpawner 如何创建容器呢?我们可以将本地主机的 Docker 守护进程的 Unix 套接字文件(/var/run/docker.sock
)挂载到 Jupyter Hub 容器中的相同位置,这样容器内的应用可以像主机上的任何 Docker 客户端一样,与 Docker 守护进程通信,执行 Docker 命令。
首先,我们通过编写一个 Dockerfile 来安装 Jupyter Hub,我们希望能在本地连接这个容器进行开发调试,所以我们像之前一样,自己编译这些组件。下面是 Dockerfile 的内容。
# 使用 Python 3.12 基础镜像
FROM python:3.12
# 安装必要的系统依赖,包括 curl、git、vim 和 SSH 服务器
RUN apt-get update && apt-get install -y \
git \
vim \
curl \
openssh-server \
apt-transport-https \
ca-certificates \
gnupg-agent \
software-properties-common \
&& rm -rf /var/lib/apt/lists/*
# 创建工作目录
WORKDIR /srv/jupyterhub
# 配置SSH服务器
RUN mkdir /var/run/sshd
# 设置root用户的密码(注意:这是不安全的,仅用于开发环境)
RUN echo 'root:password' | chpasswd
# 允许root用户通过SSH登录
RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config
# 禁用PAM限制
RUN sed -i 's@session required pam_loginuid.so@session optional pam_loginuid.so@g' /etc/pam.d/sshd
# 暴露必要的端口,包括SSH端口22
EXPOSE 22 8000
# 启动SSH服务并进入交互式 shell
CMD service ssh start && bash
执行下面的命令构建镜像。
docker build -t jupyterhub-dev .
执行下面的命令创建 network。
docker network create jupyterhub
执行下面的命令运行容器,映射本地存储和端口。
docker run -it --name jupyterhub-dev \
-v /var/run/docker.sock:/var/run/docker.sock \
--net jupyterhub \
-v /Users/tom/notebook-service-demo/docker-image/source-code:/srv/jupyterhub \
-p 8000:8000 \
-p 16022:22 \
jupyterhub-dev
然后在本地的 /Users/docker-image/source-code
目录下,clone Jupyter Server、Jupyter lab 和 Jupyter Hub 的源码。执行 docker attach jupyterhub-dev
进入容器的命令行,按照“安装部署”小节中的内容进行编译刚刚 clone 下来的源码。注意编译 Jupyter Lab 的时候,如果报错,先尝试运行下 yarn install
。
接下来,在本地的 Pycharm 中打开映射的文件夹里的 JupyterHub 源码,然后通过 SSH 使用容器内的编译器。这样就可以愉快的使用容器内的环境进行开发了,本地更新的代码也会同步到容器内。按照上面“调试”小节中的内容去操作就行。
下面是实现自定义认证的代码。
我们先编写一个自定义认证器,用于实现 JWT 的验证逻辑。
import jwt
from jupyterhub.auth import Authenticator
from tornado import gen
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
import redis
class JWTAuthenticator(Authenticator):
secret = "your_secret"
authKeyFormat="auth_token_key:{}"
# 初始化 Redis 连接
def __init__(self, **kwargs):
super().__init__(**kwargs)
# 根据 Redis 配置进行修改,host 为运行 redis 的容器名
# 如果 Redis 在容器中启动,则需要加入和 Jupyter Hub 容器所在的 network 内
self.redis_client = redis.StrictRedis(host='redis', port=6379, db=0)
def get_user_id_from_token(self, token):
try:
# Debug log for the token being decoded
self.log.info(f"Decoding token: {token}")
payload = jwt.decode(token, self.secret, algorithms=['HS384'])
self.log.info(f"Decoded payload: {payload}")
user_id = payload.get("sub")
if user_id:
return user_id
else:
self.log.warning("No user ID found in token")
return None
except ExpiredSignatureError:
self.log.warning("Token has expired")
return None
except InvalidTokenError as e:
self.log.warning(f"Invalid token: {e}")
return None
except Exception as e:
self.log.warning(f"Error decoding token: {e}")
return None
@gen.coroutine
def authenticate(self, handler, data=None):
token = handler.request.headers.get('Authorization', None)
if not token:
self.log.warning("No Authorization header provided")
return None
user_id = self.get_user_id_from_token(token)
authKey = self.authKeyFormat.format(user_id)
if user_id:
# 从 Redis 中获取 user_id 的值
user_exists = self.redis_client.exists(authKey)
if user_exists:
return user_id
else:
self.log.warning(f"User ID {user_id} not found in Redis")
return None
else:
return None
然后编写配置文件使用上述自定义认证器。
from jupyterhub.auth import LocalAuthenticator
from tornado import gen
import os
import sys
# 获取配置文件所在目录的绝对路径
current_dir = os.path.dirname(os.path.abspath(__file__))
print(current_dir)
# 添加当前目录到 Python 路径
sys.path.insert(0, current_dir)
from jwt_authenticator import JWTAuthenticator
from dockerspawner import DockerSpawner
# Jupyter Hub 的配置文件
c = get_config() # noqa
# 设置所有用户都可以登录
c.Authenticator.allow_all = True
# 使用 SimpleAuthenticator 进行基于操作系统用户的认证
c.JupyterHub.authenticator_class = JWTAuthenticator
# 设置默认启动 JupyterLab,而不是经典的 Jupyter Notebook 界面
c.Spawner.default_url = '/lab'
# 使用 DockerSpawner
c.JupyterHub.spawner_class = DockerSpawner
# DockerSpawner 的配置
# 设置每次创建实例使用的镜像
c.DockerSpawner.image = 'jupyter/minimal-notebook'
# 设置使用 docker 的自定义网络
c.DockerSpawner.network_name = 'jupyterhub'
# 我们需要 hub 在容器中监听所有 IP
c.JupyterHub.hub_ip = '0.0.0.0'
# 用于连接 hub 的主机名/IP
# 这通常是 hub 容器的名称
c.JupyterHub.hub_connect_ip = 'jupyterhub-dev'
# 禁用 XSRF 跨域保护,运行跨域请求
c.JupyterHub.tornado_settings = {
'xsrf_cookies': False,
}
然后使用上面的配置文件启动 Jupyter Hub 。通过 apifox 发送下面的请求,将获取的 cookie 存入到浏览器上的 http://localhost:8000 上,打开 http://localhost:8000/hub/。如果可以成功打开,就是成功啦😄。
![apifox-1](https://img-blog.csdnimg.cn/img_convert/4a5c1464a95e96ef5d91c4cee824a63e.png)
总结
这篇文章介绍了 Jupyter 二次开发的初步思路。首先介绍项目的需求。接着我们的进一步介绍架构设计,使用了哪些组件。然后介绍了如何编译安装部署 Jupyter Hub 及相关组件。最后在 docker 上实现了 Jupyter Hub 的自定义的 JWT 认证。
需要注意的是,目前的我们的架构会存在下面这些问题,这些问题也是之后需要优化。
- hub 单点故障会导致全部实例使用不了
- 文件存在实例本地,hub 挂掉就找不到了
- 升级比较复杂,需要给挨个实例升级