From 2d18547aaa3d9a1759b1f3a3d3856891100f037e Mon Sep 17 00:00:00 2001 From: Robert Neumann Date: Mon, 7 Sep 2026 08:13:09 +0200 Subject: [PATCH] build: multi-stage Docker image with uv and BuildKit caching (#1261) * build: multi-stage Docker image with uv and BuildKit caching Builder/runtime split keeps the toolchain out of the runtime image (standalone ~1.8GB -> ~1.2GB). Dependencies install with uv from uv.lock in a cache-mounted, source-independent layer. Runtime keeps the editable install because akkudoktoreos.core.version needs the src/ layout. Also fixes the io.hass.version label and adds a HEALTHCHECK. * build: address Docker image review feedback - Healthcheck honours EOS_SERVER__PORT instead of hardcoding 8503. - BUILD_VERSION defaults to "dev" rather than the literal "VERSION"; docker-compose passes the real version, and the redundant org.opencontainers.image.version label is dropped (CI sets it via docker/metadata-action). * fix: use resolved server port for container healthcheck --------- Co-authored-by: Normann Co-authored-by: Normann Koldrack --- .github/workflows/docker-build.yml | 2 + Dockerfile | 112 +++++++++++------- docker-compose.yaml | 1 + .../server/container_healthcheck.py | 52 ++++++++ src/akkudoktoreos/server/eos.py | 4 + 5 files changed, 128 insertions(+), 43 deletions(-) create mode 100644 src/akkudoktoreos/server/container_healthcheck.py diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index fd33ca24..5bad10da 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -119,6 +119,8 @@ jobs: uses: docker/build-push-action@v7 with: context: . + build-args: | + BUILD_VERSION=${{ steps.meta.outputs.version }} platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} diff --git a/Dockerfile b/Dockerfile index a8241e18..4cb99df9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,67 @@ # syntax=docker/dockerfile:1.7 # Dockerfile -# Support both Home Assistant builds and standalone builds -# Only Debian based images are supported (no Alpine) +# Support both Home Assistant builds and standalone builds. +# Only Debian based images are supported (no Alpine). ARG BUILD_FROM ARG PYTHON_VERSION=3.13.15 -# If BUILD_FROM is set (Home Assistant), use it; otherwise use python-slim -FROM ${BUILD_FROM:-python:${PYTHON_VERSION}-slim} +# Builder and runtime share the same base so the copied virtualenv is ABI-safe. +# If BUILD_FROM is set (Home Assistant), use it; otherwise use python-slim. +FROM ${BUILD_FROM:-python:${PYTHON_VERSION}-slim} AS builder + +# uv: pinned, copied as a static binary (no extra Python packages installed). +COPY --from=ghcr.io/astral-sh/uv:0.12.7 /uv /bin/uv + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + VIRTUAL_ENV=/opt/venv \ + PATH="/opt/venv/bin:$PATH" + +WORKDIR /opt/eos + +# Build toolchain for the numpy/scipy/pandas/matplotlib stack. python3 is +# explicit because the Home Assistant base image ships without it. +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + gcc g++ gfortran \ + libopenblas-dev liblapack-dev \ + && rm -rf /var/lib/apt/lists/* + +# Resolve and install dependencies from the lock file first. This layer stays +# cached as long as pyproject.toml / uv.lock are unchanged. +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-install-project + +# Project sources and generated version (pyproject reads version from version.txt). +COPY src/ ./src +COPY scripts/get_version.py ./scripts/get_version.py +RUN python scripts/get_version.py > version.txt + +# Install the project itself. Editable, because akkudoktoreos.core.version +# requires the src/akkudoktoreos layout at runtime; the runtime stage copies +# src/ back in. +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev + + +FROM ${BUILD_FROM:-python:${PYTHON_VERSION}-slim} AS runtime + +# Supplied by Home Assistant, docker-compose and CI; "dev" marks an +# unversioned local build. CI stamps org.opencontainers.image.version via +# docker/metadata-action, so it is not set here. +ARG BUILD_VERSION=dev LABEL \ - io.hass.version="VERSION" \ + io.hass.version="${BUILD_VERSION}" \ io.hass.type="addon" \ io.hass.arch="aarch64|amd64" \ - source="https://github.com/Akkudoktor-EOS/EOS" + source="https://github.com/Akkudoktor-EOS/EOS" \ + org.opencontainers.image.source="https://github.com/Akkudoktor-EOS/EOS" \ + org.opencontainers.image.licenses="Apache-2.0" ENV EOS_DIR="/opt/eos" # Create persistent data directory similar to home assistant add-on @@ -21,6 +69,8 @@ ENV EOS_DIR="/opt/eos" # - MPLCONFIGDIR: user customizations to Mathplotlib ENV EOS_DATA_DIR="/data" ENV EOS_CACHE_DIR="${EOS_DATA_DIR}/cache" +# Written by EOS after resolving its startup configuration. +ENV EOS_HEALTHCHECK_PORT_FILE="${EOS_CACHE_DIR}/eos-healthcheck-port" ENV EOS_OUTPUT_DIR="${EOS_DATA_DIR}/output" ENV EOS_CONFIG_DIR="${EOS_DATA_DIR}/config" ENV MPLCONFIGDIR="${EOS_DATA_DIR}/mplconfigdir" @@ -43,53 +93,29 @@ ENV PATH="$VENV_PATH/bin:$PATH" WORKDIR ${EOS_DIR} -# Create eos user and data directories with eos user permissions -RUN apt-get update && apt-get install -y --no-install-recommends adduser \ - && adduser --system --group --no-create-home eos \ - && mkdir -p "${EOS_DATA_DIR}" \ - && chown -R eos:eos "${EOS_DATA_DIR}" \ - && mkdir -p "${EOS_CACHE_DIR}" "${EOS_OUTPUT_DIR}" "${EOS_CONFIG_DIR}" "${MPLCONFIGDIR}" \ - && chown -R eos:eos "${EOS_CACHE_DIR}" "${EOS_OUTPUT_DIR}" "${EOS_CONFIG_DIR}" "${MPLCONFIGDIR}" - -# Install build dependencies (Debian) -# - System deps +# Runtime shared libraries only (no -dev packages, no compilers). Create the eos +# user and the persistent data directories with eos ownership. RUN apt-get update && apt-get install -y --no-install-recommends \ - python3-venv \ - gcc g++ gfortran \ - libopenblas-dev liblapack-dev \ + adduser python3 libopenblas0 liblapack3 \ + && adduser --system --group --no-create-home eos \ + && mkdir -p "${EOS_DATA_DIR}" "${EOS_CACHE_DIR}" "${EOS_OUTPUT_DIR}" "${EOS_CONFIG_DIR}" "${MPLCONFIGDIR}" \ + && chown -R eos:eos "${EOS_DATA_DIR}" \ && rm -rf /var/lib/apt/lists/* -# - Copy project metadata first (better Docker layer caching) -COPY pyproject.toml . - -# - Create venv -RUN python3 -m venv ${VENV_PATH} - -# - Upgrade pip inside venv -RUN pip install --upgrade pip setuptools wheel - -# Install EOS/ EOSdash -# - Copy source +COPY --from=builder /opt/venv /opt/venv +# Editable install: the venv only points at the source tree, so it must be present. COPY src/ ./src -# - Create version information -COPY scripts/get_version.py ./scripts/get_version.py -RUN python scripts/get_version.py > ./version.txt -RUN rm ./scripts/get_version.py - -RUN echo "Building Akkudoktor-EOS with Python $PYTHON_VERSION" - -# - Install akkudoktoreos package in editable form (-e) -# - pyproject-toml will read the version from version.txt -RUN pip install --no-cache-dir -e . - ENTRYPOINT [] -EXPOSE 8504 EXPOSE 8503 +EXPOSE 8504 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["python", "-m", "akkudoktoreos.server.container_healthcheck"] # Ensure EOS and EOSdash bind to 0.0.0.0 -# EOS is started with root provileges. EOS will drop root proviledges and switch to user eos. +# EOS is started with root privileges. EOS will drop root privileges and switch to user eos. CMD ["python", "-m", "akkudoktoreos.server.eos", "--host", "0.0.0.0", "--run_as_user", "eos"] # Persistent data diff --git a/docker-compose.yaml b/docker-compose.yaml index 4afe5aed..4bd86ef9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,6 +12,7 @@ services: dockerfile: "Dockerfile" args: PYTHON_VERSION: "${PYTHON_VERSION}" + BUILD_VERSION: "${VERSION}" env_file: - .env environment: diff --git a/src/akkudoktoreos/server/container_healthcheck.py b/src/akkudoktoreos/server/container_healthcheck.py new file mode 100644 index 00000000..6cb2241c --- /dev/null +++ b/src/akkudoktoreos/server/container_healthcheck.py @@ -0,0 +1,52 @@ +"""Lightweight container healthcheck using the port selected by the running server.""" + +import os +import sys +import tempfile +from pathlib import Path +from urllib.request import ProxyHandler, build_opener + +PORT_FILE_ENV = "EOS_HEALTHCHECK_PORT_FILE" + + +def publish_port(port: int) -> None: + """Atomically publish the startup port when container healthchecks are enabled. + + Called after configuration resolution and privilege dropping. This preserves + CLI, environment and config-file precedence without loading EOS in the probe. + """ + filename = os.environ.get(PORT_FILE_ENV) + if not filename: + return + path = Path(filename) + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=path.parent, delete=False + ) as output: + temporary_path = Path(output.name) + output.write(str(port)) + temporary_path.replace(path) + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + +def main() -> int: + """Return success only when the selected EOS port serves a healthy response.""" + try: + port = int(Path(os.environ[PORT_FILE_ENV]).read_text(encoding="utf-8")) + if not 1 <= port <= 65535: + raise ValueError(f"Invalid server port: {port}") + # The probe is always local and must not use HTTP_PROXY from the container. + opener = build_opener(ProxyHandler({})) + with opener.open(f"http://127.0.0.1:{port}/v1/health", timeout=3) as response: + return 0 if response.status == 200 else 1 + except (KeyError, OSError, ValueError) as error: + print(f"EOS healthcheck failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index e1d1b67f..e04b7274 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -73,6 +73,7 @@ from akkudoktoreos.prediction.load import LoadCommonSettings from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings from akkudoktoreos.prediction.pvforecastpvlib import _cec_inverters, _cec_modules +from akkudoktoreos.server.container_healthcheck import publish_port from akkudoktoreos.server.rest.error import ( EOSProblem, create_error_page, @@ -2360,6 +2361,9 @@ def run_eos() -> None: # Switch privileges to run_as_user drop_root_privileges(run_as_user=config_eos.server.run_as_user) + # Publish the effective startup port for the lightweight container probe. + publish_port(config_eos.server.port) + # Init the other singletons (besides config_eos) singletons_init()