diff --git a/backend/Dockerfile b/backend/Dockerfile index a76c832..9321979 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,11 +3,22 @@ FROM python:3.12-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app WORKDIR /app COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + gcc \ + g++ \ + python3-dev \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + COPY common ./common COPY backend ./backend COPY alembic.ini ./ COPY migrations ./migrations -RUN uv pip install --system ./common ./backend +COPY pyproject.toml uv.lock ./ +RUN UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv sync --frozen --no-dev --no-editable --package backend EXPOSE 8000 -CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["gunicorn", "--config", "backend/gunicorn.conf.py", "backend.main:app"] diff --git a/backend/gunicorn.conf.py b/backend/gunicorn.conf.py new file mode 100644 index 0000000..a326fea --- /dev/null +++ b/backend/gunicorn.conf.py @@ -0,0 +1,14 @@ +"""Gunicorn configuration for the backend service. + +All values can be overridden with GUNICORN_* environment variables. +""" + +import os + +bind = os.getenv("GUNICORN_BIND", "0.0.0.0:8000") +workers = int(os.getenv("GUNICORN_WORKERS", "1")) +worker_class = "uvicorn.workers.UvicornWorker" +timeout = int(os.getenv("GUNICORN_TIMEOUT", "120")) +graceful_timeout = int(os.getenv("GUNICORN_GRACEFUL_TIMEOUT", "30")) +accesslog = "-" +errorlog = "-" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b482af6..e15b90d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,11 +10,16 @@ dependencies = [ "croniter==6.2.4", "alembic==1.18.5", "cryptography==49.0.0", + "gunicorn>=26.0.0", ] [tool.uv.sources] common = { workspace = true } +[[tool.uv.index]] +url = "https://pypi.tuna.tsinghua.edu.cn/simple/" +default = true + [build-system] requires = ["hatchling"] diff --git a/runtime/Dockerfile b/runtime/Dockerfile index 0d7cee2..cbb2f85 100644 --- a/runtime/Dockerfile +++ b/runtime/Dockerfile @@ -20,9 +20,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY pyproject.toml uv.lock ./ COPY common ./common -COPY contracts ./contracts COPY runtime ./runtime RUN UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv sync --frozen --no-dev --no-editable --package runtime EXPOSE 8000 -CMD ["uv", "run", "--frozen", "--package", "runtime", "uvicorn", "runtime.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uv", "run", "--frozen", "--package", "runtime", "gunicorn", "--config", "runtime/gunicorn.conf.py", "runtime.main:app"] diff --git a/runtime/gunicorn.conf.py b/runtime/gunicorn.conf.py new file mode 100644 index 0000000..48f42d4 --- /dev/null +++ b/runtime/gunicorn.conf.py @@ -0,0 +1,15 @@ +"""Gunicorn configuration for the runtime service. + +Workers MUST stay at 1: file leases and Jupyter tickets live in +in-memory process state, so a second worker would split that state. +""" + +import os + +bind = os.getenv("GUNICORN_BIND", "0.0.0.0:8000") +workers = int(os.getenv("GUNICORN_WORKERS", "1")) +worker_class = "uvicorn.workers.UvicornWorker" +timeout = int(os.getenv("GUNICORN_TIMEOUT", "120")) +graceful_timeout = int(os.getenv("GUNICORN_GRACEFUL_TIMEOUT", "30")) +accesslog = "-" +errorlog = "-" diff --git a/runtime/pyproject.toml b/runtime/pyproject.toml index 3aa3113..0073dec 100644 --- a/runtime/pyproject.toml +++ b/runtime/pyproject.toml @@ -9,11 +9,16 @@ dependencies = [ "httpx==0.28.1", "loguru==0.7.2", "notebook", + "gunicorn>=26.0.0", ] [tool.uv.sources] common = { workspace = true } +[[tool.uv.index]] +url = "https://pypi.tuna.tsinghua.edu.cn/simple/" +default = true + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/schedule/Dockerfile b/schedule/Dockerfile index c88ed6c..00dfc3d 100644 --- a/schedule/Dockerfile +++ b/schedule/Dockerfile @@ -2,11 +2,21 @@ FROM python:3.12-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + gcc \ + g++ \ + python3-dev \ + build-essential \ + && rm -rf /var/lib/apt/lists/* \ + COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv +COPY pyproject.toml uv.lock ./ COPY common ./common COPY schedule ./schedule -COPY contracts ./contracts RUN UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple/" uv sync --frozen --no-dev --no-editable --package schedule EXPOSE 8000 -CMD ["uv", "run", "uvicorn", "schedule.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uv", "run", "--frozen", "--package", "schedule", "gunicorn", "--config", "schedule/gunicorn.conf.py", "schedule.main:app"] diff --git a/schedule/gunicorn.conf.py b/schedule/gunicorn.conf.py new file mode 100644 index 0000000..8f2ca9e --- /dev/null +++ b/schedule/gunicorn.conf.py @@ -0,0 +1,17 @@ +"""Gunicorn configuration for the schedule service. + +Workers MUST stay at 1: the embedded APScheduler and the Outbox +poller must not run twice in the same container. +""" + +import os + +bind = os.getenv("GUNICORN_BIND", "0.0.0.0:8000") +workers = int(os.getenv("GUNICORN_WORKERS", "1")) +worker_class = "uvicorn.workers.UvicornWorker" +# 0 disables gunicorn worker heartbeat timeout — needed because +# NodeExecutor runs notebooks synchronously. +timeout = int(os.getenv("GUNICORN_TIMEOUT", "0")) +graceful_timeout = int(os.getenv("GUNICORN_GRACEFUL_TIMEOUT", "30")) +accesslog = "-" +errorlog = "-" diff --git a/schedule/pyproject.toml b/schedule/pyproject.toml index b116545..ec08d53 100644 --- a/schedule/pyproject.toml +++ b/schedule/pyproject.toml @@ -12,11 +12,16 @@ dependencies = [ "nbclient==0.10.2", "nbformat==5.10.4", "ipykernel==6.29.5", + "gunicorn>=26.0.0", ] [tool.uv.sources] common = { workspace = true } +[[tool.uv.index]] +url = "https://pypi.tuna.tsinghua.edu.cn/simple/" +default = true + [build-system] requires = ["hatchling"] diff --git a/uv.lock b/uv.lock index 0460157..cd7c8f2 100644 --- a/uv.lock +++ b/uv.lock @@ -198,6 +198,7 @@ dependencies = [ { name = "croniter" }, { name = "cryptography" }, { name = "fastapi" }, + { name = "gunicorn" }, { name = "httpx" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -209,6 +210,7 @@ requires-dist = [ { name = "croniter", specifier = "==6.2.4" }, { name = "cryptography", specifier = "==49.0.0" }, { name = "fastapi", specifier = "==0.116.1" }, + { name = "gunicorn", specifier = ">=26.0.0" }, { name = "httpx", specifier = "==0.28.1" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" }, ] @@ -690,6 +692,18 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, ] +[[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1864,6 +1878,7 @@ source = { editable = "runtime" } dependencies = [ { name = "common" }, { name = "fastapi" }, + { name = "gunicorn" }, { name = "httpx" }, { name = "loguru" }, { name = "notebook" }, @@ -1874,6 +1889,7 @@ dependencies = [ requires-dist = [ { name = "common", editable = "common" }, { name = "fastapi", specifier = "==0.116.1" }, + { name = "gunicorn", specifier = ">=26.0.0" }, { name = "httpx", specifier = "==0.28.1" }, { name = "loguru", specifier = "==0.7.2" }, { name = "notebook" }, @@ -1900,6 +1916,7 @@ dependencies = [ { name = "apscheduler" }, { name = "common" }, { name = "fastapi" }, + { name = "gunicorn" }, { name = "httpx" }, { name = "ipykernel" }, { name = "nbclient" }, @@ -1913,6 +1930,7 @@ requires-dist = [ { name = "apscheduler", specifier = "==3.11.3" }, { name = "common", editable = "common" }, { name = "fastapi", specifier = "==0.116.1" }, + { name = "gunicorn", specifier = ">=26.0.0" }, { name = "httpx", specifier = "==0.28.1" }, { name = "ipykernel", specifier = "==6.29.5" }, { name = "nbclient", specifier = "==0.10.2" },