From 85849e2b567acd331ac3528fd4b91b1de7070118 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:13:40 +0000 Subject: [PATCH] A slim Docker image: python:3.13-slim, no pip, bytecode for start-up only Two stages: the build stage makes the venv (no pip, no pip cache, no test suites) and takes the three Inter weights the charts use out of Debian's fonts-inter; the image gets those, the bot, and the bytecode of only the modules it imports at start (16 MB, where all of it would be 48 MB; none adds ~2 s to every start). It runs as uid 10001 with its state in /data, and a COMMIT build argument is the commit its start-up log names. 278 MB unpacked, 98 MB compressed, of which the base is 110 and 45. A workflow (docker.yml) builds it whenever the Dockerfile or requirements.txt change, starts the bot in it and checks the charts it draws there against the reference images. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- .dockerignore | 6 ++++++ .github/workflows/docker.yml | 39 ++++++++++++++++++++++++++++++++++++ DEVELOPMENT.md | 5 +++-- Dockerfile | 39 ++++++++++++++++++++++++++++++++++++ README.md | 8 ++++++++ 5 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bfe3718 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# Only what the image is built from: no state, caches, secrets (*.env), tests or venv can reach it +* +!ecobot.py +!lib/ +!requirements.txt +**/__pycache__ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..ec8a879 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,39 @@ +name: Docker image + +on: + push: # any branch, so a pull request's commits carry the check too + paths: [Dockerfile, .dockerignore, requirements.txt, .github/workflows/docker.yml] + workflow_dispatch: + +permissions: + contents: read + +jobs: + image: + # The image builds from the Dockerfile, the bot starts in it, and the charts it draws there are the approved ones, in Inter + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Build + run: docker build --build-arg COMMIT="$GITHUB_SHA" -t ecobot . + - name: The bot starts and names this commit + run: | + docker run --rm --network none -e TELEGRAM_BOT_TOKEN=0:x -e XAI_API_KEY=x ecobot > start.log 2>&1 || true # stops: no data source set + cat start.log + grep -q "Starting ecobot ${GITHUB_SHA::7}" start.log + - name: Charts drawn in the image + run: | + docker run --rm --network none -v "$PWD/tests:/app/tests:ro" -e MPLCONFIGDIR=/tmp/mpl ecobot python -c ' + from PIL import Image + from lib import charts + from tests import chart_samples, imagecmp + assert (charts.FONT, charts.TITLE_WEIGHT) == ("Inter", "semibold"), (charts.FONT, charts.TITLE_WEIGHT) + for name, make in chart_samples.SAMPLES.items(): + share = imagecmp.difference(imagecmp.snapshot(make()), Image.open(imagecmp.GOLDEN / f"{name}.png"))[0] + assert share <= imagecmp.TOLERANCE, f"{name}: {share:.2%} of the pixels differ from tests/charts/{name}.png" + print("Inter with semibold titles; every sample chart matches its reference image")' + - name: Size + run: | + unpacked=$(docker run --rm --network none --user 0 --entrypoint du ecobot -sx --apparent-size --block-size=1M / | cut -f1) + compressed=$(docker save ecobot | gzip -c | wc -c) + echo "The image: ${unpacked} MB unpacked, $((compressed / 1000000)) MB compressed" | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9bc000c..d40c9cc 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -10,16 +10,17 @@ python scripts/eval_prompts.py # the saved questions: decisions made in ``` The code targets Python 3.13.5 (`.python-version`). Claude Code sessions build the same Python into `.venv` with uv (`.claude/hooks/session-start.sh`). -## GitHub Actions (`.github/workflows/ci.yml`) +## GitHub Actions (`.github/workflows/ci.yml`, and `docker.yml` for `image`) | Job | When | What | |---|---|---| | `test` | every push to `main` and every pull request | the exact versions in `requirements.txt`: pytest (chart pictures included) and the offline evals. The gate. | | `newest` | Sundays 21:17 UTC, or the Run workflow button | the tests with the newest matplotlib, numpy and Pillow. Advisory. | | `live-evals` | Sundays 21:17 UTC, or the Run workflow button | asks the real model the six questions in `tests/evals/weekly.txt` (`python scripts/eval_prompts.py --live`) and checks which tool it calls; a failure is asked once more before it is reported. Advisory. The button's "all cases" box asks all 87. Skipped until the secrets below exist. | +| `image` | pushes that change `Dockerfile`, `.dockerignore` or `requirements.txt` (any branch), or the Run workflow button | builds the Docker image, checks the bot starts in it and names the commit, draws the sample charts in it (they must be in Inter and match `tests/charts`); the image's size goes in the run's summary. | Repository secrets (Settings, Secrets and variables, Actions, New repository secret): - `XAI_API_KEY`: for `live-evals`. `XAI_MODEL` is optional (default `grok-4.3`): add it only if production runs another model. -- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`: optional; failures of any job, and of `test` on `main`, are then sent to Telegram. +- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`: optional; failures of `newest` and `live-evals`, and of `test` on `main`, are then sent to Telegram. Dependabot (`.github/dependabot.yml`) opens weekly pull requests for the Python packages and the actions. `matplotlib`, `numpy` and `Pillow` come as one pull request, because the chart pictures depend on them. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1abc61f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# The bot as a small image: python:3.13-slim with the runtime packages in a venv that has no pip, no pip cache and no test suites, +# and bytecode for only what the bot imports at start (bytecode for everything is 48 MB more; none adds ~2 s to every start). +# docker build --build-arg COMMIT=$(git rev-parse HEAD) -t ecobot . +# docker run -d --name ecobot --restart unless-stopped --env-file /etc/ecobot.env -v ecobot-data:/data ecobot +# Settings come from the environment, as under systemd (README); the state and caches are kept in /data. +FROM python:3.13-slim AS base + +FROM base AS build +# Inter for the charts, in the three weights they use (text, semibold titles, bold labels); the rest of the package stays here +RUN apt-get update \ + && apt-get install -y --no-install-recommends fonts-inter \ + && mkdir /fonts \ + && find /usr/share/fonts -name 'Inter-Regular.*' -o -name 'Inter-SemiBold.*' -o -name 'Inter-Bold.*' | xargs cp -t /fonts +COPY requirements.txt /tmp/ +# The base image's pip installs into a venv made without one +RUN python -m venv --without-pip /opt/venv \ + && pip --python /opt/venv/bin/python install --no-cache-dir --no-compile --root-user-action=ignore -r /tmp/requirements.txt \ + && find /opt/venv -type d -name tests -prune -exec rm -rf {} + + +FROM base +COPY --from=build /opt/venv /opt/venv +COPY --from=build /fonts/ /usr/share/fonts/inter/ +RUN groupadd --system --gid 10001 ecobot \ + && useradd --system --uid 10001 --gid 10001 --no-create-home --home-dir /data ecobot \ + && mkdir /data && chown 10001:10001 /data +WORKDIR /app +COPY ecobot.py ./ +COPY lib lib +ENV PATH=/opt/venv/bin:$PATH PYTHONUNBUFFERED=1 +# COMMIT goes where the bot reads the running commit from (.git/HEAD) for its start-up log line ("unknown" without it). Importing +# the bot once writes the bytecode of every module it loads at start, and only those. +ARG COMMIT=unknown +LABEL org.opencontainers.image.revision=$COMMIT +RUN mkdir .git && echo "$COMMIT" > .git/HEAD \ + && DATA_DIR=/tmp/build MPLCONFIGDIR=/tmp/build python -c "import lib.bot, ecobot" \ + && rm -rf /tmp/build +ENV DATA_DIR=/data +USER 10001:10001 +CMD ["python", "ecobot.py"] diff --git a/README.md b/README.md index d688572..dcbdb58 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,14 @@ Developing, the tests and the GitHub checks: see `DEVELOPMENT.md`. Under systemd see `ecobot.service` (it loads the environment file; the clone's location is its one `WorkingDirectory` line, the rest is relative). +In Docker, the `Dockerfile` builds a python:3.13-slim image (278 MB unpacked, 98 MB compressed): + +``` +docker build --build-arg COMMIT=$(git rev-parse HEAD) -t ecobot . # COMMIT: the commit the start-up log names +docker run -d --name ecobot --restart unless-stopped --env-file /etc/ecobot.env -v ecobot-data:/data ecobot +``` +The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host folder instead (`-v /srv/ecobot:/data`) must be writable by the image's user, uid 10001 (`sudo chown 10001:10001 /srv/ecobot`). Docker reads the environment file's `KEY=value` lines as they are: a value in quotes keeps its quotes, where systemd would drop them. + | Variable | Purpose | |---|---| | `TELEGRAM_BOT_TOKEN`, `XAI_API_KEY` | required |