Add GraalPy 25 image replacing CPython
Multi-stage Dockerfile.graalpy on ghcr.io/graalvm/graalpy-community:25 that builds the granian and rloop PyO3 extensions from source against GraalPy (no GraalPy wheels exist), installs the pure-Python deps, and serves the unchanged RSGI app on the rloop event loop. Adds docker-compose.graalpy.yml and a CI job publishing the image as the *-graalpy tags on release/*.
This commit is contained in:
@@ -15,6 +15,8 @@ dist/
|
|||||||
build/
|
build/
|
||||||
tests/
|
tests/
|
||||||
Dockerfile
|
Dockerfile
|
||||||
|
Dockerfile.graalpy
|
||||||
.dockerignore
|
.dockerignore
|
||||||
opencode.json
|
opencode.json
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
docker-compose.graalpy.yml
|
||||||
|
|||||||
@@ -36,3 +36,38 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
pull: true
|
pull: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|
||||||
|
build_and_push_graalpy_docker_image:
|
||||||
|
name: "Build and push GraalPy 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 GraalPy image tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: gitea.woggioni.net/woggioni/pyfconfig
|
||||||
|
flavor: |
|
||||||
|
latest=false
|
||||||
|
suffix=-graalpy
|
||||||
|
tags: |
|
||||||
|
type=match,pattern=release/(.*),group=1
|
||||||
|
type=raw,value=latest
|
||||||
|
- name: Build and push GraalPy docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile.graalpy
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
pull: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Multi-stage production build for pyfconfig running on GraalPy 25 instead of
|
||||||
|
# CPython. Base: ghcr.io/graalvm/graalpy-community:25 (GraalPy 25.0.2 /
|
||||||
|
# Python 3.12.8, Oracle Linux 9 slim).
|
||||||
|
#
|
||||||
|
# GraalPy is not ABI-compatible with CPython wheels, so the two compiled
|
||||||
|
# dependencies (granian and rloop, both PyO3/Rust extensions) have to be built
|
||||||
|
# from source against the GraalPy interpreter. Everything else in
|
||||||
|
# requirements.txt is pure Python and installs normally.
|
||||||
|
#
|
||||||
|
# The Rust toolchain and build artifacts live only in the builder stage and are
|
||||||
|
# discarded in the runtime image. BuildKit cache mounts (type=cache) keep the
|
||||||
|
# cargo registry and target dir across builds without baking them into layers;
|
||||||
|
# requires BuildKit (default for `docker build` / `docker buildx` on modern
|
||||||
|
# daemons).
|
||||||
|
|
||||||
|
ARG GRAALPY_IMAGE=ghcr.io/graalvm/graalpy-community:25
|
||||||
|
|
||||||
|
# --- Builder ---------------------------------------------------------------
|
||||||
|
FROM ${GRAALPY_IMAGE} AS builder
|
||||||
|
|
||||||
|
# Rust toolchain for building the PyO3-based extensions. Keep the registry and
|
||||||
|
# target dir in BuildKit cache mounts so rebuilds are cheap.
|
||||||
|
# /opt/venv/bin must be on PATH so pip's maturin build backend can find the
|
||||||
|
# maturin executable; /root/.cargo/bin provides cargo/rustc.
|
||||||
|
ENV CARGO_HOME=/root/.cargo \
|
||||||
|
RUSTUP_HOME=/root/.rustup \
|
||||||
|
CARGO_TARGET_DIR=/build/target \
|
||||||
|
PATH=/opt/venv/bin:/root/.cargo/bin:${PATH}
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.cargo/registry \
|
||||||
|
--mount=type=cache,target=/root/.cargo/git \
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --profile minimal --no-modify-path
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Use GraalPy's own pip: it is patched for native-package compatibility and
|
||||||
|
# preconfigured with GraalPy wheel repositories, so it must not be upgraded.
|
||||||
|
RUN graalpy -m venv /opt/venv \
|
||||||
|
&& /opt/venv/bin/pip install maturin
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md requirements.txt ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Build the Rust extensions from their sdists against GraalPy first (forcing
|
||||||
|
# source builds with --no-build-isolation so the preinstalled maturin is used),
|
||||||
|
# then the pure-Python deps, then the project itself. granian/rloop are already
|
||||||
|
# satisfied when requirements.txt is installed.
|
||||||
|
RUN --mount=type=cache,target=/root/.cargo/registry \
|
||||||
|
--mount=type=cache,target=/root/.cargo/git \
|
||||||
|
--mount=type=cache,target=/build/target \
|
||||||
|
/opt/venv/bin/pip install --no-build-isolation \
|
||||||
|
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple \
|
||||||
|
--extra-index-url https://pypi.org/simple \
|
||||||
|
granian==2.8.2 rloop==0.5.0 \
|
||||||
|
&& /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.txt \
|
||||||
|
&& /opt/venv/bin/pip install --no-deps .
|
||||||
|
|
||||||
|
# --- Runtime ---------------------------------------------------------------
|
||||||
|
FROM ${GRAALPY_IMAGE}
|
||||||
|
|
||||||
|
RUN useradd -r -s /sbin/nologin -d /home/app -m app
|
||||||
|
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH" \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
HOME=/home/app \
|
||||||
|
GRANIAN_HOST=0.0.0.0 \
|
||||||
|
GRANIAN_PORT=8080 \
|
||||||
|
GRANIAN_INTERFACE=rsgi \
|
||||||
|
GRANIAN_LOOP=rloop
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||||
|
CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["granian", "pyfconfig.app:app"]
|
||||||
@@ -16,6 +16,13 @@ over the RSGI protocol.
|
|||||||
|
|
||||||
No database, sessions, or authentication — the app is stateless.
|
No database, sessions, or authentication — the app is stateless.
|
||||||
|
|
||||||
|
A second image runs the exact same app on [GraalPy](https://www.graalvm.org/python/)
|
||||||
|
(GraalVM Community Edition 25) instead of CPython — see
|
||||||
|
[Dockerfile.graalpy](Dockerfile.graalpy) and the
|
||||||
|
[GraalPy image](#graalpy-image) section. Because GraalPy is not ABI-compatible
|
||||||
|
with CPython wheels, the compiled `granian` and `rloop` extensions are built
|
||||||
|
from source against GraalPy during the image build.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
| Method | Path | Response |
|
| Method | Path | Response |
|
||||||
@@ -62,6 +69,19 @@ docker compose up -d --build
|
|||||||
# App published at http://localhost:8080
|
# App published at http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### GraalPy image
|
||||||
|
|
||||||
|
An equivalent image based on `ghcr.io/graalvm/graalpy-community:25`
|
||||||
|
(GraalPy 25.0.2 / Python 3.12.8) is available via a separate compose file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.graalpy.yml up -d --build
|
||||||
|
# App published at http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
This image is published to the registry as the `*-graalpy` tags
|
||||||
|
(e.g. `latest-graalpy`, `0.1.0-graalpy`).
|
||||||
|
|
||||||
### On the host
|
### On the host
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -121,7 +141,15 @@ register handlers with `@app.GET(...)` decorators at import time —
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
The `Dockerfile` is a multi-stage alpine build producing a minimal,
|
Two images are built from this repository:
|
||||||
non-root runtime image; pushing a `release/*` tag triggers the Gitea
|
|
||||||
Actions workflow in `.gitea/workflows/build.yaml`, which builds and
|
- `Dockerfile` — multi-stage alpine build on CPython, producing a minimal,
|
||||||
publishes the image to `gitea.woggioni.net/woggioni-opencode-agent/pyfconfig`.
|
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).
|
||||||
|
|
||||||
|
Pushing a `release/*` tag triggers the Gitea Actions workflow in
|
||||||
|
`.gitea/workflows/build.yaml`, which builds and publishes both images to
|
||||||
|
`gitea.woggioni.net/woggioni/pyfconfig`: the CPython image as `latest` /
|
||||||
|
`<version>`, and the GraalPy image as `latest-graalpy` / `<version>-graalpy`.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# GraalPy variant of the app. Standalone file (not an override) so it can be
|
||||||
|
# run alongside the default compose file:
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.graalpy.yml up -d --build
|
||||||
|
#
|
||||||
|
# The app runs on GraalPy 25 instead of CPython; see Dockerfile.graalpy.
|
||||||
|
services:
|
||||||
|
pyfconfig-graalpy:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.graalpy
|
||||||
|
image: pyfconfig-graalpy: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"
|
||||||
Reference in New Issue
Block a user