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:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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` /
|
||||
`<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`.
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -21,6 +21,7 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"mypy",
|
||||
"hypercorn",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user