mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-09-10 18:06:37 +00:00
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 <github@koldrack.com> Co-authored-by: Normann Koldrack <normann.koldrack@desy.de>
This commit is contained in:
co-authored by
Normann
Normann Koldrack
parent
e9bd55caac
commit
2d18547aaa
@@ -119,6 +119,8 @@ jobs:
|
|||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
build-args: |
|
||||||
|
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
annotations: ${{ steps.meta.outputs.annotations }}
|
annotations: ${{ steps.meta.outputs.annotations }}
|
||||||
|
|||||||
+69
-43
@@ -1,19 +1,67 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# Dockerfile
|
# Dockerfile
|
||||||
|
|
||||||
# Support both Home Assistant builds and standalone builds
|
# Support both Home Assistant builds and standalone builds.
|
||||||
# Only Debian based images are supported (no Alpine)
|
# Only Debian based images are supported (no Alpine).
|
||||||
ARG BUILD_FROM
|
ARG BUILD_FROM
|
||||||
ARG PYTHON_VERSION=3.13.15
|
ARG PYTHON_VERSION=3.13.15
|
||||||
|
|
||||||
# If BUILD_FROM is set (Home Assistant), use it; otherwise use python-slim
|
# Builder and runtime share the same base so the copied virtualenv is ABI-safe.
|
||||||
FROM ${BUILD_FROM:-python:${PYTHON_VERSION}-slim}
|
# 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 \
|
LABEL \
|
||||||
io.hass.version="VERSION" \
|
io.hass.version="${BUILD_VERSION}" \
|
||||||
io.hass.type="addon" \
|
io.hass.type="addon" \
|
||||||
io.hass.arch="aarch64|amd64" \
|
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"
|
ENV EOS_DIR="/opt/eos"
|
||||||
# Create persistent data directory similar to home assistant add-on
|
# Create persistent data directory similar to home assistant add-on
|
||||||
@@ -21,6 +69,8 @@ ENV EOS_DIR="/opt/eos"
|
|||||||
# - MPLCONFIGDIR: user customizations to Mathplotlib
|
# - MPLCONFIGDIR: user customizations to Mathplotlib
|
||||||
ENV EOS_DATA_DIR="/data"
|
ENV EOS_DATA_DIR="/data"
|
||||||
ENV EOS_CACHE_DIR="${EOS_DATA_DIR}/cache"
|
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_OUTPUT_DIR="${EOS_DATA_DIR}/output"
|
||||||
ENV EOS_CONFIG_DIR="${EOS_DATA_DIR}/config"
|
ENV EOS_CONFIG_DIR="${EOS_DATA_DIR}/config"
|
||||||
ENV MPLCONFIGDIR="${EOS_DATA_DIR}/mplconfigdir"
|
ENV MPLCONFIGDIR="${EOS_DATA_DIR}/mplconfigdir"
|
||||||
@@ -43,53 +93,29 @@ ENV PATH="$VENV_PATH/bin:$PATH"
|
|||||||
|
|
||||||
WORKDIR ${EOS_DIR}
|
WORKDIR ${EOS_DIR}
|
||||||
|
|
||||||
# Create eos user and data directories with eos user permissions
|
# Runtime shared libraries only (no -dev packages, no compilers). Create the eos
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends adduser \
|
# user and the persistent data directories with eos ownership.
|
||||||
&& 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
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3-venv \
|
adduser python3 libopenblas0 liblapack3 \
|
||||||
gcc g++ gfortran \
|
&& adduser --system --group --no-create-home eos \
|
||||||
libopenblas-dev liblapack-dev \
|
&& 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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# - Copy project metadata first (better Docker layer caching)
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
COPY pyproject.toml .
|
# Editable install: the venv only points at the source tree, so it must be present.
|
||||||
|
|
||||||
# - 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 src/ ./src
|
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 []
|
ENTRYPOINT []
|
||||||
|
|
||||||
EXPOSE 8504
|
|
||||||
EXPOSE 8503
|
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
|
# 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"]
|
CMD ["python", "-m", "akkudoktoreos.server.eos", "--host", "0.0.0.0", "--run_as_user", "eos"]
|
||||||
|
|
||||||
# Persistent data
|
# Persistent data
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ services:
|
|||||||
dockerfile: "Dockerfile"
|
dockerfile: "Dockerfile"
|
||||||
args:
|
args:
|
||||||
PYTHON_VERSION: "${PYTHON_VERSION}"
|
PYTHON_VERSION: "${PYTHON_VERSION}"
|
||||||
|
BUILD_VERSION: "${VERSION}"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -73,6 +73,7 @@ from akkudoktoreos.prediction.load import LoadCommonSettings
|
|||||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||||
from akkudoktoreos.prediction.pvforecastpvlib import _cec_inverters, _cec_modules
|
from akkudoktoreos.prediction.pvforecastpvlib import _cec_inverters, _cec_modules
|
||||||
|
from akkudoktoreos.server.container_healthcheck import publish_port
|
||||||
from akkudoktoreos.server.rest.error import (
|
from akkudoktoreos.server.rest.error import (
|
||||||
EOSProblem,
|
EOSProblem,
|
||||||
create_error_page,
|
create_error_page,
|
||||||
@@ -2360,6 +2361,9 @@ def run_eos() -> None:
|
|||||||
# Switch privileges to run_as_user
|
# Switch privileges to run_as_user
|
||||||
drop_root_privileges(run_as_user=config_eos.server.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)
|
# Init the other singletons (besides config_eos)
|
||||||
singletons_init()
|
singletons_init()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user