From 0042690a587a4e3aab2322686376a2dd5342f6d5 Mon Sep 17 00:00:00 2001 From: Francisco Perez Date: Tue, 23 Jun 2026 15:55:08 -0500 Subject: [PATCH] chore: switch Docker image to Alpine multi-stage build --- CHANGELOG.md | 4 ++++ Dockerfile | 46 +++++++++++++++++++++++++++++++++++++++------- RELEASING.md | 8 +++++++- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 719dd32..867cb50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- The Docker image now uses an Alpine base with a multi-stage build, producing a + smaller image (~99 MB). It continues to run as a non-root user. + ## [0.3.0] - 2026-06-22 ### Added diff --git a/Dockerfile b/Dockerfile index f8c23d2..43319ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,15 +4,42 @@ # always matches the published release of the same version. Pass # `--build-arg VERSION=X.Y.Z` to pin a specific release; with no VERSION the # latest release on PyPI is installed. -ARG PYTHON_VERSION=3.10-slim -FROM python:${PYTHON_VERSION} +# +# Notes: +# * Alpine base — small footprint (~99 MB image). +# * Multi-stage build — the compiler toolchain (psycopg2 has no musl wheel and +# is built from source) stays in the builder stage and never reaches the +# runtime image, keeping it minimal. +# * Runs as an unprivileged user. +ARG PYTHON_VERSION=3.13-alpine + +# --- Builder: compile psycopg2 and install the package into a venv ----------- +FROM python:${PYTHON_VERSION} AS builder ARG VERSION= -# PostgreSQL + MySQL/MariaDB drivers come from the package extras; SQLite is -# built into Python. `${VERSION:+==${VERSION}}` expands to `==X.Y.Z` only when -# VERSION is set, otherwise installs the latest release. -RUN pip install --no-cache-dir "sql2json[postgres,mysql]${VERSION:+==${VERSION}}" +ENV PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# psycopg2-binary ships only glibc (manylinux) wheels, so on Alpine/musl it is +# built from source — that needs a C toolchain and the libpq headers. PyMySQL is +# pure Python and SQLite is built into Python, so no other build deps are needed. +RUN apk add --no-cache build-base postgresql-dev + +# `${VERSION:+==${VERSION}}` expands to `==X.Y.Z` only when VERSION is set, +# otherwise installs the latest release. +RUN python -m venv /opt/venv \ + && /opt/venv/bin/pip install --upgrade pip \ + && /opt/venv/bin/pip install "sql2json[postgres,mysql]${VERSION:+==${VERSION}}" + +# --- Runtime: minimal Alpine carrying only the venv and libpq ---------------- +FROM python:${PYTHON_VERSION} AS runtime + +ARG VERSION= + +# libpq is the only runtime shared library psycopg2 needs; the build toolchain +# is intentionally left behind in the builder stage. +RUN apk add --no-cache libpq LABEL org.opencontainers.image.title="sql2json" \ org.opencontainers.image.description="Run SQL queries via SQLAlchemy and output JSON, CSV, or Excel." \ @@ -20,10 +47,15 @@ LABEL org.opencontainers.image.title="sql2json" \ org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.version="${VERSION}" +# Copy the ready-to-run virtualenv from the builder and put it first on PATH so +# `sql2json` (and `pip`, for the release verify step) resolve from it. +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + # Run as an unprivileged user. The home directory holds the config # (`/home/app/.sql2json`) and `/workspace` is the writable working dir for # `--output` files; both are owned by `app`. -RUN useradd --create-home --uid 1000 app \ +RUN adduser -D -u 1000 app \ && mkdir -p /workspace \ && chown app:app /workspace USER app diff --git a/RELEASING.md b/RELEASING.md index fff93bf..72fdc37 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -76,6 +76,12 @@ requesting one rather than bumping the version yourself. version has to exist on PyPI first). Both Podman and Docker are shown; either works. + The image is Alpine-based, and the PostgreSQL driver (`psycopg2`) has no musl + wheel, so the build compiles it from source. This is handled inside the + Dockerfile (the build stage installs the needed toolchain), but it means the + `linux/arm64` leg compiles under QEMU emulation and takes noticeably longer + than a native build — expect a few extra minutes, not a failure. + First, log in to Docker Hub once (use a Docker Hub [access token](https://hub.docker.com/settings/security) as the password, not your account password): @@ -156,7 +162,7 @@ requesting one rather than bumping the version yourself. > **Cached-arch base-image gotcha.** After a cross-arch / multi-arch build > attempt, your local image store can hold a *non-native* base image (e.g. an - > `arm64` `python:3.10-slim` pulled during an `--platform linux/arm64` + > `arm64` `python:3.13-alpine` pulled during an `--platform linux/arm64` > experiment). Podman will silently reuse that cached base for a subsequent > plain `podman build`, producing an image that fails to run with > `exec /bin/sh: Exec format error` and a build-time warning like