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/
|
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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
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`.
|
||||||
|
|||||||
@@ -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]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"mypy",
|
"mypy",
|
||||||
|
"hypercorn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[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