From 4fa6c0cf2e0c3bb8bd5fb423302457c774492e72 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Thu, 24 Sep 2026 11:11:00 +0000 Subject: [PATCH] Add self-contained GraalPy native executable Build the app to a single native binary with graalpy's 'standalone native' command (Dockerfile.native). granian/rloop cannot be bundled into a standalone binary, so the executable serves the app over ASGI with Hypercorn via native/entrypoint.py, which also works around GraalPy standalone quirks (socket.listen, str response bodies, signal handling). Adds docker-compose.native.yml, a -native CI job, and README docs. --- .dockerignore | 2 + .gitea/workflows/build.yaml | 35 +++++++++++++ Dockerfile.native | 81 +++++++++++++++++++++++++++++ README.md | 48 +++++++++++++++-- docker-compose.native.yml | 24 +++++++++ native/entrypoint.py | 100 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + requirements-native.txt | 38 ++++++++++++++ 8 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 Dockerfile.native create mode 100644 docker-compose.native.yml create mode 100644 native/entrypoint.py create mode 100644 requirements-native.txt diff --git a/.dockerignore b/.dockerignore index 64f76b5..ab70d15 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,7 +16,9 @@ build/ tests/ Dockerfile Dockerfile.graalpy +Dockerfile.native .dockerignore opencode.json docker-compose.yml docker-compose.graalpy.yml +docker-compose.native.yml diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index e4f83ba..daba52b 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -71,3 +71,38 @@ jobs: push: true pull: true tags: ${{ steps.meta.outputs.tags }} + + build_and_push_native_docker_image: + name: "Build and push native docker image" + runs-on: hostinger + steps: + - name: Checkout sources + uses: actions/checkout@v6 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Gitea container registry + uses: docker/login-action@v3 + with: + registry: gitea.woggioni.net + username: woggioni-opencode-agent + password: ${{ secrets.PUBLISHER_TOKEN }} + - name: Extract metadata for native image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: gitea.woggioni.net/woggioni/pyfconfig + flavor: | + latest=false + suffix=-native + tags: | + type=match,pattern=release/(.*),group=1 + type=raw,value=latest + - name: Build and push native docker image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.native + platforms: linux/amd64 + push: true + pull: true + tags: ${{ steps.meta.outputs.tags }} diff --git a/Dockerfile.native b/Dockerfile.native new file mode 100644 index 0000000..6306b28 --- /dev/null +++ b/Dockerfile.native @@ -0,0 +1,81 @@ +# syntax=docker/dockerfile:1 +# Native standalone build for pyfconfig. +# +# Produces a single self-contained executable bundling the app, its +# dependencies, the Python standard library and the GraalPy runtime, using +# GraalPy's `standalone native` command (backed by GraalVM Native Image). +# +# Unlike Dockerfile.graalpy, the executable does not run granian/rloop: those +# are native extensions that cannot be bundled into a standalone binary and +# granian spawns workers through multiprocessing. The app is served over ASGI +# by Hypercorn (pure Python) instead; see native/entrypoint.py. +# +# Build requirements: network access to the Gitea registry, PyPI, Maven Central +# and the base images, plus enough RAM for native-image (see NATIVE_IMAGE_XMX). + +ARG GRAALPY_IMAGE=ghcr.io/graalvm/graalpy-community:25 +ARG NATIVE_IMAGE_TOOLS=ghcr.io/graalvm/native-image-community:25 + +# GraalVM CE with native-image and javac, version-matched to the GraalPy +# distribution below. Copied into the builder so the standalone build does not +# download a JDK at build time. +FROM ${NATIVE_IMAGE_TOOLS} AS tools + +# --- Builder --------------------------------------------------------------- +FROM ${GRAALPY_IMAGE} AS builder + +ARG NATIVE_IMAGE_XMX=5500m + +COPY --from=tools /usr/lib64/graalvm/graalvm-community-java25 /usr/lib64/graalvm/graalvm-community-java25 +ENV JAVA_HOME=/usr/lib64/graalvm/graalvm-community-java25 \ + PATH=/usr/lib64/graalvm/graalvm-community-java25/bin:${PATH} + +# native-image links the executable against zlib; the runtime library is +# present but the linker needs the -devel package (libz.so). +RUN dnf install -y zlib-devel \ + && dnf clean all + +WORKDIR /build + +COPY pyproject.toml README.md requirements-native.txt ./ +COPY src/ ./src/ +COPY native/ ./native/ + +RUN graalpy -m venv /opt/venv \ + && /opt/venv/bin/pip install \ + --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple \ + --extra-index-url https://pypi.org/simple \ + -r requirements-native.txt \ + && /opt/venv/bin/pip install --no-deps . + +# -Os builds an interpreter-only Truffle runtime: much lower build memory and a +# smaller binary, at the cost of peak throughput. Remove it to enable the JIT. +# The cache mount keeps the Maven artifacts across builds. +RUN --mount=type=cache,target=/opt/graalpy-25.0.2/downloaded_standalone_resources \ + mkdir -p /out \ + && graalpy -m standalone native \ + -m native/entrypoint.py \ + --venv /opt/venv \ + -o /out/pyfconfig \ + -ce -Os \ + -N-J-Xmx${NATIVE_IMAGE_XMX} -N-J-Xms512m + +# --- Runtime --------------------------------------------------------------- +FROM oraclelinux:10-slim + +RUN useradd -r -s /sbin/nologin -d /home/app -m app + +COPY --from=builder /out/pyfconfig /usr/local/bin/pyfconfig + +ENV HOME=/home/app \ + HOST=0.0.0.0 \ + PORT=8080 + +USER app + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ + CMD curl -fsS "http://127.0.0.1:${PORT}/api/health" || exit 1 + +CMD ["/usr/local/bin/pyfconfig"] diff --git a/README.md b/README.md index 6de248f..1d91bee 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,14 @@ A second image runs the exact same app on [GraalPy](https://www.graalvm.org/pyth with CPython wheels, the compiled `granian` and `rloop` extensions are built from source against GraalPy during the image build. +A third image compiles the app to a **single self-contained native +executable** with GraalPy's `standalone native` command — see +[Dockerfile.native](Dockerfile.native) and the +[native executable](#native-executable) section. That build serves the app +over ASGI with [Hypercorn](https://hypercorn.readthedocs.io/) instead of +granian/rloop, because compiled extensions cannot be bundled into a standalone +binary. + ## Endpoints | Method | Path | Response | @@ -82,6 +90,34 @@ docker compose -f docker-compose.graalpy.yml up -d --build This image is published to the registry as the `*-graalpy` tags (e.g. `latest-graalpy`, `0.1.0-graalpy`). +### Native executable + +The app can be compiled to a single self-contained executable (GraalPy runtime ++ standard library + dependencies + app, no Python installation required) with +GraalPy's `standalone native` command: + +```bash +docker compose -f docker-compose.native.yml up -d --build +# App published at http://localhost:8080 +``` + +The executable is built in [Dockerfile.native](Dockerfile.native) on top of +`ghcr.io/graalvm/graalpy-community:25` with the version-matched +`native-image` toolchain, and runs on a slim `oraclelinux:10-slim` base. It is +published as the `*-native` tags (e.g. `latest-native`, `0.1.0-native`). + +Notes: + +- The native build is memory-hungry: `native-image` needs several GB of RAM + (tunable with the `NATIVE_IMAGE_XMX` build arg, default `5500m`) and takes + roughly 8 minutes per build. +- It uses GraalPy's interpreter-only runtime (`-Os`) for a smaller binary and + a lighter build. Remove `-Os` in [Dockerfile.native](Dockerfile.native) to + enable the JIT at the cost of build time and memory. +- The native executable serves HTTP via Hypercorn over ASGI rather than + granian/rloop (see [native/entrypoint.py](native/entrypoint.py)). +- `HOST` / `PORT` configure the bind address (defaults `0.0.0.0:8080`). + ### On the host ```bash @@ -133,6 +169,8 @@ src/pyfconfig/ └── connection.py # /ip /ua /lang /encoding /mime /charset /forwarded /all /all.json tests/ └── test_routes.py # unittest + httpx.ASGITransport +native/ +└── entrypoint.py # Hypercorn ASGI entry point for the native executable ``` Route modules import the module-level `app` from `pyfconfig.app` and @@ -141,15 +179,19 @@ register handlers with `@app.GET(...)` decorators at import time — ## Deployment -Two images are built from this repository: +Three images are built from this repository: - `Dockerfile` — multi-stage alpine build on CPython, producing a minimal, non-root runtime image. - `Dockerfile.graalpy` — multi-stage Oracle Linux build on GraalPy 25, where the compiled `granian` and `rloop` extensions are compiled from source against GraalPy (the Rust toolchain stays in the builder stage). +- `Dockerfile.native` — compiles the app to a single self-contained native + executable with GraalPy's `standalone native` command, served by Hypercorn + over ASGI; the runtime image is a slim `oraclelinux:10-slim`. Pushing a `release/*` tag triggers the Gitea Actions workflow in -`.gitea/workflows/build.yaml`, which builds and publishes both images to +`.gitea/workflows/build.yaml`, which builds and publishes all three images to `gitea.woggioni.net/woggioni/pyfconfig`: the CPython image as `latest` / -``, and the GraalPy image as `latest-graalpy` / `-graalpy`. +``, the GraalPy image as `latest-graalpy` / `-graalpy`, and +the native executable as `latest-native` / `-native`. diff --git a/docker-compose.native.yml b/docker-compose.native.yml new file mode 100644 index 0000000..d3b43fc --- /dev/null +++ b/docker-compose.native.yml @@ -0,0 +1,24 @@ +# Native standalone variant of the app. Standalone file (not an override) so +# it can be run alongside the other compose files: +# +# docker compose -f docker-compose.native.yml up -d --build +# +# The app is compiled to a single self-contained GraalPy native executable; see +# Dockerfile.native. +services: + pyfconfig-native: + build: + context: . + dockerfile: Dockerfile.native + image: pyfconfig-native:latest + restart: unless-stopped + environment: + # Public name shown in the HTML page and the curl examples; set to the + # deployment domain (e.g. "ifconfig.example.com"). + SITE_NAME: ${SITE_NAME:-pyfconfig} + # Comma-separated CIDRs of trusted reverse proxies (e.g. + # "172.16.0.0/12"); required for correct client IPs when deployed + # behind a reverse proxy. Empty means no proxy is trusted. + TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-} + ports: + - "127.0.0.1:8080:8080" diff --git a/native/entrypoint.py b/native/entrypoint.py new file mode 100644 index 0000000..11a12ca --- /dev/null +++ b/native/entrypoint.py @@ -0,0 +1,100 @@ +"""Entry point for the GraalPy native standalone executable. + +Serves the pyfconfig ASGI app with Hypercorn. ``graalpy -m standalone native`` +(see ``Dockerfile.native``) bundles this file, the virtual environment and the +GraalPy runtime into a single self-contained executable. + +granian and rloop are not used here: they are native extensions that cannot be +bundled into a standalone binary, and granian spawns its workers through +``multiprocessing``. Hypercorn is pure Python and runs in-process. +""" +from __future__ import annotations + +import asyncio +import os +import signal +import socket +from types import FrameType +from typing import Any, Callable, Optional + +from hypercorn.asyncio import serve +from hypercorn.config import Config + +from pyfconfig.app import app + + +def _patch_socket_listen() -> None: + """Make ``socket.listen`` retry with a blocking socket. + + In a GraalPy standalone binary the POSIX layer runs on GraalPy's Java + backend, where asyncio's ``sock.listen()`` on the non-blocking server + socket raises ``OSError(95, "Operation not supported on transport + endpoint")``. Listening on a temporarily blocking socket succeeds. + """ + original: Callable[..., None] = socket.socket.listen + + def listen(self: socket.socket, backlog: int = socket.SOMAXCONN) -> None: + try: + original(self, backlog) + except OSError as exc: + if exc.errno != 95: + raise + blocking = self.getblocking() + self.setblocking(True) + try: + original(self, backlog) + finally: + self.setblocking(blocking) + + socket.socket.listen = listen # type: ignore[method-assign] + + +async def _asgi(scope: Any, receive: Any, send: Any) -> None: + """Serve the app, coercing ``str`` response bodies to ``bytes``. + + kaya's ASGI adapter sends ``''`` (a ``str``) for empty and streamed bodies + (see ``AsgiContext.send_empty`` / ``stream_body``). The ASGI spec requires + ``bytes``, and Hypercorn rejects a ``str`` body, so normalize it here. + """ + + async def _send(message: Any) -> None: + body = message.get("body") + if message.get("type") == "http.response.body" and isinstance(body, str): + message = dict(message, body=body.encode()) + await send(message) + + await app(scope, receive, _send) + + +async def _serve() -> None: + config = Config() + config.bind = [f"{os.environ.get('HOST', '0.0.0.0')}:{os.environ.get('PORT', '8080')}"] + + stop = asyncio.Event() + loop = asyncio.get_running_loop() + + def _shutdown(signum: int, frame: Optional[FrameType]) -> None: + loop.call_soon_threadsafe(stop.set) + + for name in ("SIGINT", "SIGTERM"): + sig: Any = getattr(signal, name, None) + if sig is not None: + signal.signal(sig, _shutdown) + + # A custom shutdown_trigger keeps Hypercorn from installing its own signal + # handlers (loop.add_signal_handler is unreliable in the standalone runtime). + await serve(_asgi, config, shutdown_trigger=stop.wait) + + +def main() -> None: + _patch_socket_listen() + try: + asyncio.run(_serve()) + except asyncio.CancelledError: + # Hypercorn cancels the serve task while shutting down after SIGTERM; + # treat that as a clean exit. + pass + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index ee37ffa..88dfb45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "mypy", + "hypercorn", ] [tool.setuptools.packages.find] diff --git a/requirements-native.txt b/requirements-native.txt new file mode 100644 index 0000000..6905f2a --- /dev/null +++ b/requirements-native.txt @@ -0,0 +1,38 @@ +# +# Runtime dependencies for the GraalPy native standalone executable +# (Dockerfile.native). Unlike requirements.txt this deliberately excludes +# granian, rloop and kaya-rsgi: the standalone binary serves the app over ASGI +# with Hypercorn, because those compiled extensions cannot be bundled into a +# native executable. +# +--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple +--extra-index-url https://pypi.org/simple + +hypercorn==0.18.0 + # via pyfconfig (native) +h11==0.16.0 + # via hypercorn +h2==4.4.1 + # via hypercorn +hpack==4.2.0 + # via h2 +hyperframe==6.1.0 + # via h2 +priority==2.0.0 + # via hypercorn +wsproto==1.3.2 + # via hypercorn +kaya-core==0.0.3 + # via + # kaya-forwarded + # pyfconfig (pyproject.toml) +kaya-forwarded==0.0.3 + # via pyfconfig (pyproject.toml) +pwo==0.1.2 + # via + # kaya-core + # pyfconfig (pyproject.toml) +typing-extensions==4.16.0 + # via + # kaya-core + # pwo