From 08175fc6ae927a132f1f5f50aa78e7112e824d88 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 5 Aug 2026 09:13:50 -0500 Subject: [PATCH] feat: precompile EXLA in CI base image to prevent OOM kills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Dockerfile.ci that extends the hexpm/elixir image with precompiled EXLA NIF cached at ~/.cache/xla/. When mix test runs, EXLA's cached_make finds the precompiled libexla.so and skips the OOM-prone g++ C++ compilation entirely. - Dockerfile.ci: two-stage build — builder compiles EXLA into cache; final stage copies the cache and installs test deps (cdo) - .forgejo/workflows/build-ci-image.yaml: builds and pushes prop-ci image on mix.exs/mix.lock/vendor changes + weekly cron - .forgejo/workflows/build.yaml: test step uses prop-ci:latest instead of raw hexpm/elixir image, removing apt-get + hex/rebar install steps --- .forgejo/workflows/build-ci-image.yaml | 81 ++++++++++++++++++++++++++ .forgejo/workflows/build.yaml | 7 +-- Dockerfile.ci | 67 +++++++++++++++++++++ 3 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 .forgejo/workflows/build-ci-image.yaml create mode 100644 Dockerfile.ci diff --git a/.forgejo/workflows/build-ci-image.yaml b/.forgejo/workflows/build-ci-image.yaml new file mode 100644 index 00000000..777d325a --- /dev/null +++ b/.forgejo/workflows/build-ci-image.yaml @@ -0,0 +1,81 @@ +name: Build CI test image + +on: + push: + branches: + - main + paths: + - 'mix.exs' + - 'mix.lock' + - 'vendor/**' + - 'Dockerfile.ci' + - '.forgejo/workflows/build-ci-image.yaml' + schedule: + - cron: '0 8 * * 1' + workflow_dispatch: + +env: + REGISTRY: git.mcintire.me + OWNER: graham + IMAGE_NAME: prop-ci + DOCKER_CLI_VERSION: '28.5.2' + BUILDX_VERSION: '0.35.0' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-and-push: + name: Build and push CI image + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Docker CLI and buildx + run: | + set -euo pipefail + curl -fsSL --retry 3 --retry-delay 2 \ + "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_CLI_VERSION}.tgz" \ + | tar xz --strip-components=1 -C /usr/local/bin docker/docker + mkdir -p "$HOME/.docker/cli-plugins" + curl -fsSL --retry 3 --retry-delay 2 \ + -o "$HOME/.docker/cli-plugins/docker-buildx" \ + "https://github.com/docker/buildx/releases/download/v${BUILDX_VERSION}/buildx-v${BUILDX_VERSION}.linux-amd64" + chmod +x "$HOME/.docker/cli-plugins/docker-buildx" + docker version + docker buildx version + + - name: Build and push + env: + REGISTRY_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + run: | + set -euo pipefail + + IMAGE="${REGISTRY}/${OWNER}/${IMAGE_NAME}" + SHA_TAG="${GITHUB_SHA::7}" + + echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$OWNER" --password-stdin + + DOCKER_BUILDKIT=1 docker build \ + --file Dockerfile.ci \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from "${IMAGE}:latest" \ + --tag "${IMAGE}:${SHA_TAG}" \ + --tag "${IMAGE}:latest" \ + . + + docker push "${IMAGE}:${SHA_TAG}" + docker push "${IMAGE}:latest" + + { + echo "### CI image pushed" + echo "" + echo "- \`${IMAGE}:${SHA_TAG}\`" + echo "- \`${IMAGE}:latest\`" + } >> "$GITHUB_STEP_SUMMARY" + + docker image rm "${IMAGE}:${SHA_TAG}" "${IMAGE}:latest" 2>/dev/null || true + docker logout "$REGISTRY" || true diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index af39b76b..5a1f3da8 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -92,16 +92,11 @@ jobs: -cf - . | \ docker run --rm -i \ -e MIX_ENV=test \ - -e MAKEFLAGS="-j1" \ --network host \ -w /app \ - docker.io/hexpm/elixir:1.20.1-erlang-29.0.2-debian-trixie-20260518-slim \ + git.mcintire.me/graham/prop-ci:latest \ sh -euc '\ mkdir -p /app && cd /app && tar xf - && \ - export DEBIAN_FRONTEND=noninteractive && \ - apt-get update -qq && apt-get install -y -qq git curl ca-certificates build-essential cdo && \ - mix local.hex --force && \ - mix local.rebar --force && \ mix deps.get && \ mix ecto.create --quiet && \ mix ecto.migrate --quiet && \ diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 00000000..46a2afa8 --- /dev/null +++ b/Dockerfile.ci @@ -0,0 +1,67 @@ +# syntax=docker/dockerfile:1.6 +# +# Pre-built CI test image. Extends the hexpm/elixir image with: +# * build-essential + git + curl + ca-certificates + cdo +# * hex + rebar +# * Precompiled EXLA NIF cached at ~/.cache/xla/ +# +# When mix test runs with this image, EXLA's cached_make finds the +# precompiled libexla.so and skips the OOM-prone g++ C++ compilation +# entirely. Rebuild this image whenever mix.exs changes elixir/exla/xla +# versions or c_src/ files change (the cache key auto-invalidates). +# +# Built by .forgejo/workflows/build-ci-image.yaml. + +ARG ELIXIR_IMAGE="docker.io/hexpm/elixir:1.20.1-erlang-29.0.2-debian-trixie-20260518-slim" + +FROM ${ELIXIR_IMAGE} AS builder + +# Install build tools and runtime test deps (cdo is needed by tests). +RUN export DEBIAN_FRONTEND=noninteractive && \ + apt-get update -qq && \ + apt-get install -y -qq git curl ca-certificates build-essential cdo && \ + rm -rf /var/lib/apt/lists/* + +# Install hex + rebar +RUN mix local.hex --force && mix local.rebar --force + +# Precompile EXLA to populate ~/.cache/xla/ with both the downloaded +# XLA archive and the compiled libexla.so. We only need mix.exs + +# mix.lock + vendor + minimal config — no full project checkout. +WORKDIR /tmp/exla_warm + +# Copy only what's needed for deps.get + deps.compile exla +COPY mix.exs mix.lock /tmp/exla_warm/ +COPY vendor /tmp/exla_warm/vendor + +# Minimal config — nx needs :default_backend to exist +RUN mkdir -p /tmp/exla_warm/config && \ + printf 'import Config\nconfig :nx, :default_backend, EXLA.Backend\n' > /tmp/exla_warm/config/config.exs + +# Fetch deps and compile EXLA. The cached_make compiler downloads the XLA +# archive, extracts it, compiles the C++ NIF, and caches libexla.so at +# ~/.cache/xla/exla/{cache_key}/libexla.so. This layer is cached by +# Docker until mix.exs/mix.lock/vendor change. +RUN mix deps.get && mix deps.compile exla + +# Clean up the temp project — the cache in ~/.cache/xla/ persists in +# the image and is what matters. +RUN rm -rf /tmp/exla_warm + +FROM ${ELIXIR_IMAGE} AS final + +# Install runtime test deps +RUN export DEBIAN_FRONTEND=noninteractive && \ + apt-get update -qq && \ + apt-get install -y -qq git curl ca-certificates build-essential cdo && \ + rm -rf /var/lib/apt/lists/* + +# Install hex + rebar +RUN mix local.hex --force && mix local.rebar --force + +# Copy the precompiled EXLA cache from the builder stage. This is the +# ~/.cache/xla/ directory containing the XLA archive download and the +# compiled libexla.so keyed by exact Elixir/ERTS/exla/xla/c_src versions. +COPY --from=builder /root/.cache/xla /root/.cache/xla + +WORKDIR /app