- 后端
- 微服务
【免费下载链接】jupyterhub
Multi-user server for Jupyter notebooks
导读
本文以 JupyterHub 官方 API 参考文档(docs/source/reference/api/service.md)为骨架,深入解析jupyterhub.services.service.Service类的全部核心成员(name、admin、url、api_token、managed、kind、command、cwd、environment、user、oauth_client_id、server、prefix、proxy_spec等),并对照源码(jupyterhub/services/service.py)说明其实现原理。读完本文,你将能独立配置一个 Hub-Managed Service(由 Hub 托管的子进程)或 Externally-Managed Service(由 Docker/systemd 等外部工具管理的进程),理解服务如何被注册进代理、如何拿到 Hub 颁发的凭据与路由信息,以及如何通过 REST API 在运行时动态增删服务。
Service 是什么:两类服务形态
在 JupyterHub 中,一个Service是能够与 Hub 的 REST API 交互的独立进程(或外部系统)。典型用途包括:
- 定时清理空闲的单用户 notebook 服务器(cull-idle);
- 一个以 Hub 为 OAuth 提供方的 Web 应用,用于登录与授权;
- 定期执行某类 API 动作的脚本;
- 收集用户服务器活动数据的自动化任务。
区分服务的两个关键特征是:
- 是否由 JupyterHub 托管(managed);
- 是否运行 Web 服务器并需要加入代理路由表。
由此划分出两种形态(参考 docs/source/reference/services.md):
- Hub-Managed Service:由 Hub 以本地子进程方式启动,Hub 负责进程生命周期,进程意外退出时自动重启;只能运行在 Hub 所在主机上。需要跑在 Docker 等容器环境中的服务应注册为外部服务。
- Externally-Managed Service:由 Docker、systemd 等外部工具管理,不在 Hub 的进程树内;Hub 通过
api_token识别其身份。
Service类源码 docstring 中给出了两种形态的典型配置字典(jupyterhub/services/service.py#L23-L38):
# 外部管理的、运行在固定 URL 上的服务 { 'name': 'my-service', 'url': 'https://host:8888', 'admin': True, 'api_token': 'super-secret', } # Hub 管理的、无 URL 的服务(如 cull-idle) { 'name': 'cull-idle', 'command': ['python', '/path/to/cull-idle'], 'admin': True, }Service 类的核心 trait(配置项)
Service继承自LoggingConfigurable(jupyterhub/services/service.py#L160),其输入型 trait(input=True)既可通过c.JupyterHub.services配置,也可通过 REST API 提交。所有input=True的 trait 默认会持久化到数据库,除非同时标记了in_db=False。下面逐一说明各成员的含义、默认值与实现细节。
name:服务名称
服务的唯一标识,也是代理路由与 OAuth client id 的命名基础。若服务有 HTTP 端点,默认路径前缀为/services/<name>/。
admin:是否拥有 Hub API 的管理权限
Bool类型,默认False。源码注释指出:admin 服务的 token 拥有对 Hub API 的管理访问权;非 admin 服务的 token 仅有非管理访问权(除了将认证委托给 Hub 外能做的事不多)。从 jupyterhub/apihandlers/services.py#L39-L56 可以看到,运行时创建服务时,若admin: True,Hub 会校验请求者是否拥有默认 admin 角色的全部 scope。
url:服务所在 URL
Unicode,默认None。仅当服务确实运行 HTTP(s) 端点时填写。指定url后,服务会被注册到代理的/services/:name路径下;对托管服务而言,该值还会以JUPYTERHUB_SERVICE_URL环境变量传给子进程。若端口填 0,则由 Hub 自动选择端口(见模块 docstring,jupyterhub/services/service.py#L13-L16)。
api_token:API 令牌
Unicode,默认None(标记为in_db=False)。对托管服务,若未指定,Hub 会在启动时自动生成,并通过$JUPYTERHUB_API_TOKEN注入环境;对 OAuth 服务,此 token 同时充当 OAuth client secret。外部服务必须自行指定 token,且每个服务需要唯一 token,因为 Hub 靠 token 识别请求发起方。
oauth_client_id:OAuth 客户端 ID
默认值为service-<name>(jupyterhub/services/service.py#L325-L327),通常无需修改。校验器强制要求其必须以service-开头,否则抛ValueError(jupyterhub/services/service.py#L329-L336)。该字段标记in_db=False,不入库。
oauth_redirect_uri:OAuth 重定向 URI
默认值为<prefix>/oauth_callback,即/services/<name>/oauth_callback。仅当重定向地址不同于默认值、或服务不通过代理暴露(未设置url)时才需要显式设置。例如外部 OAuth 示例(examples/external-oauth/jupyterhub_config.py)将重定向 URI 显式指到外部端口:
c.JupyterHub.services = [ { 'name': 'external-oauth', 'oauth_client_id': "service-oauth-client-test", 'api_token': api_token, 'oauth_redirect_uri': 'http://127.0.0.1:5555/oauth_callback', } ]oauth_client_allowed_scopes:OAuth 客户端允许的 scope
从 3.0 起取代已废弃的oauth_roles。它定义该服务签发的 OAuth token(即用户通过浏览器完成认证后保存在 cookie 中的 token)的最大与默认 scope,也就是服务能在用户授权下代表用户执行哪些操作。默认空列表,意味着仅能识别用户身份,不能代用户执行任何动作。这在"用户委托"模式下至关重要:服务自身可以不拥有任何 scope,仅凭用户 OAuth 授权代为执行操作。
oauth_no_confirm:跳过 OAuth 确认页
Bool,默认False(1.1 版本加入)。设为True后,用户访问该服务时不再看到授权确认页面。适用于被视为 Hub 一部分、无需额外提示的管理员服务。运行时通过 API 创建服务时若设置该项,Hub 会把该服务的oauth_client_id加入oauth_no_confirm_list(jupyterhub/apihandlers/services.py#L106-L110)。
display:是否在 Hub 首页展示链接
Bool,默认True。置为False可让服务不出现在用户首页"Services"下拉菜单中(仅在同时指定url时生效)。参考示例 examples/service-whoami/jupyterhub_config.py 中whoami-api服务即设置了'display': False。
timeout:服务就绪等待超时
Integer,默认 30(秒),6.0 版本加入。用于等待服务变为可用。
info:附加信息字典
Dict,可通过配置提供关于服务的杂项信息。
managed 与 kind:托管状态从何而来
managed是一个只读属性(property),其实现异常简洁——只要配置了command,服务即为托管服务(jupyterhub/services/service.py#L271-L274):
@property def managed(self): """Am I managed by the Hub?""" return bool(self.command)kind属性据此返回字符串'managed'或'external'(jupyterhub/services/service.py#L276-L283)。因此"是否托管"完全由command字段决定:指定command→ Hub 以子进程方式托管;不指定 → 假定由外部进程管理。
托管服务的启动参数:command、cwd、environment、user
托管服务(Hub-Managed)比外部服务多了四个启动相关的配置:
command:Command类型(trait 校验后的命令列表,minlen=0),JupyterHub 用它来 spawn 服务进程。只有希望服务作为子进程运行时才使用。例如 cull-idle 的经典配置(docs/source/reference/services.md#L96-L114):
import sys c.JupyterHub.load_roles = [ { "name": "idle-culler", "scopes": [ "read:users:activity", # 读取用户 last_activity "servers", # 启动和停止服务器 # 'admin:users' # 若还要清理空闲用户本身则取消注释 ] } ] c.JupyterHub.services = [ { 'name': 'idle-culler', 'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600'] } ]cwd:服务进程的工作目录,缺省为 Hub 目录。environment:额外传给服务进程的环境变量字典(仅托管时生效)。user:要切换到的系统用户名,缺省与 Hub 同用户运行;若指定则需要 Hub 以 root 身份运行。
托管的底层实现:_ServiceSpawner
Hub 并不直接Popen命令,而是复用了 Spawner 的概念——源码注释坦言"这里本不该用 Spawner,但可复用的概念太多"(jupyterhub/services/service.py#L96-L97)。Service.start()会构造一个_ServiceSpawner(LocalProcessSpawner的子类,去除了 notebook 特定逻辑),关键点包括:
start()中通过Popen(self.cmd, env=env, preexec_fn=..., start_new_session=True, cwd=self.cwd or None)拉起子进程,并在 PermissionError 时给出更友好的错误提示(jupyterhub/services/service.py#L129-L157);- 进程启动后,
add_poll_callback(self._proc_stopped)注册退出回调;进程意外退出时_proc_stopped会记录错误并通过asyncio.ensure_future(self.start())自动重启(jupyterhub/services/service.py#L484-L490); - 若配置了
user(系统用户名),make_preexec_fn会在 exec 前调用set_user_setuid(name, chdir=False)切换用户(jupyterhub/services/service.py#L117-L121); - 托管服务默认的 OAuth 访问 scope 为
access:services与access:services!service=<name>(jupyterhub/services/service.py#L110-L115)。
Service.stop()则停止轮询、删除 ORM 中的 server 记录并调用spawner.stop()(jupyterhub/services/service.py#L492-L502)。
server、prefix、proxy_spec:URL 与代理路由
这三个只读属性决定服务在代理中的位置:
server:返回Server.from_orm(self.orm.server)(若存在),即服务对应的服务器记录。prefix:服务的路径前缀,实现为url_path_join(self.base_url, 'services', self.name + '/'),即/services/<name>/(jupyterhub/services/service.py#L381-L383)。proxy_spec:代理路由规则,返回domain + server.base_url(配置了子域时),否则返回server.base_url(jupyterhub/services/service.py#L393-L400)。
模块 docstring 明确:公开路由始终是/services/service-name,url在配置中指定;如果端口为 0,则由 Hub 选择端口。因此一个带 URL 的服务最终可通过https://myhub.horse/services/my-service/访问。你的服务代码在路由时必须考虑JUPYTERHUB_SERVICE_PREFIX(注意它自带结尾斜杠),否则会出现 404 或路由错乱——例如/foo端点应挂载为JUPYTERHUB_SERVICE_PREFIX + 'foo'。
href属性则为 UI 链接提供了便捷拼接(//domain + prefix或prefix)。
服务启动时注入的环境变量
Hub 启动托管服务时,会注入一整套JUPYTERHUB_*环境变量(源码见 jupyterhub/services/service.py#L437-L440,完整清单见 docs/source/reference/services.md#L125-L149):
JUPYTERHUB_SERVICE_NAME 服务名称 JUPYTERHUB_API_TOKEN API 令牌(托管服务自动生成) JUPYTERHUB_API_URL Hub API 地址(默认 http://127.0.0.1:8080/hub/api) JUPYTERHUB_BASE_URL Hub 的 Base URL JUPYTERHUB_SERVICE_PREFIX 服务路径前缀(/services/<name>/) JUPYTERHUB_SERVICE_URL 服务监听的本机 URL(仅 proxied web 服务) JUPYTERHUB_OAUTH_ACCESS_SCOPES 3.0 起:访问该服务所需的 JSON scope 列表 JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES 3.0 起:OAuth client 可代表用户请求的 scope JUPYTERHUB_PUBLIC_URL 服务的公网 URL(配置了子域时可用) JUPYTERHUB_PUBLIC_HUB_URL JupyterHub 整体的公网 URL(配置了子域时可用)值得注意的实现细节:若 Hub 监听在所有网卡(ip为''、'0.0.0.0'或'::'),Service.start()会深拷贝 Hub 配置并改用127.0.0.1(IPv6 为::1)作为connect_ip,因为托管服务永远是本地子进程(jupyterhub/services/service.py#L449-L458)。另外,_ServiceSpawner.start()会从环境中移除JUPYTERHUB_ACTIVITY_URL,因为服务没有活动上报地址(jupyterhub/services/service.py#L129-L133)。
服务凭据与权限模型
服务通过api_token直接访问 Hub API,具体能做什么由该服务的角色分配(role assignments)决定。例如给一个服务分配"列出用户"的角色(docs/source/reference/services.md#L199-L214):
c.JupyterHub.services = [ { "name": "user-lister", "command": ["python3", "/path/to/user-lister"], } ] c.JupyterHub.load_roles = [ { "name": "list-users", "scopes": ["list:users", "read:users"], "services": ["user-lister"] } ]当服务配置了url或显式的oauth_client_id/oauth_redirect_uri时,它还能作为 OAuth 客户端运行。用户访问这类服务并完成认证后,会得到一枚 OAuth token,该 token:
- 归认证用户所有;
- 与该服务的 OAuth client 绑定;
- 受该服务
oauth_client_allowed_scopes配置约束,使服务能代表用户行事。
服务发起请求时有两套凭据可选:自己的api_token(以服务身份行事,受自身角色约束)或用户 OAuth token(以用户身份行事)。一个典型的"grader-dashboard"示例展示了如何让服务自身零权限、仅靠用户授权代执行操作(docs/source/reference/services.md#L251-L277):
c.JupyterHub.services = [ { "name": "grader-dashboard", "command": ["python3", "/path/to/grader-dashboard"], "url": "http://127.0.0.1:12345", "oauth_client_allowed_scopes": [ "list:users", "read:users", ] } ] c.JupyterHub.load_roles = [ { "name": "grader", "scopes": [ "list:users!group=class-a", "read:users!group=class-a", "servers!group=class-a", "access:servers!group=class-a", "access:services", ], "groups": ["graders"] } ]该服务自身无任何角色、无权限;但 grader 登录后,dashboard 获得一枚可列出、读取 A 班用户信息的 token,却不能启动/停止/访问用户服务器(这些不在oauth_client_allowed_scopes中),也无法在用户未授权时执行任何操作。
运行时动态增删服务(REST API)
只有外部托管服务(无command)可以在运行时通过 REST API 增删(docs/source/reference/services.md#L294-L333),对应实现见 jupyterhub/apihandlers/services.py。
新增服务:POST /hub/api/services/:servicename,需要admin:servicesscope,payload 与配置文件支持的外部服务属性一致。add_service内部会先校验模型与请求的 scope(_check_service_scopes),并拒绝在运行时创建托管服务(返回 400,jupyterhub/apihandlers/services.py#L76-L80)。响应:
201 Created:服务及相关对象创建成功(Hub 托管服务会同时启动,但运行时不支持,故实际仅外部服务);400 Bad Request:payload 无效或无法创建;409 Conflict:同名服务已存在。
删除服务:DELETE /hub/api/services/:servicename,需要admin:servicesscope,无 payload。响应:
200 OK:删除成功(Hub 托管服务会先停止);400 Bad Request:无法删除;404 Not Found:服务不存在;405 Not Allowed:服务由配置文件创建,运行时不可删除。
实战:一个完整的 Hub-Managed Web 服务
参考仓库中的 whoami 示例(examples/service-whoami/jupyterhub_config.py),同时配置一个纯 API 服务和一个 OAuth Web 服务:
import sys c = get_config() c.JupyterHub.services = [ { 'name': 'whoami-api', 'url': 'http://127.0.0.1:10101', 'command': [sys.executable, './whoami.py'], 'display': False, }, { 'name': 'whoami-oauth', 'url': 'http://127.0.0.1:10102', 'command': [sys.executable, './whoami-oauth.py'], # 默认 OAuth scope 最小,仅请求访问服务与按名识别用户; # 通过 oauth_client_allowed_scopes 可请求更多用户信息 # 或代用户执行操作,例如 'inherit' 表示继承用户全部权限 # 'oauth_client_allowed_scopes': ['inherit'], }, ] c.JupyterHub.load_roles = [ { "name": "user", # 赋予所有用户访问所有服务的权限 "scopes": ["access:services", "self"], } ]对应服务端代码(examples/service-whoami/README.md)演示了两种认证路径:
whoami-oauth:基于HubOAuthenticated的浏览器 OAuth 流程。启动jupyterhub后访问http://127.0.0.1:8000/services/whoami-oauth,登录后返回 JSON 用户模型(name、scopes等)。返回内容取决于oauth_client_allowed_scopes配置,默认只包含识别身份所需的最小信息。whoami-api:基于基础HubAuthenticated,只支持 token 认证的 API 请求,不支持浏览器访问。从/hub/token页面申请 token 后直接请求:
token="d584cbc5bba2430fb153aadb305029b4" curl -H "Authorization: token $token" http://127.0.0.1:8000/services/whoami-api/ | jq .公告栏服务示例(examples/service-announcement/jupyterhub_config.py)则演示了command带参数、以及用角色精确控制谁能访问服务:
c.JupyterHub.services = [ { 'name': 'announcement', 'url': 'http://127.0.0.1:9999', 'command': [sys.executable, "-m", "announcement", '--port', '9999'], } ] c.JupyterHub.load_roles = [ { "name": "announcers", "users": ["announcer"], "scopes": ["access:services!service=announcement"], } ]使用 Hub 认证基础设施
Service类负责服务注册与进程管理;服务自身的请求认证则由 jupyterhub/services/auth.py 提供,分为两级:
HubAuth:最基础的认证,适合只接受 token 授权 API 请求的服务。通过JUPYTERHUB_API_TOKEN环境变量或构造参数设置api_token,调用user_for_token(token)向 Hub 的/hub/api/user发起请求换取用户模型;Hub 响应会被缓存,默认cache_max_age = 300秒(5 分钟),可通过cache_max_age调节。HubOAuth:支持浏览器 OAuth 认证,适用于需要被浏览器直接访问的服务,负责登录跳转、PKCE 校验、OAuth 状态与结果 cookie 管理。
对于 tornado 服务,可直接混入HubAuthenticated/HubOAuthenticatedmixin 并定义initialize注入hub_auth:
class MyHandler(HubOAuthenticated, web.RequestHandler): def initialize(self, hub_auth): self.hub_auth = hub_auth @web.authenticated def get(self): ...HubAuth 会自动从JUPYTERHUB_*环境变量加载配置。如果不希望使用参考实现,也可以把 JupyterHub 当作标准 OAuth2 提供方,用任意 OAuth 2 客户端(如 requests + Flask)自行完成授权:拿到 token 后调用GET /hub/api/user(Authorization: token <token>)即可获取用户模型(含name、groups、scopes字段),仓库中的 FastAPI 示例(examples/service-fastapi/jupyterhub_config.py)展示了完全不依赖 JupyterHub 代码的第三方接入方式。
小结
Service类是 JupyterHub 服务体系的配置模型与运行时句柄:command字段决定托管与否(managed/kind),url与prefix/proxy_spec决定代理路由,api_token与角色分配决定 API 权限,oauth_client_id/oauth_redirect_uri/oauth_client_allowed_scopes决定 OAuth 交互边界。理解这些成员的默认值与联动关系,是写出可靠、可维护的 JupyterHub 服务的第一步——无论是定时任务类的托管服务,还是容器化部署的外部服务。
- 后端
- 微服务
【免费下载链接】jupyterhub
Multi-user server for Jupyter notebooks
相关推荐
如何免费安装Loop并掌握Mac窗口管理的6个技巧
如何免费安装Loop并掌握Mac窗口管理的6个技巧 Loop 是一款免费开源的 macOS 窗口管理工具,它把繁琐的拖拽窗口变成一次按键:按下触发键、光标指向哪
后端微服务JupyterHub 外部服务(Service)实战:用 API Token 与 idle-culler 构建 Hub 自动化任务
JupyterHub 外部服务(Service)实战:用 API Token 与 idle culler 构建 Hub 自动化任务 JupyterHub 的 S
后端微服务JupyterHub外部服务管理实战:以闲置服务器清理为例
JupyterHub外部服务管理实战:以闲置服务器清理为例 前言 在JupyterHub的实际运维中,外部服务 External Services 扮演着重要角
后端微服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考