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.
This commit is contained in:
2026-09-24 11:11:00 +00:00
parent 609d9e22a8
commit 4fa6c0cf2e
8 changed files with 326 additions and 3 deletions
+2
View File
@@ -16,7 +16,9 @@ build/
tests/ tests/
Dockerfile Dockerfile
Dockerfile.graalpy Dockerfile.graalpy
Dockerfile.native
.dockerignore .dockerignore
opencode.json opencode.json
docker-compose.yml docker-compose.yml
docker-compose.graalpy.yml docker-compose.graalpy.yml
docker-compose.native.yml
+35
View File
@@ -71,3 +71,38 @@ jobs:
push: true push: true
pull: true pull: true
tags: ${{ steps.meta.outputs.tags }} 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 }}
+81
View File
@@ -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"]
+45 -3
View File
@@ -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 with CPython wheels, the compiled `granian` and `rloop` extensions are built
from source against GraalPy during the image build. 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 ## Endpoints
| Method | Path | Response | | 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 This image is published to the registry as the `*-graalpy` tags
(e.g. `latest-graalpy`, `0.1.0-graalpy`). (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 ### On the host
```bash ```bash
@@ -133,6 +169,8 @@ src/pyfconfig/
└── connection.py # /ip /ua /lang /encoding /mime /charset /forwarded /all /all.json └── connection.py # /ip /ua /lang /encoding /mime /charset /forwarded /all /all.json
tests/ tests/
└── test_routes.py # unittest + httpx.ASGITransport └── 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 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 ## 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, - `Dockerfile` — multi-stage alpine build on CPython, producing a minimal,
non-root runtime image. non-root runtime image.
- `Dockerfile.graalpy` — multi-stage Oracle Linux build on GraalPy 25, where - `Dockerfile.graalpy` — multi-stage Oracle Linux build on GraalPy 25, where
the compiled `granian` and `rloop` extensions are compiled from source the compiled `granian` and `rloop` extensions are compiled from source
against GraalPy (the Rust toolchain stays in the builder stage). 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 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` / `gitea.woggioni.net/woggioni/pyfconfig`: the CPython image as `latest` /
`<version>`, and the GraalPy image as `latest-graalpy` / `<version>-graalpy`. `<version>`, the GraalPy image as `latest-graalpy` / `<version>-graalpy`, and
the native executable as `latest-native` / `<version>-native`.
+24
View File
@@ -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"
+100
View File
@@ -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()
+1
View File
@@ -21,6 +21,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"mypy", "mypy",
"hypercorn",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
+38
View File
@@ -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