refactor: kernel spec
This commit is contained in:
+23
-7
@@ -13,16 +13,32 @@ RUN mkdir -p /root/.jupyter/custom /app/.venv/share/jupyter/custom && \
|
||||
echo '#top-panel, #top-panel-wrapper, .jp-Notebook-header, .jp-FileEditorHeader {display: none !important; height: 0 !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; border: none !important; position: absolute !important; pointer-events: none !important; }' > /root/.jupyter/custom/custom.css && \
|
||||
cp /root/.jupyter/custom/custom.css /app/.venv/share/jupyter/custom/custom.css
|
||||
|
||||
RUN uv venv --seed --python 3.12 /opt/venv/python3.12 && \
|
||||
UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv pip install --python /opt/venv/python3.12/bin/python -r extras/requirements-py312
|
||||
|
||||
RUN uv venv --seed --python 3.8 /opt/venv/python3.8 && \
|
||||
UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv pip install --python /opt/venv/python3.8/bin/python -r extras/requirements-py38 && \
|
||||
/opt/venv/python3.8/bin/python -m ipykernel install --prefix=/opt/venv/python3.12 --name=python38 --display-name="python3.8"
|
||||
UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||
uv pip install --python /opt/venv/python3.8/bin/python -r extras/requirements-py38 && \
|
||||
/opt/venv/python3.8/bin/python -m ipykernel install \
|
||||
--prefix=/usr/local \
|
||||
--name=python38 \
|
||||
--display-name="Python 3.8"
|
||||
|
||||
RUN uv venv --seed --python 3.10 /opt/venv/python3.10 && \
|
||||
UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv pip install --python /opt/venv/python3.10/bin/python -r extras/requirements-py310 && \
|
||||
/opt/venv/python3.10/bin/python -m ipykernel install --prefix=/opt/venv/python3.12 --name=python310 --display-name="python3.10"
|
||||
UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||
uv pip install --python /opt/venv/python3.10/bin/python -r extras/requirements-py310 && \
|
||||
/opt/venv/python3.10/bin/python -m ipykernel install \
|
||||
--prefix=/usr/local \
|
||||
--name=python310 \
|
||||
--display-name="Python 3.10"
|
||||
|
||||
RUN uv venv --seed --python 3.12 /opt/venv/python3.12 && \
|
||||
UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||
uv pip install --python /opt/venv/python3.12/bin/python -r extras/requirements-py312 && \
|
||||
/opt/venv/python3.12/bin/python -m ipykernel install \
|
||||
--prefix=/usr/local \
|
||||
--name=python312 \
|
||||
--display-name="Python 3.12" \
|
||||
|
||||
# 删除主服务kernel
|
||||
RUN /app/.venv/bin/jupyter kernelspec remove python3 -f || true
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "--frozen", "--package", "runtime", "gunicorn", "--config", "runtime/gunicorn.conf.py", "runtime.main:app"]
|
||||
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"loguru==0.7.2",
|
||||
"gunicorn>=26.0.0",
|
||||
"aiofiles>=25.1.0",
|
||||
"jupyter>=1.1.1",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
|
||||
@@ -67,8 +67,7 @@ JUPYTER_MAX_LIFETIME_SECONDS = int(
|
||||
# which also registers the Python 3.8 / 3.10 kernelspecs under that venv's
|
||||
# share/jupyter/kernels tree. The runtime only launches the server here;
|
||||
# user code runs in the per-kernel venv the user picks in the Notebook UI.
|
||||
JUPYTER_VENV = os.environ.get("JUPYTER_VENV", "/opt/venv/python3.12")
|
||||
JUPYTER_BIN = os.path.join(JUPYTER_VENV, "bin", "jupyter")
|
||||
JUPYTER_BIN = os.environ.get("JUPYTER_BIN", "/app/.venv/bin/jupyter")
|
||||
|
||||
# Sidecar metadata file written next to the workspace directory. Holds
|
||||
# the runtime state we need to reconstruct the in-memory map after a
|
||||
@@ -169,8 +168,12 @@ async def start_workspace(ws_id: str) -> dict:
|
||||
workspace_path = (WORKSPACES_ROOT / ws_id).resolve()
|
||||
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Reuse existing Jupyter process
|
||||
# ---------------------------------------------------------
|
||||
if ws_id in JUPYTER_PROCESSES:
|
||||
p_info = JUPYTER_PROCESSES[ws_id]
|
||||
|
||||
if p_info["process"].poll() is None:
|
||||
if time.time() - p_info["started_at"] > JUPYTER_MAX_LIFETIME_SECONDS:
|
||||
logger.warning(
|
||||
@@ -178,7 +181,9 @@ async def start_workspace(ws_id: str) -> dict:
|
||||
f"{JUPYTER_MAX_LIFETIME_SECONDS}s "
|
||||
f"(started_at={p_info['started_at']})"
|
||||
)
|
||||
|
||||
_bump_last_used(p_info)
|
||||
|
||||
return {
|
||||
"status": "running",
|
||||
"workspace_id": ws_id,
|
||||
@@ -187,49 +192,97 @@ async def start_workspace(ws_id: str) -> dict:
|
||||
"token": p_info["token"],
|
||||
"base_url": PUBLIC_BASE_URL,
|
||||
"full_url": (
|
||||
f"{PUBLIC_BASE_URL}:{p_info['port']}/jupyter/{ws_id}/"
|
||||
f"{PUBLIC_BASE_URL}:{p_info['port']}"
|
||||
f"/jupyter/{ws_id}/"
|
||||
f"?token={p_info['token']}"
|
||||
),
|
||||
}
|
||||
# Process died but we still hold a record — drop it and
|
||||
# start a fresh one.
|
||||
|
||||
# Process died but we still hold a record.
|
||||
_delete_meta(ws_id)
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Prepare Jupyter
|
||||
# ---------------------------------------------------------
|
||||
port = get_free_port()
|
||||
token = secrets.token_urlsafe(16)
|
||||
logger.debug(f"starting workspace {ws_id} with token {token}")
|
||||
|
||||
base_path = f"/jupyter/{ws_id}/"
|
||||
|
||||
logger.info(
|
||||
f"Starting Jupyter for workspace={ws_id}, "
|
||||
f"port={port}, base_path={base_path}"
|
||||
)
|
||||
|
||||
# Jupyter Server itself runs in the runtime environment:
|
||||
#
|
||||
# /app/.venv
|
||||
#
|
||||
# Python 3.8 / 3.10 / 3.12 are registered separately as
|
||||
# Jupyter kernels:
|
||||
#
|
||||
# /usr/local/share/jupyter/kernels/python38
|
||||
# /usr/local/share/jupyter/kernels/python310
|
||||
# /usr/local/share/jupyter/kernels/python312
|
||||
#
|
||||
# Therefore DO NOT set VIRTUAL_ENV to /opt/venv/python3.12.
|
||||
cmd = [
|
||||
JUPYTER_BIN,
|
||||
"notebook",
|
||||
"lab",
|
||||
f"--port={port}",
|
||||
"--ip=0.0.0.0",
|
||||
"--no-browser",
|
||||
"--allow-root",
|
||||
f"--ServerApp.token={token}",
|
||||
f"--ServerApp.base_url={base_path}",
|
||||
# f"--notebook-dir={workspace_path}",
|
||||
f"--ServerApp.root_dir={workspace_path}",
|
||||
"--ServerApp.terminals_enabled=False",
|
||||
"--NotebookApp.terminals_enabled=False",
|
||||
# If the frontend is served through F5/Nginx and has a
|
||||
# different Origin, configure the actual origin here
|
||||
# instead of "*" in production.
|
||||
"--ServerApp.allow_origin=*",
|
||||
"--NotebookApp.allow_origin=*",
|
||||
# Keep only if your outer FastAPI service is responsible
|
||||
# for authentication / CSRF protection.
|
||||
"--ServerApp.disable_check_xsrf=True",
|
||||
"--NotebookApp.disable_check_xsrf=True",
|
||||
]
|
||||
|
||||
# Isolate the Jupyter subprocess inside the dedicated Python 3.12
|
||||
# venv. VIRTUAL_ENV makes `python` / `pip` resolve against it; PATH
|
||||
# prepended so its `bin/` wins over the runtime's /app/.venv.
|
||||
# ---------------------------------------------------------
|
||||
# 3. Environment
|
||||
# ---------------------------------------------------------
|
||||
#
|
||||
# IMPORTANT:
|
||||
#
|
||||
# Do NOT do:
|
||||
#
|
||||
# VIRTUAL_ENV=/opt/venv/python3.12
|
||||
#
|
||||
# Jupyter Server should run from /app/.venv.
|
||||
#
|
||||
# The kernel process will independently use:
|
||||
#
|
||||
# /opt/venv/python3.8/bin/python
|
||||
# /opt/venv/python3.10/bin/python
|
||||
# /opt/venv/python3.12/bin/python
|
||||
#
|
||||
# according to the kernelspec.
|
||||
jupyter_env = os.environ.copy()
|
||||
jupyter_env["VIRTUAL_ENV"] = JUPYTER_VENV
|
||||
jupyter_env["PATH"] = (
|
||||
os.path.join(JUPYTER_VENV, "bin")
|
||||
+ os.pathsep
|
||||
+ jupyter_env.get("PATH", "")
|
||||
)
|
||||
|
||||
# Make sure Jupyter's system kernelspec directory is visible.
|
||||
#
|
||||
# Usually this is already included in Jupyter's default search
|
||||
# paths, but explicitly setting JUPYTER_PATH makes the deployment
|
||||
# deterministic.
|
||||
system_jupyter_path = "/usr/local/share/jupyter"
|
||||
|
||||
existing_jupyter_path = jupyter_env.get("JUPYTER_PATH")
|
||||
|
||||
if existing_jupyter_path:
|
||||
jupyter_env["JUPYTER_PATH"] = (
|
||||
system_jupyter_path + os.pathsep + existing_jupyter_path
|
||||
)
|
||||
else:
|
||||
jupyter_env["JUPYTER_PATH"] = system_jupyter_path
|
||||
|
||||
try:
|
||||
process, log_file = start_process(
|
||||
@@ -238,12 +291,20 @@ async def start_workspace(ws_id: str) -> dict:
|
||||
env=jupyter_env,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start Jupyter for {ws_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start Jupyter: {e}")
|
||||
logger.exception(f"Failed to start Jupyter for workspace={ws_id}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to start Jupyter: {e}",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Save process metadata
|
||||
# ---------------------------------------------------------
|
||||
full_url = f"{PUBLIC_BASE_URL}:{port}{base_path}?token={token}"
|
||||
|
||||
meta_path = _meta_path(ws_id)
|
||||
now = time.time()
|
||||
|
||||
JUPYTER_PROCESSES[ws_id] = {
|
||||
"process": process,
|
||||
"base_url": PUBLIC_BASE_URL,
|
||||
@@ -252,13 +313,22 @@ async def start_workspace(ws_id: str) -> dict:
|
||||
"started_at": now,
|
||||
"last_used_at": now,
|
||||
"meta_path": meta_path,
|
||||
"workspace_path": str(workspace_path),
|
||||
}
|
||||
_write_meta(ws_id, JUPYTER_PROCESSES[ws_id])
|
||||
|
||||
_write_meta(
|
||||
ws_id,
|
||||
JUPYTER_PROCESSES[ws_id],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Started Jupyter for workspace {ws_id} "
|
||||
f"pid={process.pid} port={port} log={log_file}"
|
||||
f"Started Jupyter for workspace={ws_id} "
|
||||
f"pid={process.pid} "
|
||||
f"port={port} "
|
||||
f"root_dir={workspace_path} "
|
||||
f"log={log_file}"
|
||||
)
|
||||
|
||||
return {
|
||||
"pid": process.pid,
|
||||
"base_url": PUBLIC_BASE_URL,
|
||||
@@ -363,7 +433,7 @@ async def get_workspace(ws_id: str) -> dict:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
f"Jupyter process for workspace '{ws_id}' " "has terminated unexpectedly."
|
||||
f"Jupyter process for workspace '{ws_id}' has terminated unexpectedly."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -520,7 +590,7 @@ async def scan_workspaces() -> None:
|
||||
if not WORKSPACES_ROOT.exists():
|
||||
return
|
||||
try:
|
||||
entries = [f for f in os.listdir(WORKSPACES_ROOT) if not f.startswith('.')]
|
||||
entries = [f for f in os.listdir(WORKSPACES_ROOT) if not f.startswith(".")]
|
||||
except Exception as e:
|
||||
logger.error(f"scan workspace failed: {e}")
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user