From 617b2bbc657881280c8722af8f5c8de26823d8af Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 12 Aug 2026 13:25:46 +0200 Subject: [PATCH 1/9] fix(deploy): try to fix deployment --- .dockerignore | 5 +- .env.example | 47 +++++++++ .github/workflows/image.yml | 89 +++++++++++++++++ .gitignore | 3 + DEPLOY.md | 191 ++++++++++++++++++++++-------------- Dockerfile | 84 +++++++++++----- docker-compose.cpu.yml | 16 +++ docker-compose.gpu.yml | 17 ---- docker-compose.yml | 69 +++++++++++-- 9 files changed, 397 insertions(+), 124 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/image.yml create mode 100644 docker-compose.cpu.yml delete mode 100644 docker-compose.gpu.yml diff --git a/.dockerignore b/.dockerignore index c45e593..112352a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,7 +29,10 @@ prompt.txt # Pre-built binary transcriber -# Docs / CI not needed in build context +# Docs / CI / deploy config not needed in build context DEPLOY.md COMPATIBILITY.md IMPROVEMENTS.md +.github +docker-compose*.yml +.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3af96a9 --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +# Copy to .env and edit. `docker compose` reads .env automatically. +# Every value here is optional — the defaults in docker-compose.yml match these. + +# ---- Image ---- +# Pull a CI-built image instead of compiling whisper.cpp on every host. +# Pin a tag (e.g. :sha-abc1234) in production so you can roll back. +IMAGE=ghcr.io/bcc-code/transcriber:latest + +# ---- Storage ---- +# Must already exist on the host, and must be the same path the caller puts in +# the `path` / `output_path` fields — they are resolved inside the container. +STORAGE_PATH=/mnt/storage + +# ---- Network ---- +# The API is unauthenticated. Set this to an internal interface, or firewall +# the port, so only the workflow engine can reach it. +BIND_ADDR=0.0.0.0 +PORT=8888 + +# ---- Runtime ---- +# Concurrent transcription jobs. Each one runs its own whisper-cli process +# holding a ~3 GB FP16 large-v3 model on the GPU; 2 fits comfortably in the +# 3090's 24 GB, but raising this risks CUDA OOM (which surfaces as a failed job). +WORKERS=2 +CALLBACK_WORKERS=2 +DEFAULT_MODEL=whisper-cpp-large-v3 +# ISO 639-1 code to skip whisper's language auto-detection (faster and more +# reliable for a mono-lingual corpus). Empty = auto-detect. Requests can +# override per job. +DEFAULT_LANGUAGE=no +JOB_TIMEOUT=30m +# Finished jobs retained in memory. The store is in-memory only, so a completed +# job evicted before the caller polls it becomes a 404 — keep this comfortably +# above the largest burst the pipeline will submit. +MAX_TERMINAL_JOBS=200 +LOG_FORMAT=json +# true forces whisper-cli's CPU backend. Use with docker-compose.cpu.yml. +NO_GPU=false + +# ---- Build args (only used by `docker compose build`) ---- +# CUDA 12.6 requires host driver >= 560.28.03; the container refuses to start +# otherwise. Check with nvidia-smi. +CUDA_VERSION=12.6.3 +# SM architecture of the deployment GPU: 86 = RTX 3090/Ampere, 89 = L4/RTX 40xx, +# 90 = H100. Wrong value means PTX JIT at startup (slow) or a hard failure. +CUDA_ARCHS=86 +WHISPER_CPP_REF=v1.8.6 diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..a2c83c3 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,89 @@ +name: image + +# Builds the image once on an x86_64 runner and pushes it to GHCR, so on-prem +# hosts `docker compose pull` instead of compiling whisper.cpp + CUDA from +# source on every deploy. Also keeps nvcc off qemu, which is where local +# builds from an arm64 Mac fall over. + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + # internal/web/dist is gitignored, so //go:embed has nothing to embed in + # a fresh checkout and the package fails to compile. The real SPA is + # built inside the image; a placeholder is enough for vet/test. + - name: Stage embed placeholder + run: | + mkdir -p internal/web/dist + echo 'placeholder' > internal/web/dist/index.html + + - run: go vet ./... + - run: go test -race ./... + + image: + runs-on: ubuntu-latest + needs: test + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v5 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + # Skip on pull_request: forks have no push credentials. The build still + # runs, so a PR that breaks the Dockerfile fails here rather than on-prem. + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # sha- tags are the ones to pin in .env for a rollback-able deploy. + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # The whisper.cpp/CUDA stage is ~15 min from cold; cache it in GHA so + # only Go/frontend changes rebuild. + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + CUDA_VERSION=12.6.3 + CUDA_ARCHS=86 + WHISPER_CPP_REF=v1.8.6 diff --git a/.gitignore b/.gitignore index ddd2359..55e3aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ docker-compose.override.yml # Default prompt file (deployment-specific vocabulary) prompt.txt + +# Compiled binary from `make build` +transcriber diff --git a/DEPLOY.md b/DEPLOY.md index ad471a6..7e59978 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,90 +1,113 @@ # Deploying transcriber -Target: on-prem Linux host with NVIDIA GPUs, running Docker. The image +Target: on-prem Linux host with an NVIDIA GPU, running Docker. The image bundles `whisper-cli` (whisper.cpp, built with the **CUDA** GGML backend plus OpenBLAS for the CPU fallback path), `ffmpeg`/`ffprobe`, and the Go API + embedded SPA. Everything the container needs is in the image except the ggml model files (downloaded from Hugging Face on first use into a persisted volume). -## GPU access (CUDA) +## Preflight + +Run through these before deploying — each one is a failure that otherwise +shows up minutes later as a mysteriously failed job. + +1. **NVIDIA driver version.** `nvidia-smi`. The image is built on CUDA + 12.6, and `nvidia/cuda` images carry `NVIDIA_REQUIRE_CUDA=cuda>=12.6` + which the NVIDIA Container Toolkit **enforces at container start**. + With an older driver the container refuses to start: + `nvidia-container-cli: requirement error: unsatisfied condition: + cuda>=12.6`. Driver must be **≥ 560.28.03**. To target an older + driver, lower `CUDA_VERSION` to a release whose minimum driver matches + (see the [CUDA compatibility matrix][cuda-compat]), or set + `NVIDIA_DISABLE_REQUIRE=1` to bypass the check. +2. **NVIDIA Container Toolkit installed.** [Install guide][nvct]. Verify: + `docker run --rm --gpus all nvidia/cuda:12.6.3-base-ubuntu24.04 nvidia-smi`. +3. **`STORAGE_PATH` exists on the host.** Docker otherwise creates it as + an empty root-owned directory and every job fails with ENOENT. +4. **Outbound HTTPS to `huggingface.co`.** Models (~3 GB) are fetched on + first use. If the host is firewalled, pre-seed them (below) — otherwise + every job fails. -Install the [NVIDIA Container Toolkit][nvct] on the host. With it in -place, `docker-compose.gpu.yml` reserves all NVIDIA devices for the -container, and `NVIDIA_DRIVER_CAPABILITIES=compute,utility` (baked into -the image) tells the toolkit which driver libraries to expose. To run -on CPU only — e.g. a host without NVIDIA hardware, or local Mac dev — -bring up `docker-compose.yml` alone and add `-whispercpp-no-gpu` to the -service command. +[nvct]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html +[cuda-compat]: https://docs.nvidia.com/deploy/cuda-compatibility/ -CUDA runtime version is pinned via `CUDA_VERSION` build arg -(default `12.6.3`); this requires NVIDIA driver **≥ 560.28.03** on the -host. Check with `nvidia-smi` before deploying. To target an older -driver, lower `CUDA_VERSION` to a release whose minimum driver matches -what's installed — see the [CUDA compatibility matrix][cuda-compat]. +## Configure -GPU architecture is pinned via `CUDA_ARCHS` build arg (default `"86"`, -the SM version for the on-prem RTX 3090 / Ampere). If the deployment -GPU changes, override at build time (e.g. `--build-arg CUDA_ARCHS=89` -for L4 / RTX 40xx, `90` for H100) and update the Dockerfile default. +All configuration lives in `.env`, read automatically by `docker compose`: -[nvct]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html -[cuda-compat]: https://docs.nvidia.com/deploy/cuda-compatibility/ +```sh +cp .env.example .env +$EDITOR .env +``` -## Build & run +`.env.example` documents every knob. The ones that matter most: +`STORAGE_PATH` (must match the paths the caller sends), `CUDA_ARCHS` +(`86` for the RTX 3090; `89` for L4/RTX 40xx, `90` for H100), +`DEFAULT_LANGUAGE`, and `WORKERS`. + +## Deploy + +Preferred — pull the CI-built image (no compiling on the host): ```sh -# CPU-only (local sanity check / dev machine without GPU): -docker compose build +docker compose pull docker compose up -d +``` + +Or build locally, on an **x86_64** host: -# On-prem with NVIDIA GPU: -docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d +```sh +docker compose build +docker compose up -d ``` -The base `docker-compose.yml` works anywhere; `docker-compose.gpu.yml` -overlays the NVIDIA device reservation and is **Linux-host only** — -the `nvidia` driver runtime isn't available on macOS or Windows. On a -Mac dev machine, run the base compose file alone (CPU fallback, under -qemu emulation — slow but correct). +GPU access is part of `docker-compose.yml`, so the short command above is +the production command — there is no overlay to remember. On a host with +no NVIDIA GPU this fails loudly with "could not select device driver" +rather than silently running 20–50× slower on CPU. For those hosts: -All Dockerfile stages are pinned to `linux/amd64` because the on-prem GPU -hosts are x86_64. On an x86_64 build host this is a no-op; on an arm64 -host (Apple Silicon dev machine) the build runs under qemu emulation, -which is slow but produces deployment-correct binaries. To build natively -for arm64 instead, strip the `--platform=linux/amd64` from each `FROM`. +```sh +# CPU-only (Mac dev, CPU-only Linux). Also set NO_GPU=true in .env. +docker compose -f docker-compose.yml -f docker-compose.cpu.yml up -d +``` The API is served on `:8888`. Open `http://:8888/` for the SPA or hit `POST /transcription/job` directly. `GET /healthz` and `GET /readyz` -are available for liveness/readiness probes. +are available for probes, and the compose file wires `/healthz` into a +container healthcheck — `docker compose ps` shows health at a glance. -## Volumes +> **Note:** `/readyz` currently only checks that a default model is +> registered, which is always true. It does **not** verify that +> `whisper-cli` runs, that a GPU is visible, or that model files are +> present. Treat a green `/readyz` as "the process is up", not "the next +> job will succeed". + +### Building the image -| Mount | Purpose | -| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `models:/var/cache/transcriber` | ggml whisper.cpp models. Survives container restarts — first job downloads ~3 GB. | -| `/mnt/storage:/mnt/storage` | Audio inputs (`path`) and transcript outputs (`output_path`). Paths in API requests are read inside the container, so the host paths you reference must be visible at the same mount points. | -| `./prompt.txt:/app/prompt.txt:ro` _(optional)_ | Default prompt file. Without it, only requests carrying their own `prompt` field get one. | +Only the whisper.cpp/CUDA stage and the runtime stage are target-arch +(`linux/amd64`); the frontend and Go stages run natively on the build host +and cross-compile. So on an arm64 Mac, `docker compose build` still has to +qemu-emulate `nvcc`, which is slow (30–90 min) and prone to dying outright. -## Configuration +Build on the Linux host, or let CI do it: `.github/workflows/image.yml` +builds on an x86_64 runner and pushes to +`ghcr.io/bcc-code/transcriber`, tagged `latest`, `sha-`, and +semver on tags. Pin a `sha-` tag in `.env` for a rollback-able deploy. -Flags are set via the `command:` field in `docker-compose.yml`. The -defaults run `whisper-cpp-large-v3` with 2 workers; for a beefier host -something like `["-workers=4", "-callback-workers=4"]` is reasonable. +The frontend build downloads webfonts from `fonts.gstatic.com` +(`@nuxt/fonts`), so the *builder* needs outbound internet even though the +resulting image is self-contained. -To skip whisper's language auto-detection (faster, more reliable when the -corpus is mono-lingual), pass an ISO 639-1 code with `-default-language`, -e.g. `["-default-language=no"]`. Requests can still override with their -own `language` field, or send `"auto"` to opt back into detection. +## Volumes -On hosts without an NVIDIA GPU (Mac dev, CPU-only Linux), pass -`-whispercpp-no-gpu` to force whisper-cli's CPU backend (OpenBLAS- -accelerated). Without it, the CUDA backend tries to initialize, fails -to find a device, and the job errors. The `docker-compose.override.yml` -in this repo already sets the flag for local dev; production NVIDIA -hosts don't need it. +| Mount | Purpose | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `models:/var/cache/transcriber` | ggml whisper.cpp models. Survives restarts — first job downloads ~3 GB. | +| `${STORAGE_PATH}:${STORAGE_PATH}` | Audio inputs (`path`) and transcript outputs (`output_path`). Mounted at the same path inside and out, because request paths resolve in the container. | +| `./prompt.txt:/app/prompt.txt:ro` _(optional)_ | Default prompt file. Uncomment in `docker-compose.yml`. Without it, only requests carrying their own `prompt` get one. | -Env vars set inside the image: +Env vars baked into the image: - `WHISPER_CPP_BIN=/usr/local/bin/whisper-cli` - `XDG_CACHE_HOME=/var/cache` → models live at `/var/cache/transcriber/hf//` @@ -92,42 +115,62 @@ Env vars set inside the image: Both whisper.cpp adapters resolve the FP16 large-v3 weights (~3 GB each) — the reference quality. On the RTX 3090 (24 GB VRAM) there's no reason to trade accuracy for the Q5_0 variant; CUDA inference is -compute-bound here, not memory-bound, so quantization wouldn't speed -things up meaningfully either. To switch to a quantized file anyway -(e.g. `ggml-large-v3-q5_0.bin`), pre-seed it into the volume and pin -via `WHISPER_CPP_MODEL` / `NB_WHISPER_MODEL`. +compute-bound here, not memory-bound. Override `WHISPER_CPP_MODEL` / +`NB_WHISPER_MODEL` / `WHISPER_VAD_MODEL` on the service to pin a model to +a specific file on disk instead of letting the HF cache resolve it. -Override `WHISPER_CPP_MODEL` / `NB_WHISPER_MODEL` / `WHISPER_VAD_MODEL` -on the service to pin a model to a specific file on disk instead of -letting the HF cache resolve it. +## Pre-seeding models -## Pre-seeding models (optional) - -To avoid the first-request download, drop ggml files into the volume -ahead of time: +Worth doing even with internet access: the download currently happens +*inside* the first job's context, so it competes with `-job-timeout` +(default 30m) and a slow fetch makes the first job fail with +`error: "timeout"`. With `WORKERS=2`, the second job blocks on the first +job's download while its own deadline runs. ```sh # Find the volume path: docker volume inspect transcriber_models -f '{{ .Mountpoint }}' -# Copy a pre-downloaded model into place: +# Copy pre-downloaded models into place: sudo mkdir -p /transcriber/hf/ggerganov/whisper.cpp sudo cp ggml-large-v3.bin /transcriber/hf/ggerganov/whisper.cpp/ +sudo mkdir -p /transcriber/hf/ggml-org/whisper-vad +sudo cp ggml-silero-v5.1.2.bin /transcriber/hf/ggml-org/whisper-vad/ ``` ## Upgrading ```sh -git pull -docker compose build +git pull # picks up .env.example / compose changes +docker compose pull # or: docker compose build docker compose up -d ``` The API has a 10 s graceful shutdown — in-flight HTTP requests finish, but workers receive a cancel and any running transcription jobs are -killed. Avoid redeploying while jobs are running, or drain the queue -first (`GET /transcription/jobs`, `DELETE /transcription/job/{id}`). +killed. **The job store is in-memory**, so a restart also discards the +queue and all job history; the caller's next poll returns 404. Drain +first (`GET /transcription/jobs`, `DELETE /transcription/job/{id}`) or +redeploy when idle. ## Logs -`docker compose logs -f transcriber` — the API logs via slog to stderr. +`docker compose logs -f transcriber` — the API logs via slog to stderr, +JSON by default (`LOG_FORMAT` in `.env`). + +## Known rough edges + +Tracked in `IMPROVEMENTS.md`; these are the ones that affect operations: + +- **In-memory job store.** Restart or crash loses the queue and history. + `MAX_TERMINAL_JOBS` (default 200 here) also evicts completed jobs, so a + large enough burst can evict a result before the caller reads it. +- **Chunk temp files are left behind.** For audio longer than 5 minutes, + per-chunk `.wav` files are written into the job's `output_path` and never + cleaned up — roughly 115 MB per hour of audio, accumulating on shared + storage. +- **Output files are root-owned.** The container runs as root, so + transcripts land on `STORAGE_PATH` as `root:root`. +- **No authentication.** Anything that can reach the port can submit jobs + with arbitrary absolute `path` / `output_path` values. Bind to an + internal interface (`BIND_ADDR`) and firewall it. diff --git a/Dockerfile b/Dockerfile index 2e9ea3d..cabb036 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,30 @@ # syntax=docker/dockerfile:1.7 -# Target on-prem x86_64 GPU servers. On arm64 hosts (Apple Silicon) the build -# runs under qemu emulation, which is slow but produces deployment-correct -# binaries; on x86_64 hosts --platform is a no-op. +# Only the whisper.cpp stage and the runtime stage have to be the *target* +# architecture (they carry CUDA and the compiled whisper-cli). The frontend and +# Go stages run natively on the build host ($BUILDPLATFORM) and cross-compile, +# which keeps them out of qemu when building for amd64 from an arm64 Mac. +# +# Building the CUDA stage on an arm64 host still means qemu-emulating nvcc, +# which is slow and flaky — build on an x86_64 host or in CI (see +# .github/workflows/image.yml) and `docker compose pull` on-prem. +ARG ARCH=amd64 +ARG CUDA_VERSION=12.6.3 # ---- Stage 1: build whisper.cpp (whisper-cli) with CUDA backend ---- # Pinned to the on-prem host's GPU stack: NVIDIA only. CUDA is whisper.cpp's # most-optimized backend (1.5–2× over Vulkan on NVIDIA). The runtime stage # uses the matching nvidia/cuda runtime image so libcudart / libcublas etc. -# are available without polluting the host. Local Mac dev still works in -# CPU-only mode (`-whispercpp-no-gpu`) — the CUDA libs are present but -# unused. -ARG CUDA_VERSION=12.6.3 -FROM --platform=linux/amd64 nvidia/cuda:${CUDA_VERSION}-devel-ubuntu24.04 AS whisper-build +# are available without polluting the host. +FROM --platform=linux/${ARCH} nvidia/cuda:${CUDA_VERSION}-devel-ubuntu24.04 AS whisper-build ARG WHISPER_CPP_REF=v1.8.6 RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential cmake git ca-certificates pkg-config \ libopenblas-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /src +# WHISPER_CPP_REF is a mutable tag — git tags can be force-moved, so the image +# is not bit-reproducible. Pin a 40-char commit SHA when that matters. RUN git clone --depth 1 --branch ${WHISPER_CPP_REF} https://github.com/ggerganov/whisper.cpp.git . # BLAS stays on for the CPU fallback path (`-whispercpp-no-gpu`); on CUDA # hosts it's unused at runtime, so the only cost is image size. @@ -26,8 +32,8 @@ RUN git clone --depth 1 --branch ${WHISPER_CPP_REF} https://github.com/ggerganov # CMAKE_CUDA_ARCHITECTURES must be pinned: ggml-cuda's default is `native`, # which queries nvidia-smi on the build host — docker build has no GPU, so # the build would fail. Pinned to sm_86 for the on-prem RTX 3090 (Ampere). -# If the deployment GPU changes, update this — building for the wrong arch -# either falls back to PTX JIT at startup (slow) or fails outright. +# If the deployment GPU changes, override CUDA_ARCHS — building for the wrong +# arch either falls back to PTX JIT at startup (slow) or fails outright. ARG CUDA_ARCHS="86" RUN cmake -B build \ -DCMAKE_BUILD_TYPE=Release \ @@ -40,37 +46,69 @@ RUN cmake -B build \ -DBUILD_SHARED_LIBS=ON \ && cmake --build build --config Release -j \ && cmake --install build --prefix /opt/whisper +# Fail here rather than at first transcription if the install layout changes. +RUN test -x /opt/whisper/bin/whisper-cli # ---- Stage 2: build frontend (Nuxt → static) ---- -FROM --platform=linux/amd64 node:22-bookworm-slim AS frontend-build -# Pin pnpm v10: lockfile is v9.0 (created by pnpm 9), pnpm 10 reads it natively. -# Avoids the pnpm v11 "approved builds" gate which requires interactive -# pnpm approve-builds to allow esbuild / @parcel/watcher native postinstalls. -RUN npm install -g pnpm@10 +# Runs on the build host's native arch: the output is platform-independent +# static JS/CSS, so there is nothing to gain from emulating the target. +FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS frontend-build +# PNPM_VERSION must match frontend/package.json's "packageManager" field. +# pnpm >= 10 self-manages to whatever that field declares, so installing a +# different major here does not pin anything — it silently re-execs the +# declared version mid-build. --config.manage-package-manager-versions=false +# disables that, so the version installed here is the version that runs and +# any drift fails loudly (lockfile mismatch) instead of quietly. +ARG PNPM_VERSION=11.21.0 +RUN npm install -g pnpm@${PNPM_VERSION} +ENV PNPM_FLAGS="--config.manage-package-manager-versions=false" WORKDIR /src/frontend -COPY frontend/package.json frontend/pnpm-lock.yaml ./ -RUN pnpm install --frozen-lockfile +# pnpm-workspace.yaml carries settings pnpm needs AT INSTALL TIME: allowBuilds +# (permits the esbuild / @parcel/watcher native postinstalls) and +# minimumReleaseAge. Omit it from this layer and `pnpm install` still exits 0 +# but skips those builds (ERR_PNPM_IGNORED_BUILDS), and the Nuxt build fails +# downstream. It must be copied alongside package.json, not with the source. +COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./ +RUN pnpm ${PNPM_FLAGS} install --frozen-lockfile COPY frontend/ ./ -RUN pnpm generate +# @nuxt/fonts downloads webfonts from fonts.gstatic.com during the build, so +# this stage needs outbound internet even though the result is self-contained. +RUN pnpm ${PNPM_FLAGS} generate # ---- Stage 3: build Go binary ---- -FROM --platform=linux/amd64 golang:1.26-bookworm AS go-build +# Also native + cross-compiled: CGO_ENABLED=0 makes GOARCH a pure flag flip. +FROM --platform=$BUILDPLATFORM golang:1.26.3-bookworm AS go-build +ARG ARCH +# go.mod pins a patch version (go 1.26.3). GOTOOLCHAIN=local makes a mismatch +# with this base image fail loudly instead of silently downloading a toolchain +# from proxy.golang.org — which breaks in network-restricted builders and makes +# the build depend on whatever the floating image tag resolved to. +ENV GOTOOLCHAIN=local CGO_ENABLED=0 GOOS=linux WORKDIR /src COPY go.mod ./ COPY cmd ./cmd COPY internal ./internal -# Replace the embedded dist with the freshly built SPA. +# internal/web/dist is gitignored, so it is absent (or stale) in the build +# context. Replace it with the freshly built SPA — //go:embed needs it to +# exist and contain index.html. RUN rm -rf internal/web/dist && mkdir -p internal/web/dist COPY --from=frontend-build /src/frontend/.output/public/ ./internal/web/dist/ -RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/transcriber ./cmd/transcriber +RUN GOARCH=${ARCH} go build -trimpath -ldflags='-s -w' -o /out/transcriber ./cmd/transcriber # ---- Stage 4: runtime ---- # Matches the whisper-build base so glibc / libstdc++ and CUDA runtime libs # (libcudart, libcublas) are present without bundling the full toolkit. -FROM --platform=linux/amd64 nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu24.04 AS runtime +# +# NOTE: nvidia/cuda images carry NVIDIA_REQUIRE_CUDA=cuda>=, which +# the NVIDIA Container Toolkit *enforces* at container start. With CUDA 12.6 the +# host driver must be >= 560.28.03 or the container refuses to start with +# "requirement error: unsatisfied condition: cuda>=12.6". Lower CUDA_VERSION or +# set NVIDIA_DISABLE_REQUIRE=1 to override. See DEPLOY.md. +FROM --platform=linux/${ARCH} nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu24.04 AS runtime RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg ca-certificates libgomp1 libstdc++6 \ libopenblas0-pthread \ + curl \ && rm -rf /var/lib/apt/lists/* # NVIDIA Container Toolkit reads these to expose the right device files and @@ -95,5 +133,7 @@ WORKDIR /app EXPOSE 8888 # `prompt.txt` is optional; mount one in if you want a default prompt. +# Flags come from docker-compose.yml (driven by .env); these defaults apply +# when running the image directly with `docker run`. ENTRYPOINT ["/usr/local/bin/transcriber"] CMD ["-port=8888", "-workers=2", "-default-model=whisper-cpp-large-v3", "-log-format=json"] diff --git a/docker-compose.cpu.yml b/docker-compose.cpu.yml new file mode 100644 index 0000000..bc61bc1 --- /dev/null +++ b/docker-compose.cpu.yml @@ -0,0 +1,16 @@ +# CPU-only overlay for hosts with no NVIDIA GPU (Mac dev, CPU-only Linux). +# Pair with NO_GPU=true in .env so whisper-cli is also told to use its CPU +# backend — without that flag the CUDA backend initializes, finds no device, +# and every job fails. +# +# docker compose -f docker-compose.yml -f docker-compose.cpu.yml up -d +# +# `!reset` drops the GPU device reservation inherited from docker-compose.yml. +# An empty list (`devices: []`) does NOT work — Compose treats that as "no +# change" and keeps the nvidia reservation, which then fails to start. +# +# Transcription on CPU is 20-50x slower than on the 3090 — fine for a sanity +# check on a short file, not for production throughput. +services: + transcriber: + deploy: !reset null diff --git a/docker-compose.gpu.yml b/docker-compose.gpu.yml deleted file mode 100644 index ab3cd44..0000000 --- a/docker-compose.gpu.yml +++ /dev/null @@ -1,17 +0,0 @@ -# GPU access overlay. Compose this on top of docker-compose.yml on the on-prem -# NVIDIA host. Without it, the container runs CPU-only (fine for local sanity -# checks, not fine for production throughput). Requires the NVIDIA Container -# Toolkit on the host. -# -# Usage: -# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d - -services: - transcriber: - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: all - capabilities: [gpu] diff --git a/docker-compose.yml b/docker-compose.yml index 5a18816..6d00ca6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,71 @@ +# Single compose file for all hosts. Every knob comes from `.env` — copy +# .env.example to .env and edit. See DEPLOY.md. +# +# docker compose pull && docker compose up -d # registry image (preferred) +# docker compose build && docker compose up -d # build locally +# +# GPU access is part of THIS file, not an opt-in overlay, so the short command +# is the correct production command. On a host without an NVIDIA GPU this fails +# loudly ("could not select device driver") rather than silently running 20-50x +# slower on CPU — for those hosts add -f docker-compose.cpu.yml. +name: transcriber + services: transcriber: - build: . - image: transcriber:latest + image: ${IMAGE:-ghcr.io/bcc-code/transcriber:latest} + build: + context: . + args: + CUDA_VERSION: ${CUDA_VERSION:-12.6.3} + CUDA_ARCHS: ${CUDA_ARCHS:-86} + WHISPER_CPP_REF: ${WHISPER_CPP_REF:-v1.8.6} restart: unless-stopped + + # BIND_ADDR=0.0.0.0 preserves the previous behaviour: the API has no + # authentication, so anything that can reach this port can read and write + # any path visible inside the container. Firewall it, or set BIND_ADDR to + # an internal interface. ports: - - "8888:8888" + - "${BIND_ADDR:-0.0.0.0}:${PORT:-8888}:${PORT:-8888}" + + command: + - "-port=${PORT:-8888}" + - "-workers=${WORKERS:-2}" + - "-callback-workers=${CALLBACK_WORKERS:-2}" + - "-default-model=${DEFAULT_MODEL:-whisper-cpp-large-v3}" + - "-default-language=${DEFAULT_LANGUAGE:-}" + - "-job-timeout=${JOB_TIMEOUT:-30m}" + - "-max-terminal-jobs=${MAX_TERMINAL_JOBS:-200}" + - "-log-format=${LOG_FORMAT:-json}" + # Go's flag package accepts an explicit boolean value, so this stays a + # single env toggle instead of a second compose file. + - "-whispercpp-no-gpu=${NO_GPU:-false}" + volumes: - # Whisper model cache — persists ggml model downloads across restarts. + # ggml model cache — first job downloads ~3 GB, this keeps it. - models:/var/cache/transcriber - # Audio in / transcripts out. Adjust the host paths to your storage. - - /mnt/storage:/mnt/storage - # Optional: mount a default prompt file (vocabulary hints, etc.). + # Audio in / transcripts out. Job requests carry absolute paths that are + # resolved *inside* the container, so host and container paths must match. + # STORAGE_PATH must already exist on the host: Docker otherwise creates + # it as an empty root-owned directory and every job fails with ENOENT. + - ${STORAGE_PATH:-/mnt/storage}:${STORAGE_PATH:-/mnt/storage} + # Optional default prompt (vocabulary hints). Uncomment to use. # - ./prompt.txt:/app/prompt.txt:ro - # Override the default flags here if you want, e.g.: - # command: ["-port=8888", "-workers=4", "-default-model=nb-whisper-large"] + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:${PORT:-8888}/healthz"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 -# GPU access is opt-in via docker-compose.gpu.yml — see DEPLOY.md. + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] volumes: models: From 2ca4629b71077abb6336ab90cbb5b6271da571b2 Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 12 Aug 2026 13:26:40 +0200 Subject: [PATCH 2/9] fix: do not ignore transcriber folder --- .github/workflows/image.yml | 20 ++++++++++++++++++++ .gitignore | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index a2c83c3..fded730 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -42,12 +42,32 @@ jobs: image: runs-on: ubuntu-latest needs: test + env: + # Same-repo events (push, tag, branch PR) get a write-scoped + # GITHUB_TOKEN and can authenticate to GHCR. Fork PRs get a read-only + # token, so they build without touching the registry cache. + CAN_PUSH: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} permissions: contents: read packages: write steps: - uses: actions/checkout@v5 + - name: Free up disk space + # ubuntu-latest ships ~14 GB free on /. The CUDA devel image alone is + # ~8 GB extracted, plus the whisper build tree and the runtime image — + # it does not fit. Drop preinstalled toolchains this build never uses. + run: | + df -h / + sudo rm -rf \ + /usr/local/lib/android \ + /usr/share/dotnet \ + /opt/ghc \ + /usr/local/share/boost \ + /opt/hostedtoolcache/CodeQL + sudo docker image prune -af + df -h / + - uses: docker/setup-buildx-action@v3 - name: Log in to GHCR diff --git a/.gitignore b/.gitignore index 55e3aa0..c6bb64b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,4 @@ docker-compose.override.yml prompt.txt # Compiled binary from `make build` -transcriber +./transcriber From 3585158261b6095cf0c1050b8e45d1025fd30c36 Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 12 Aug 2026 13:29:21 +0200 Subject: [PATCH 3/9] fix(build): fix ci --- .github/workflows/image.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index fded730..4bfb47e 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -71,9 +71,10 @@ jobs: - uses: docker/setup-buildx-action@v3 - name: Log in to GHCR - # Skip on pull_request: forks have no push credentials. The build still - # runs, so a PR that breaks the Dockerfile fails here rather than on-prem. - if: github.event_name != 'pull_request' + # Logging in on same-repo PRs is deliberate: the PR build is the test + # run for the CUDA stage, and it needs the registry cache to not take + # ~30 min every time. It still doesn't publish the image (see `push:`). + if: env.CAN_PUSH == 'true' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -99,10 +100,14 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - # The whisper.cpp/CUDA stage is ~15 min from cold; cache it in GHA so - # only Go/frontend changes rebuild. - cache-from: type=gha - cache-to: type=gha,mode=max + # The whisper.cpp/CUDA stage is ~15-30 min from cold, so it must be + # cached — but it's an *intermediate* stage, which rules out both GHA + # options: mode=max blows past GHA's 10 GB per-repo cache limit and + # thrashes, while mode=min only caches final-image layers and so + # misses the whisper stage entirely. Registry cache has no such cap + # and mode=max exports every stage. + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: ${{ env.CAN_PUSH == 'true' && format('type=registry,ref={0}/{1}:buildcache,mode=max', env.REGISTRY, env.IMAGE_NAME) || '' }} build-args: | CUDA_VERSION=12.6.3 CUDA_ARCHS=86 From 8bfd9101bd842b820ac3663515e8f55d6e083717 Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 12 Aug 2026 13:41:28 +0200 Subject: [PATCH 4/9] feat: scratch dir --- .gitignore | 2 +- DEPLOY.md | 18 ++- README.md | 2 + cmd/transcriber/main.go | 8 +- internal/transcriber/adapter.go | 10 +- internal/transcriber/chunked/chunked.go | 12 +- internal/transcriber/chunked/chunked_test.go | 6 +- internal/transcriber/whispercpp/whispercpp.go | 6 +- internal/worker/pool.go | 112 +++++++++++++--- internal/worker/pool_test.go | 121 +++++++++++++++++- 10 files changed, 259 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index c6bb64b..d04e107 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,4 @@ docker-compose.override.yml prompt.txt # Compiled binary from `make build` -./transcriber +/transcriber diff --git a/DEPLOY.md b/DEPLOY.md index 7e59978..c7b29f3 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -99,6 +99,20 @@ The frontend build downloads webfonts from `fonts.gstatic.com` (`@nuxt/fonts`), so the *builder* needs outbound internet even though the resulting image is self-contained. +## Scratch space + +Adapter intermediates — extracted chunk wavs and raw whisper JSON — go into a +per-job scratch directory that is deleted when the job ends, including on +timeout, cancellation, and failure. Only the final transcripts are written to +`output_path`. + +Scratch defaults to the OS temp dir (`/tmp` in the container, so the container's +writable layer). Chunking writes roughly **115 MB per hour of audio**, so with +`WORKERS=2` on long files budget a few GB of headroom. Set `-scratch-dir` to +move it — keep it on local disk rather than network storage, since none of it +is worth shipping over the wire. `-keep-work-dirs` retains the directories for +inspecting a bad transcription. + ## Volumes | Mount | Purpose | @@ -165,10 +179,6 @@ Tracked in `IMPROVEMENTS.md`; these are the ones that affect operations: - **In-memory job store.** Restart or crash loses the queue and history. `MAX_TERMINAL_JOBS` (default 200 here) also evicts completed jobs, so a large enough burst can evict a result before the caller reads it. -- **Chunk temp files are left behind.** For audio longer than 5 minutes, - per-chunk `.wav` files are written into the job's `output_path` and never - cleaned up — roughly 115 MB per hour of audio, accumulating on shared - storage. - **Output files are root-owned.** The container runs as root, so transcripts land on `STORAGE_PATH` as `root:root`. - **No authentication.** Anything that can reach the port can submit jobs diff --git a/README.md b/README.md index 6ef74d4..a920d1f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ Go code. Server settings come from flags; per-machine paths from env vars. | `-job-timeout` | `30m` | wall-clock cap per job; on expiry the worker cancels the subprocess and marks the job `FAILED` with `error: "timeout"`. Per-request `timeout_seconds` overrides this. `<= 0` disables | | `-max-terminal-jobs` | `20` | how many finished jobs (completed/failed/canceled) to retain in memory; `<= 0` disables the cap | | `-log-format` | `text` | `text` for human-readable output (dev), `json` for structured logs (prod). The Dockerfile sets `json` | +| `-scratch-dir` | _(OS temp)_ | where per-job scratch dirs are created for adapter intermediates (extracted chunk wavs, raw model output). Keep on local disk — chunking writes ~115 MB per hour of audio | +| `-keep-work-dirs` | `false` | retain per-job scratch dirs after completion, for inspecting a bad transcription. They are large; off by default | | Env var | Default | Meaning | | ------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/cmd/transcriber/main.go b/cmd/transcriber/main.go index f3c8c20..ad8a305 100644 --- a/cmd/transcriber/main.go +++ b/cmd/transcriber/main.go @@ -30,6 +30,8 @@ func main() { maxTerminalJobs := flag.Int("max-terminal-jobs", 20, "how many finished jobs (completed/failed/canceled) to retain in memory; <= 0 disables the cap") jobTimeout := flag.Duration("job-timeout", 30*time.Minute, "default wall-clock timeout per job; per-request timeout_seconds overrides this; <= 0 disables") logFormat := flag.String("log-format", "text", "log handler: text (human-readable, dev) or json (structured, prod)") + scratchDir := flag.String("scratch-dir", "", "directory for per-job scratch space (extracted audio chunks, raw model output); empty uses the OS temp dir. Keep this on local disk, not network storage") + keepWorkDirs := flag.Bool("keep-work-dirs", false, "retain per-job scratch directories after completion for debugging; they are large (~115 MB per hour of audio)") whisperNoGPU := flag.Bool("whispercpp-no-gpu", false, "pass `-ng` to whisper-cli, forcing the CPU backend (use on hosts without a real GPU, e.g. Docker-on-Mac)") flag.Parse() @@ -87,7 +89,11 @@ func main() { pool := worker.New(*workers, store, queue, registry, notifier, func(j jobs.Job) any { return api.ToDTO(j) - }, *jobTimeout) + }, worker.Config{ + DefaultTimeout: *jobTimeout, + ScratchRoot: *scratchDir, + KeepWorkDirs: *keepWorkDirs, + }) pool.Start(ctx) srv := api.NewServer(store, queue, registry, defaultPrompt, normalizedDefaultLang) diff --git a/internal/transcriber/adapter.go b/internal/transcriber/adapter.go index 95bb817..e49604f 100644 --- a/internal/transcriber/adapter.go +++ b/internal/transcriber/adapter.go @@ -35,7 +35,15 @@ type Word struct { type Request struct { InputPath string Language string - OutputDir string + // WorkDir is scratch space for intermediate files (extracted chunk wavs, + // raw model output). It is owned by the caller, which deletes it once the + // job finishes, so adapters must not treat anything written here as a + // deliverable. + // + // It is deliberately NOT the job's output_path: the final transcripts are + // written by the worker via internal/formats, so nothing an adapter leaves + // behind lands in the directory the API caller reads. + WorkDir string // Prompt biases the decoder toward names and terms it might otherwise mishear. // whisper.cpp truncates to ~224 tokens. Prompt string diff --git a/internal/transcriber/chunked/chunked.go b/internal/transcriber/chunked/chunked.go index 85978fa..665e524 100644 --- a/internal/transcriber/chunked/chunked.go +++ b/internal/transcriber/chunked/chunked.go @@ -55,10 +55,12 @@ func (a *Adapter) Transcribe(ctx context.Context, req transcriber.Request, onPro return a.Inner.Transcribe(ctx, req, onProgress) } - if err := os.MkdirAll(req.OutputDir, 0o755); err != nil { + if err := os.MkdirAll(req.WorkDir, 0o755); err != nil { return nil, err } - chunksDir := filepath.Join(req.OutputDir, "chunks") + // Extracted chunk wavs are ~32 kB/s of audio (16 kHz mono s16le), i.e. + // ~115 MB per hour. They live in scratch and are deleted with it. + chunksDir := filepath.Join(req.WorkDir, "chunks") if err := os.MkdirAll(chunksDir, 0o755); err != nil { return nil, err } @@ -109,14 +111,14 @@ func (a *Adapter) transcribeChunk(ctx context.Context, req transcriber.Request, if err := ExtractChunk(ctx, a.cfg.FFmpegBin, req.InputPath, wavPath, ch.Start, ch.Duration()); err != nil { return nil, err } - chunkOutDir := filepath.Join(chunksDir, fmt.Sprintf("%03d", ch.Index)) - if err := os.MkdirAll(chunkOutDir, 0o755); err != nil { + chunkWorkDir := filepath.Join(chunksDir, fmt.Sprintf("%03d", ch.Index)) + if err := os.MkdirAll(chunkWorkDir, 0o755); err != nil { return nil, err } subReq := transcriber.Request{ InputPath: wavPath, Language: req.Language, - OutputDir: chunkOutDir, + WorkDir: chunkWorkDir, Prompt: req.Prompt, Options: req.Options, } diff --git a/internal/transcriber/chunked/chunked_test.go b/internal/transcriber/chunked/chunked_test.go index 6685824..1549038 100644 --- a/internal/transcriber/chunked/chunked_test.go +++ b/internal/transcriber/chunked/chunked_test.go @@ -56,14 +56,14 @@ func TestAdapterEndToEnd(t *testing.T) { t.Fatalf("ffmpeg generate: %v: %s", err, out) } - outDir := filepath.Join(tmp, "out") + workDir := filepath.Join(tmp, "work") inner := &fakeInner{} a := New(inner, Config{ChunkLengthSec: 2, OverlapSec: 0.5}) var progressLast float64 res, err := a.Transcribe(context.Background(), transcriber.Request{ InputPath: src, - OutputDir: outDir, + WorkDir: workDir, }, func(p float64) { progressLast = p }) @@ -119,7 +119,7 @@ func TestAdapterShortFileBypassesChunking(t *testing.T) { a := New(inner, Config{ChunkLengthSec: 300, OverlapSec: 3}) _, err := a.Transcribe(context.Background(), transcriber.Request{ InputPath: src, - OutputDir: filepath.Join(tmp, "out"), + WorkDir: filepath.Join(tmp, "work"), }, nil) if err != nil { t.Fatalf("Transcribe: %v", err) diff --git a/internal/transcriber/whispercpp/whispercpp.go b/internal/transcriber/whispercpp/whispercpp.go index b096dd8..8da8ac4 100644 --- a/internal/transcriber/whispercpp/whispercpp.go +++ b/internal/transcriber/whispercpp/whispercpp.go @@ -90,10 +90,12 @@ func (a *Adapter) Transcribe(ctx context.Context, req transcriber.Request, onPro } modelPath = p } - if err := os.MkdirAll(req.OutputDir, 0o755); err != nil { + if err := os.MkdirAll(req.WorkDir, 0o755); err != nil { return nil, err } - outPrefix := filepath.Join(req.OutputDir, "whispercpp_out") + // Raw whisper output is an intermediate: it goes in the scratch dir, not + // the caller's output_path. + outPrefix := filepath.Join(req.WorkDir, "whispercpp_out") args := []string{ "-m", modelPath, diff --git a/internal/worker/pool.go b/internal/worker/pool.go index ac30043..245dbb7 100644 --- a/internal/worker/pool.go +++ b/internal/worker/pool.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "log/slog" + "os" "path/filepath" + "strings" "sync" "time" @@ -16,20 +18,35 @@ import ( "transcriber/internal/transcriber" ) +// Config holds the pool's tunables. +type Config struct { + // DefaultTimeout <= 0 disables the wall-clock cap; a job's own Timeout + // takes precedence when non-zero. + DefaultTimeout time.Duration + + // ScratchRoot is where per-job working directories are created; empty uses + // os.TempDir(). Keep it on local disk — chunk extraction writes ~115 MB per + // hour of audio, and routing that through network storage is wasted I/O. + ScratchRoot string + + // KeepWorkDirs retains per-job scratch directories after the job finishes, + // for inspecting a bad transcription. Off by default: they are large. + KeepWorkDirs bool +} + // Pool is a fixed-size worker pool that runs jobs through the configured adapter. type Pool struct { - workers int - store *jobs.Store - queue *jobs.Queue - registry *transcriber.Registry - notifier *callback.Notifier - dtoFn func(jobs.Job) any - defaultTimeout time.Duration - wg sync.WaitGroup + workers int + store *jobs.Store + queue *jobs.Queue + registry *transcriber.Registry + notifier *callback.Notifier + dtoFn func(jobs.Job) any + cfg Config + wg sync.WaitGroup } -// New builds a pool. defaultTimeout <= 0 disables the wall-clock cap; the -// per-job Timeout takes precedence when non-zero. +// New builds a pool. func New( workers int, store *jobs.Store, @@ -37,19 +54,19 @@ func New( registry *transcriber.Registry, notifier *callback.Notifier, dtoFn func(jobs.Job) any, - defaultTimeout time.Duration, + cfg Config, ) *Pool { if workers < 1 { workers = 1 } return &Pool{ - workers: workers, - store: store, - queue: queue, - registry: registry, - notifier: notifier, - dtoFn: dtoFn, - defaultTimeout: defaultTimeout, + workers: workers, + store: store, + queue: queue, + registry: registry, + notifier: notifier, + dtoFn: dtoFn, + cfg: cfg, } } @@ -92,9 +109,20 @@ func (p *Pool) runJob(parent context.Context, id string, log *slog.Logger) { return } + // Scratch space for the adapter's intermediates, removed when the job ends + // (including on timeout, cancel, and failure) so nothing accumulates in the + // caller's output_path. + workDir, cleanupWorkDir, err := p.newWorkDir(id) + if err != nil { + p.markFailed(id, fmt.Errorf("create work dir: %w", err)) + p.fireCallback(id) + return + } + defer cleanupWorkDir() + timeout := job.Timeout if timeout <= 0 { - timeout = p.defaultTimeout + timeout = p.cfg.DefaultTimeout } var ctx context.Context var cancel context.CancelFunc @@ -124,7 +152,7 @@ func (p *Pool) runJob(parent context.Context, id string, log *slog.Logger) { req := transcriber.Request{ InputPath: job.Path, Language: job.Language, - OutputDir: job.OutputPath, + WorkDir: workDir, Prompt: job.Prompt, } @@ -175,6 +203,50 @@ func (p *Pool) runJob(parent context.Context, id string, log *slog.Logger) { p.fireCallback(id) } +// newWorkDir allocates per-job scratch space and returns a cleanup func that +// the caller must defer. The cleanup is a no-op (beyond a log line) when +// KeepWorkDirs is set. +func (p *Pool) newWorkDir(id string) (string, func(), error) { + root := p.cfg.ScratchRoot + if root == "" { + root = os.TempDir() + } + if err := os.MkdirAll(root, 0o755); err != nil { + return "", nil, err + } + dir, err := os.MkdirTemp(root, "transcriber-"+safeName(id)+"-") + if err != nil { + return "", nil, err + } + if p.cfg.KeepWorkDirs { + return dir, func() { + slog.Info("retaining job work dir", "id", id, "dir", dir) + }, nil + } + return dir, func() { + if err := os.RemoveAll(dir); err != nil { + slog.Warn("work dir cleanup failed", "id", id, "dir", dir, "err", err) + } + }, nil +} + +// safeName reduces s to characters safe in a single filename component. The ID +// goes into the work dir name so `-keep-work-dirs` output is traceable back to +// a job, but os.MkdirTemp rejects patterns containing a path separator and IDs +// are only hex by current convention. +func safeName(s string) string { + return strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + return r + case r == '-', r == '_', r == '.': + return r + default: + return '-' + } + }, s) +} + func (p *Pool) pickAdapter(j jobs.Job) (transcriber.Transcriber, error) { if j.Model != "" { a, ok := p.registry.Get(j.Model) diff --git a/internal/worker/pool_test.go b/internal/worker/pool_test.go index c585946..53da058 100644 --- a/internal/worker/pool_test.go +++ b/internal/worker/pool_test.go @@ -1,6 +1,8 @@ package worker import ( + "context" + "errors" "os" "path/filepath" "testing" @@ -35,6 +37,123 @@ func TestPool_Timeouts(t *testing.T) { } } +// scratchAdapter writes intermediates into req.WorkDir the way the real +// whisper.cpp adapters do, so the test can assert none of it reaches the +// caller's output_path and that the scratch dir is reclaimed. +type scratchAdapter struct { + workDirs chan string +} + +func (a *scratchAdapter) ID() string { return "scratch" } +func (a *scratchAdapter) Name() string { return "Scratch" } + +func (a *scratchAdapter) Transcribe(ctx context.Context, req transcriber.Request, _ transcriber.ProgressFunc) (*transcriber.Result, error) { + if req.WorkDir == "" { + return nil, errors.New("WorkDir not set") + } + if err := os.MkdirAll(filepath.Join(req.WorkDir, "chunks"), 0o755); err != nil { + return nil, err + } + for _, name := range []string{"whispercpp_out.json", filepath.Join("chunks", "000.wav")} { + if err := os.WriteFile(filepath.Join(req.WorkDir, name), []byte("junk"), 0o644); err != nil { + return nil, err + } + } + a.workDirs <- req.WorkDir + return &transcriber.Result{ + Transcription: &transcriber.Transcription{ + Language: "en", + Text: "hello", + Segments: []transcriber.Segment{{ID: 0, Start: 0, End: 1, Text: "hello"}}, + }, + ModelUsed: "scratch", + }, nil +} + +func TestPool_IntermediatesStayOutOfOutputPath(t *testing.T) { + tmp := t.TempDir() + outPath := filepath.Join(tmp, "out") + + store := jobs.NewStore(0) + queue := jobs.NewQueue() + t.Cleanup(queue.Close) + + adapter := &scratchAdapter{workDirs: make(chan string, 1)} + registry := transcriber.NewRegistry("scratch") + registry.Register(adapter) + + // ScratchRoot under tmp keeps the test off the real temp dir. + pool := New(1, store, queue, registry, nil, func(j jobs.Job) any { return j }, + Config{ScratchRoot: filepath.Join(tmp, "scratch")}) + pool.Start(t.Context()) + + now := time.Now() + job := jobs.Job{ + ID: "clean-output", + Path: filepath.Join(tmp, "input.wav"), + OutputPath: outPath, + Format: "json,txt", + Model: "scratch", + Status: jobs.StatusPending, + CreatedAt: now, + } + store.Create(job) + queue.Push(job.ID, 1, now) + + var final jobs.Job + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if j, ok := store.Get(job.ID); ok && isTerminal(j.Status) { + final = j + break + } + time.Sleep(20 * time.Millisecond) + } + if final.Status != jobs.StatusCompleted { + t.Fatalf("status = %q (%s), want COMPLETED", final.Status, final.Error) + } + + // output_path holds deliverables and nothing else. + entries, err := os.ReadDir(outPath) + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + want := map[string]bool{"input.wav.json": true, "input.wav.txt": true} + if len(names) != len(want) { + t.Errorf("output_path contains %v, want exactly %v", names, want) + } + for _, n := range names { + if !want[n] { + t.Errorf("intermediate %q leaked into output_path", n) + } + } + + // And the scratch dir the adapter wrote into is reclaimed. Cleanup is a + // deferred call that runs *after* the status is published — marking the job + // complete deliberately isn't blocked on removing a potentially large + // directory — so poll rather than checking once. + var wd string + select { + case wd = <-adapter.workDirs: + default: + t.Fatal("adapter never reported a work dir") + } + deadline = time.Now().Add(2 * time.Second) + for { + if _, err := os.Stat(wd); os.IsNotExist(err) { + break + } + if time.Now().After(deadline) { + t.Fatalf("work dir %s still exists after job", wd) + } + time.Sleep(20 * time.Millisecond) + } +} + func runJobAndWait(t *testing.T, defaultTimeout, jobTimeout time.Duration) jobs.Job { t.Helper() tmp := t.TempDir() @@ -46,7 +165,7 @@ func runJobAndWait(t *testing.T, defaultTimeout, jobTimeout time.Duration) jobs. registry := transcriber.NewRegistry("stub") registry.Register(stub.New("stub", "Stub")) - pool := New(1, store, queue, registry, nil, func(j jobs.Job) any { return j }, defaultTimeout) + pool := New(1, store, queue, registry, nil, func(j jobs.Job) any { return j }, Config{DefaultTimeout: defaultTimeout}) pool.Start(t.Context()) now := time.Now() From 50e321ac79f9032f855d9cc95f0362a2f6680cbe Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 12 Aug 2026 14:26:26 +0200 Subject: [PATCH 5/9] fix: prevent memory choke in ci --- .env.example | 5 +++++ Dockerfile | 17 ++++++++++++++++- docker-compose.yml | 1 + 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 3af96a9..90251ac 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,8 @@ CUDA_VERSION=12.6.3 # 90 = H100. Wrong value means PTX JIT at startup (slow) or a hard failure. CUDA_ARCHS=86 WHISPER_CPP_REF=v1.8.6 +# Compile parallelism for whisper.cpp. Empty auto-derives from available memory +# (~2 GB per concurrent nvcc), capped at the core count. Only set this if the +# build gets OOM-killed — the symptom is the build dying mid-compile with no +# compiler error. +BUILD_JOBS= diff --git a/Dockerfile b/Dockerfile index cabb036..de9e861 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,14 @@ RUN git clone --depth 1 --branch ${WHISPER_CPP_REF} https://github.com/ggerganov # If the deployment GPU changes, override CUDA_ARCHS — building for the wrong # arch either falls back to PTX JIT at startup (slow) or fails outright. ARG CUDA_ARCHS="86" +# BUILD_JOBS bounds compile parallelism. This matters: ggml-cuda's template +# instantiations need roughly 2 GB of RAM *per concurrent nvcc*, so the build is +# memory-bound, not core-bound. A bare `-j` means unlimited — it launched 138 +# concurrent nvcc processes in 2.6 s on a 4-vCPU/16 GB GitHub runner and the OOM +# killer took down the runner agent mid-build, with no compiler error to show +# for it. Empty derives a safe count from available memory, capped at the core +# count; override with --build-arg BUILD_JOBS=N. +ARG BUILD_JOBS RUN cmake -B build \ -DCMAKE_BUILD_TYPE=Release \ -DGGML_CUDA=ON \ @@ -44,7 +52,14 @@ RUN cmake -B build \ -DWHISPER_BUILD_TESTS=OFF \ -DWHISPER_BUILD_EXAMPLES=ON \ -DBUILD_SHARED_LIBS=ON \ - && cmake --build build --config Release -j \ + && set -eu \ + && cores="$(nproc)" \ + && memjobs="$(awk '/^MemTotal:/ {printf "%d", $2/1024/1024/3}' /proc/meminfo)" \ + && if [ "${memjobs}" -lt 1 ]; then memjobs=1; fi \ + && jobs="${BUILD_JOBS:-${memjobs}}" \ + && if [ "${jobs}" -gt "${cores}" ]; then jobs="${cores}"; fi \ + && echo "whisper.cpp build: -j${jobs} (cores=${cores} mem-derived=${memjobs})" \ + && cmake --build build --config Release --parallel "${jobs}" \ && cmake --install build --prefix /opt/whisper # Fail here rather than at first transcription if the install layout changes. RUN test -x /opt/whisper/bin/whisper-cli diff --git a/docker-compose.yml b/docker-compose.yml index 6d00ca6..47e52ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: CUDA_VERSION: ${CUDA_VERSION:-12.6.3} CUDA_ARCHS: ${CUDA_ARCHS:-86} WHISPER_CPP_REF: ${WHISPER_CPP_REF:-v1.8.6} + BUILD_JOBS: ${BUILD_JOBS:-} restart: unless-stopped # BIND_ADDR=0.0.0.0 preserves the previous behaviour: the API has no From 90e94b9358608321c47b564ee64b4457d15147e6 Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 12 Aug 2026 15:03:30 +0200 Subject: [PATCH 6/9] feat: try to split image --- .env.example | 28 +++---- .github/workflows/image.yml | 54 +++++-------- .github/workflows/whisper-base.yml | 97 +++++++++++++++++++++++ DEPLOY.md | 88 +++++++++++++++------ Dockerfile | 106 ++++--------------------- Dockerfile.whisper | 120 +++++++++++++++++++++++++++++ README.md | 7 ++ docker-compose.cpu.yml | 16 ---- docker-compose.yml | 25 +++--- 9 files changed, 347 insertions(+), 194 deletions(-) create mode 100644 .github/workflows/whisper-base.yml create mode 100644 Dockerfile.whisper delete mode 100644 docker-compose.cpu.yml diff --git a/.env.example b/.env.example index 90251ac..1d650dd 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,19 @@ # Copy to .env and edit. `docker compose` reads .env automatically. # Every value here is optional — the defaults in docker-compose.yml match these. +# +# Requires an NVIDIA GPU + driver >= 560.28.03. There is no CPU-only mode for +# this image; see DEPLOY.md. # ---- Image ---- -# Pull a CI-built image instead of compiling whisper.cpp on every host. +# Pull a CI-built image instead of building on every host. # Pin a tag (e.g. :sha-abc1234) in production so you can roll back. IMAGE=ghcr.io/bcc-code/transcriber:latest +# Prebuilt whisper.cpp + CUDA base, used only by `docker compose build`. Bump to +# change the whisper.cpp or CUDA version; build it with +# .github/workflows/whisper-base.yml. The tag encodes both versions. +BASE_IMAGE=ghcr.io/bcc-code/whisper-cuda:v1.8.6-cuda12.6.3 + # ---- Storage ---- # Must already exist on the host, and must be the same path the caller puts in # the `path` / `output_path` fields — they are resolved inside the container. @@ -21,6 +29,8 @@ PORT=8888 # Concurrent transcription jobs. Each one runs its own whisper-cli process # holding a ~3 GB FP16 large-v3 model on the GPU; 2 fits comfortably in the # 3090's 24 GB, but raising this risks CUDA OOM (which surfaces as a failed job). +# Each job also uses scratch disk for chunk extraction (~115 MB per hour of +# audio), under the OS temp dir unless -scratch-dir is set. WORKERS=2 CALLBACK_WORKERS=2 DEFAULT_MODEL=whisper-cpp-large-v3 @@ -34,19 +44,3 @@ JOB_TIMEOUT=30m # above the largest burst the pipeline will submit. MAX_TERMINAL_JOBS=200 LOG_FORMAT=json -# true forces whisper-cli's CPU backend. Use with docker-compose.cpu.yml. -NO_GPU=false - -# ---- Build args (only used by `docker compose build`) ---- -# CUDA 12.6 requires host driver >= 560.28.03; the container refuses to start -# otherwise. Check with nvidia-smi. -CUDA_VERSION=12.6.3 -# SM architecture of the deployment GPU: 86 = RTX 3090/Ampere, 89 = L4/RTX 40xx, -# 90 = H100. Wrong value means PTX JIT at startup (slow) or a hard failure. -CUDA_ARCHS=86 -WHISPER_CPP_REF=v1.8.6 -# Compile parallelism for whisper.cpp. Empty auto-derives from available memory -# (~2 GB per concurrent nvcc), capped at the core count. Only set this if the -# build gets OOM-killed — the symptom is the build dying mid-compile with no -# compiler error. -BUILD_JOBS= diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 4bfb47e..7185a5d 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -1,9 +1,12 @@ name: image -# Builds the image once on an x86_64 runner and pushes it to GHCR, so on-prem -# hosts `docker compose pull` instead of compiling whisper.cpp + CUDA from -# source on every deploy. Also keeps nvcc off qemu, which is where local -# builds from an arm64 Mac fall over. +# Builds the app image (Go binary + embedded SPA) on top of the prebuilt +# whisper.cpp/CUDA base, and pushes it to GHCR so on-prem hosts `docker compose +# pull` instead of building. Nothing here compiles CUDA — that lives in +# whisper-base.yml — so this is a fast build. +# +# The base image must exist before this can run. On a fresh repo, dispatch the +# whisper-base workflow first. on: push: @@ -16,6 +19,8 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + # Keep in sync with docker-compose.yml / .env.example. + BASE_IMAGE: ghcr.io/${{ github.repository_owner }}/whisper-cuda:v1.8.6-cuda12.6.3 jobs: test: @@ -45,7 +50,8 @@ jobs: env: # Same-repo events (push, tag, branch PR) get a write-scoped # GITHUB_TOKEN and can authenticate to GHCR. Fork PRs get a read-only - # token, so they build without touching the registry cache. + # token, so they build without touching the registry — but they also + # can't pull the private base image, so the build is skipped entirely. CAN_PUSH: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} permissions: contents: read @@ -53,27 +59,11 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Free up disk space - # ubuntu-latest ships ~14 GB free on /. The CUDA devel image alone is - # ~8 GB extracted, plus the whisper build tree and the runtime image — - # it does not fit. Drop preinstalled toolchains this build never uses. - run: | - df -h / - sudo rm -rf \ - /usr/local/lib/android \ - /usr/share/dotnet \ - /opt/ghc \ - /usr/local/share/boost \ - /opt/hostedtoolcache/CodeQL - sudo docker image prune -af - df -h / - - uses: docker/setup-buildx-action@v3 - name: Log in to GHCR - # Logging in on same-repo PRs is deliberate: the PR build is the test - # run for the CUDA stage, and it needs the registry cache to not take - # ~30 min every time. It still doesn't publish the image (see `push:`). + # Required even on PRs: the FROM in Dockerfile needs to pull the base + # image, and the PR build is the test run for the app image. if: env.CAN_PUSH == 'true' uses: docker/login-action@v3 with: @@ -93,6 +83,7 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push + if: env.CAN_PUSH == 'true' uses: docker/build-push-action@v6 with: context: . @@ -100,15 +91,10 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - # The whisper.cpp/CUDA stage is ~15-30 min from cold, so it must be - # cached — but it's an *intermediate* stage, which rules out both GHA - # options: mode=max blows past GHA's 10 GB per-repo cache limit and - # thrashes, while mode=min only caches final-image layers and so - # misses the whisper stage entirely. Registry cache has no such cap - # and mode=max exports every stage. - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache - cache-to: ${{ env.CAN_PUSH == 'true' && format('type=registry,ref={0}/{1}:buildcache,mode=max', env.REGISTRY, env.IMAGE_NAME) || '' }} build-args: | - CUDA_VERSION=12.6.3 - CUDA_ARCHS=86 - WHISPER_CPP_REF=v1.8.6 + BASE_IMAGE=${{ env.BASE_IMAGE }} + # Only the frontend and Go stages are cached here — small, so GHA + # cache is a fine fit now that the CUDA build lives elsewhere. + # `not found` on the first run is expected and non-fatal. + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/whisper-base.yml b/.github/workflows/whisper-base.yml new file mode 100644 index 0000000..7e6cfa7 --- /dev/null +++ b/.github/workflows/whisper-base.yml @@ -0,0 +1,97 @@ +name: whisper-base + +# Builds the whisper.cpp + CUDA base image that the app image FROMs. This is the +# expensive build (15-30 min of nvcc), so it runs only when Dockerfile.whisper +# changes or when dispatched manually — not on every app change. +# +# The tag encodes both versions, e.g. v1.8.6-cuda12.6.3, so bumping either one +# produces a new image and BASE_IMAGE in .env / docker-compose.yml selects it. +# Nothing consumes `latest` by default; pin the versioned tag. + +on: + push: + branches: [main] + paths: [Dockerfile.whisper, .github/workflows/whisper-base.yml] + pull_request: + branches: [main] + paths: [Dockerfile.whisper, .github/workflows/whisper-base.yml] + workflow_dispatch: + inputs: + whisper_ref: + description: whisper.cpp git ref (tag or 40-char SHA) + default: v1.8.6 + cuda_version: + description: CUDA version (must match a nvidia/cuda image tag) + default: 12.6.3 + cuda_archs: + description: "SM arch: 86=RTX 3090, 89=L4/RTX 40xx, 90=H100" + default: "86" + +env: + REGISTRY: ghcr.io + IMAGE: ghcr.io/${{ github.repository_owner }}/whisper-cuda + +jobs: + build: + runs-on: ubuntu-latest + env: + WHISPER_REF: ${{ inputs.whisper_ref || 'v1.8.6' }} + CUDA_VERSION: ${{ inputs.cuda_version || '12.6.3' }} + CUDA_ARCHS: ${{ inputs.cuda_archs || '86' }} + # Fork PRs get a read-only token and cannot touch the registry. + CAN_PUSH: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v5 + + - name: Free up disk space + # ubuntu-latest ships ~14 GB free on /. The CUDA devel image alone is + # ~8 GB extracted, plus the whisper build tree and the runtime image — + # it does not fit. Drop preinstalled toolchains this build never uses. + run: | + df -h / + sudo rm -rf \ + /usr/local/lib/android \ + /usr/share/dotnet \ + /opt/ghc \ + /usr/local/share/boost \ + /opt/hostedtoolcache/CodeQL + sudo docker image prune -af + df -h / + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: env.CAN_PUSH == 'true' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.whisper + platforms: linux/amd64 + push: ${{ env.CAN_PUSH == 'true' && github.event_name != 'pull_request' }} + tags: | + ${{ env.IMAGE }}:${{ env.WHISPER_REF }}-cuda${{ env.CUDA_VERSION }} + ${{ env.IMAGE }}:latest + build-args: | + WHISPER_CPP_REF=${{ env.WHISPER_REF }} + CUDA_VERSION=${{ env.CUDA_VERSION }} + CUDA_ARCHS=${{ env.CUDA_ARCHS }} + + - name: Next step + if: env.CAN_PUSH == 'true' && github.event_name != 'pull_request' + run: | + echo "Pushed ${{ env.IMAGE }}:${{ env.WHISPER_REF }}-cuda${{ env.CUDA_VERSION }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "To use it, set in docker-compose.yml / .env:" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "BASE_IMAGE=${{ env.IMAGE }}:${{ env.WHISPER_REF }}-cuda${{ env.CUDA_VERSION }}" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/DEPLOY.md b/DEPLOY.md index c7b29f3..ba47555 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,11 +1,37 @@ # Deploying transcriber -Target: on-prem Linux host with an NVIDIA GPU, running Docker. The image -bundles `whisper-cli` (whisper.cpp, built with the **CUDA** GGML backend -plus OpenBLAS for the CPU fallback path), `ffmpeg`/`ffprobe`, and the Go -API + embedded SPA. Everything the container needs is in the image -except the ggml model files (downloaded from Hugging Face on first use -into a persisted volume). +Target: on-prem Linux host with an NVIDIA GPU, running Docker. Everything +the container needs is in the image except the ggml model files +(downloaded from Hugging Face on first use into a persisted volume). + +> **An NVIDIA GPU and driver are required.** `whisper-cli` is dynamically +> linked against `libcuda.so.1`, which the NVIDIA Container Toolkit injects +> at container start. Without it the dynamic loader fails before `main()`, +> so the binary cannot run at all — there is no CPU fallback in the +> container, and `-whispercpp-no-gpu` is unreachable there. For CPU or +> Apple-Metal hosts, run the Go binary natively against a native +> `whisper-cli` (see README.md); that is also the recommended local +> development path. + +## Two images + +The build is split in two, because compiling whisper.cpp with nvcc takes +15–30 minutes and only changes when whisper.cpp or CUDA does: + +| Image | Built by | Contains | +| ----------------------------------------- | ------------------------------------- | ----------------------------------------------------- | +| `ghcr.io/bcc-code/whisper-cuda:-cuda` | `Dockerfile.whisper`, `whisper-base.yml` | whisper.cpp (CUDA), ffmpeg/ffprobe, CUDA runtime libs | +| `ghcr.io/bcc-code/transcriber:` | `Dockerfile`, `image.yml` | the above + Go binary with embedded SPA | + +Ordinary code changes rebuild only the app image, in seconds. `BASE_IMAGE` +in `.env` / `docker-compose.yml` selects the base; the tag encodes both the +whisper.cpp ref and the CUDA version. + +To bump whisper.cpp or CUDA: run the **whisper-base** workflow +(`Actions → whisper-base → Run workflow`) with the new ref/version, then +update `BASE_IMAGE` in `.env.example`, `docker-compose.yml`, and +`image.yml`. On a fresh clone the base must be built once before the app +image can build at all. ## Preflight @@ -64,13 +90,8 @@ docker compose up -d GPU access is part of `docker-compose.yml`, so the short command above is the production command — there is no overlay to remember. On a host with -no NVIDIA GPU this fails loudly with "could not select device driver" -rather than silently running 20–50× slower on CPU. For those hosts: - -```sh -# CPU-only (Mac dev, CPU-only Linux). Also set NO_GPU=true in .env. -docker compose -f docker-compose.yml -f docker-compose.cpu.yml up -d -``` +no NVIDIA GPU it fails loudly with "could not select device driver", which +is the correct outcome: the image cannot run without a GPU regardless. The API is served on `:8888`. Open `http://:8888/` for the SPA or hit `POST /transcription/job` directly. `GET /healthz` and `GET /readyz` @@ -83,17 +104,36 @@ container healthcheck — `docker compose ps` shows health at a glance. > present. Treat a green `/readyz` as "the process is up", not "the next > job will succeed". -### Building the image - -Only the whisper.cpp/CUDA stage and the runtime stage are target-arch -(`linux/amd64`); the frontend and Go stages run natively on the build host -and cross-compile. So on an arm64 Mac, `docker compose build` still has to -qemu-emulate `nvcc`, which is slow (30–90 min) and prone to dying outright. - -Build on the Linux host, or let CI do it: `.github/workflows/image.yml` -builds on an x86_64 runner and pushes to -`ghcr.io/bcc-code/transcriber`, tagged `latest`, `sha-`, and -semver on tags. Pin a `sha-` tag in `.env` for a rollback-able deploy. +### Building the images + +**App image** (`Dockerfile`) — cheap. It only builds the SPA and the Go +binary, both of which run natively on the build host and cross-compile, so +nothing is qemu-emulated even on an arm64 Mac. Measured locally: ~2 s with +a warm cache. `.github/workflows/image.yml` builds it on every push and +tags `latest`, `sha-`, and semver on tags. Pin a `sha-` tag in +`.env` for a rollback-able deploy. + +**Base image** (`Dockerfile.whisper`) — expensive, and rarely rebuilt. +Compiling ggml-cuda is ~15–30 min on an x86_64 runner. Two things about +this build are easy to get wrong and are pinned deliberately: + +- **`BUILD_JOBS`.** nvcc needs roughly 2 GB of RAM per concurrent job for + ggml-cuda's template instantiations, so the build is memory-bound rather + than core-bound. A bare `-j` (unlimited) launched 138 concurrent nvcc + processes on a 4-vCPU/16 GB runner and the OOM killer took down the + runner agent mid-build, leaving no compiler error behind — the log simply + stopped. The job count is now derived from available memory and capped at + the core count; override with `--build-arg BUILD_JOBS=N`. +- **The CUDA driver stub on the link line.** ggml-cuda calls driver-API + functions (`cuMemCreate`, `cuGetErrorString`, …) that live in + `libcuda.so`, not `libcudart`. With `BUILD_SHARED_LIBS=ON`, + `libggml-cuda.so` links fine with those undefined, and the failure + surfaces much later as `undefined reference to 'cuGetErrorString'` when + an executable links against it. + +Build it on an x86_64 host or via the **whisper-base** workflow. On arm64 +it also builds natively (nvidia/cuda publishes arm64 manifests), which is +useful for validating changes to `Dockerfile.whisper` without qemu. The frontend build downloads webfonts from `fonts.gstatic.com` (`@nuxt/fonts`), so the *builder* needs outbound internet even though the diff --git a/Dockerfile b/Dockerfile index de9e861..5028ea1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,70 +1,22 @@ # syntax=docker/dockerfile:1.7 -# Only the whisper.cpp stage and the runtime stage have to be the *target* -# architecture (they carry CUDA and the compiled whisper-cli). The frontend and -# Go stages run natively on the build host ($BUILDPLATFORM) and cross-compile, -# which keeps them out of qemu when building for amd64 from an arm64 Mac. +# App image: the Go binary with the SPA embedded, on top of the prebuilt +# whisper.cpp/CUDA base (see Dockerfile.whisper). Nothing here compiles CUDA, so +# this builds in seconds — bump BASE_IMAGE when whisper.cpp or CUDA changes. # -# Building the CUDA stage on an arm64 host still means qemu-emulating nvcc, -# which is slow and flaky — build on an x86_64 host or in CI (see -# .github/workflows/image.yml) and `docker compose pull` on-prem. -ARG ARCH=amd64 -ARG CUDA_VERSION=12.6.3 - -# ---- Stage 1: build whisper.cpp (whisper-cli) with CUDA backend ---- -# Pinned to the on-prem host's GPU stack: NVIDIA only. CUDA is whisper.cpp's -# most-optimized backend (1.5–2× over Vulkan on NVIDIA). The runtime stage -# uses the matching nvidia/cuda runtime image so libcudart / libcublas etc. -# are available without polluting the host. -FROM --platform=linux/${ARCH} nvidia/cuda:${CUDA_VERSION}-devel-ubuntu24.04 AS whisper-build -ARG WHISPER_CPP_REF=v1.8.6 -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential cmake git ca-certificates pkg-config \ - libopenblas-dev \ - && rm -rf /var/lib/apt/lists/* -WORKDIR /src -# WHISPER_CPP_REF is a mutable tag — git tags can be force-moved, so the image -# is not bit-reproducible. Pin a 40-char commit SHA when that matters. -RUN git clone --depth 1 --branch ${WHISPER_CPP_REF} https://github.com/ggerganov/whisper.cpp.git . -# BLAS stays on for the CPU fallback path (`-whispercpp-no-gpu`); on CUDA -# hosts it's unused at runtime, so the only cost is image size. +# The frontend and Go stages run natively on the build host ($BUILDPLATFORM) and +# cross-compile, so building an amd64 image from an arm64 Mac never touches qemu. # -# CMAKE_CUDA_ARCHITECTURES must be pinned: ggml-cuda's default is `native`, -# which queries nvidia-smi on the build host — docker build has no GPU, so -# the build would fail. Pinned to sm_86 for the on-prem RTX 3090 (Ampere). -# If the deployment GPU changes, override CUDA_ARCHS — building for the wrong -# arch either falls back to PTX JIT at startup (slow) or fails outright. -ARG CUDA_ARCHS="86" -# BUILD_JOBS bounds compile parallelism. This matters: ggml-cuda's template -# instantiations need roughly 2 GB of RAM *per concurrent nvcc*, so the build is -# memory-bound, not core-bound. A bare `-j` means unlimited — it launched 138 -# concurrent nvcc processes in 2.6 s on a 4-vCPU/16 GB GitHub runner and the OOM -# killer took down the runner agent mid-build, with no compiler error to show -# for it. Empty derives a safe count from available memory, capped at the core -# count; override with --build-arg BUILD_JOBS=N. -ARG BUILD_JOBS -RUN cmake -B build \ - -DCMAKE_BUILD_TYPE=Release \ - -DGGML_CUDA=ON \ - -DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \ - -DGGML_BLAS=ON \ - -DGGML_BLAS_VENDOR=OpenBLAS \ - -DWHISPER_BUILD_TESTS=OFF \ - -DWHISPER_BUILD_EXAMPLES=ON \ - -DBUILD_SHARED_LIBS=ON \ - && set -eu \ - && cores="$(nproc)" \ - && memjobs="$(awk '/^MemTotal:/ {printf "%d", $2/1024/1024/3}' /proc/meminfo)" \ - && if [ "${memjobs}" -lt 1 ]; then memjobs=1; fi \ - && jobs="${BUILD_JOBS:-${memjobs}}" \ - && if [ "${jobs}" -gt "${cores}" ]; then jobs="${cores}"; fi \ - && echo "whisper.cpp build: -j${jobs} (cores=${cores} mem-derived=${memjobs})" \ - && cmake --build build --config Release --parallel "${jobs}" \ - && cmake --install build --prefix /opt/whisper -# Fail here rather than at first transcription if the install layout changes. -RUN test -x /opt/whisper/bin/whisper-cli +# NOTE: the base image requires an NVIDIA GPU and driver at runtime — whisper-cli +# is dynamically linked against libcuda.so.1, injected by the NVIDIA Container +# Toolkit. There is no CPU-only mode. For local development on a machine without +# an NVIDIA GPU, run the binary natively (`make build`) against a native +# whisper-cli; see README.md. -# ---- Stage 2: build frontend (Nuxt → static) ---- +ARG ARCH=amd64 +ARG BASE_IMAGE=ghcr.io/bcc-code/whisper-cuda:v1.8.6-cuda12.6.3 + +# ---- Stage 1: build frontend (Nuxt → static) ---- # Runs on the build host's native arch: the output is platform-independent # static JS/CSS, so there is nothing to gain from emulating the target. FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS frontend-build @@ -90,7 +42,7 @@ COPY frontend/ ./ # this stage needs outbound internet even though the result is self-contained. RUN pnpm ${PNPM_FLAGS} generate -# ---- Stage 3: build Go binary ---- +# ---- Stage 2: build Go binary ---- # Also native + cross-compiled: CGO_ENABLED=0 makes GOARCH a pure flag flip. FROM --platform=$BUILDPLATFORM golang:1.26.3-bookworm AS go-build ARG ARCH @@ -110,38 +62,14 @@ RUN rm -rf internal/web/dist && mkdir -p internal/web/dist COPY --from=frontend-build /src/frontend/.output/public/ ./internal/web/dist/ RUN GOARCH=${ARCH} go build -trimpath -ldflags='-s -w' -o /out/transcriber ./cmd/transcriber -# ---- Stage 4: runtime ---- -# Matches the whisper-build base so glibc / libstdc++ and CUDA runtime libs -# (libcudart, libcublas) are present without bundling the full toolkit. -# -# NOTE: nvidia/cuda images carry NVIDIA_REQUIRE_CUDA=cuda>=, which -# the NVIDIA Container Toolkit *enforces* at container start. With CUDA 12.6 the -# host driver must be >= 560.28.03 or the container refuses to start with -# "requirement error: unsatisfied condition: cuda>=12.6". Lower CUDA_VERSION or -# set NVIDIA_DISABLE_REQUIRE=1 to override. See DEPLOY.md. -FROM --platform=linux/${ARCH} nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu24.04 AS runtime -RUN apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg ca-certificates libgomp1 libstdc++6 \ - libopenblas0-pthread \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# NVIDIA Container Toolkit reads these to expose the right device files and -# driver libraries. `compute,utility` is all CUDA needs — we dropped `graphics` -# along with the Vulkan backend. -ENV NVIDIA_VISIBLE_DEVICES=all \ - NVIDIA_DRIVER_CAPABILITIES=compute,utility +# ---- Stage 3: runtime ---- +FROM --platform=linux/${ARCH} ${BASE_IMAGE} # Model cache: $XDG_CACHE_HOME/transcriber/hf// ENV XDG_CACHE_HOME=/var/cache \ WHISPER_CPP_BIN=/usr/local/bin/whisper-cli RUN mkdir -p /var/cache/transcriber/hf -# `cmake --install` lays out whisper-cli + the libwhisper/libggml shared libs -# it dynamically links against under one prefix. Drop it into /usr/local so the -# binary and libs land on the default $PATH / loader path. -COPY --from=whisper-build /opt/whisper/ /usr/local/ -RUN ldconfig COPY --from=go-build /out/transcriber /usr/local/bin/transcriber WORKDIR /app diff --git a/Dockerfile.whisper b/Dockerfile.whisper new file mode 100644 index 0000000..a34485f --- /dev/null +++ b/Dockerfile.whisper @@ -0,0 +1,120 @@ +# syntax=docker/dockerfile:1.7 + +# Base image: whisper.cpp (CUDA) + ffmpeg + CUDA runtime libs. +# +# Split out from the app Dockerfile because compiling whisper.cpp with nvcc +# takes 15-30 minutes and only changes when WHISPER_CPP_REF or CUDA_VERSION +# does — which is rarely. The app image FROMs this, so ordinary Go/frontend +# changes rebuild in seconds instead of recompiling CUDA kernels. +# +# Build and publish (see .github/workflows/whisper-base.yml): +# docker build -f Dockerfile.whisper \ +# -t ghcr.io/bcc-code/whisper-cuda:v1.8.6-cuda12.6.3 . +# +# NOTE: this image requires an NVIDIA GPU and driver at runtime. whisper-cli is +# dynamically linked against libcuda.so.1, which the NVIDIA Container Toolkit +# injects; without it the loader fails before main() and the binary cannot run +# at all. There is no CPU-only mode for this image — use a native whisper-cli +# build for CPU/Metal hosts. + +ARG ARCH=amd64 +ARG CUDA_VERSION=12.6.3 + +# ---- Stage 1: build whisper.cpp (whisper-cli) with the CUDA backend ---- +FROM --platform=linux/${ARCH} nvidia/cuda:${CUDA_VERSION}-devel-ubuntu24.04 AS whisper-build +ARG WHISPER_CPP_REF=v1.8.6 +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential cmake git ca-certificates pkg-config \ + libopenblas-dev \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /src +# WHISPER_CPP_REF is a mutable tag — git tags can be force-moved, so the image +# is not bit-reproducible. Pin a 40-char commit SHA when that matters. +RUN git clone --depth 1 --branch ${WHISPER_CPP_REF} https://github.com/ggerganov/whisper.cpp.git . + +# BLAS stays on for CPU-side work; the only cost is image size. +# +# CMAKE_CUDA_ARCHITECTURES must be pinned: ggml-cuda's default is `native`, +# which queries nvidia-smi on the build host — docker build has no GPU, so the +# build would fail. 86 = RTX 3090 (Ampere); 89 = L4/RTX 40xx; 90 = H100. +# Building for the wrong arch either falls back to PTX JIT at startup (slow) or +# fails outright. +ARG CUDA_ARCHS="86" + +# BUILD_JOBS bounds compile parallelism. This matters: ggml-cuda's template +# instantiations need roughly 2 GB of RAM *per concurrent nvcc*, so the build is +# memory-bound, not core-bound. A bare `-j` means unlimited — it launched 138 +# concurrent nvcc processes in 2.6 s on a 4-vCPU/16 GB GitHub runner and the OOM +# killer took down the runner agent mid-build, with no compiler error to show +# for it. Empty derives a safe count from available memory, capped at the core +# count; override with --build-arg BUILD_JOBS=N. +ARG BUILD_JOBS + +# The CUDA *driver* API stub must be on the link line. ggml-cuda calls +# cuMemCreate / cuMemMap / cuDeviceGet / cuGetErrorString etc., which live in +# libcuda.so (driver), not libcudart (runtime). With BUILD_SHARED_LIBS=ON, +# libggml-cuda.so builds fine with those symbols undefined — shared libraries +# tolerate that — and the failure only appears later when an executable links +# against it ("undefined reference to `cuGetErrorString'"). The devel image ships +# a stub at this arch-independent path; DT_NEEDED resolves to the real driver at +# runtime, which the NVIDIA Container Toolkit injects. +ARG CUDA_STUBS=/usr/local/cuda/lib64/stubs + +RUN cmake -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DGGML_CUDA=ON \ + -DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \ + -DGGML_BLAS=ON \ + -DGGML_BLAS_VENDOR=OpenBLAS \ + -DWHISPER_BUILD_TESTS=OFF \ + -DWHISPER_BUILD_EXAMPLES=ON \ + -DBUILD_SHARED_LIBS=ON \ + -DCMAKE_SHARED_LINKER_FLAGS="-L${CUDA_STUBS} -lcuda" \ + -DCMAKE_EXE_LINKER_FLAGS="-L${CUDA_STUBS} -lcuda" \ + && set -eu \ + && cores="$(nproc)" \ + && memjobs="$(awk '/^MemTotal:/ {printf "%d", $2/1024/1024/3}' /proc/meminfo)" \ + && if [ "${memjobs}" -lt 1 ]; then memjobs=1; fi \ + && jobs="${BUILD_JOBS:-${memjobs}}" \ + && if [ "${jobs}" -gt "${cores}" ]; then jobs="${cores}"; fi \ + && echo "whisper.cpp build: -j${jobs} (cores=${cores} mem-derived=${memjobs})" \ + && cmake --build build --config Release --parallel "${jobs}" \ + && cmake --install build --prefix /opt/whisper +# Fail here rather than at first transcription if the install layout changes. +RUN test -x /opt/whisper/bin/whisper-cli + +# ---- Stage 2: the published base ---- +# Matches the whisper-build base so glibc / libstdc++ and the CUDA runtime libs +# (libcudart, libcublas) are present without bundling the full toolkit. +# +# NOTE: nvidia/cuda images carry NVIDIA_REQUIRE_CUDA=cuda>=, which +# the NVIDIA Container Toolkit *enforces* at container start. With CUDA 12.6 the +# host driver must be >= 560.28.03 or the container refuses to start with +# "requirement error: unsatisfied condition: cuda>=12.6". Lower CUDA_VERSION or +# set NVIDIA_DISABLE_REQUIRE=1 to override. See DEPLOY.md. +FROM --platform=linux/${ARCH} nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg ca-certificates libgomp1 libstdc++6 \ + libopenblas0-pthread \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# NVIDIA Container Toolkit reads these to expose the right device files and +# driver libraries. `compute,utility` is all CUDA needs — `compute` is what +# gets libcuda.so.1 injected, which whisper-cli requires to start. +ENV NVIDIA_VISIBLE_DEVICES=all \ + NVIDIA_DRIVER_CAPABILITIES=compute,utility + +# `cmake --install` lays out whisper-cli plus the libwhisper/libggml shared libs +# it dynamically links against under one prefix. Drop it into /usr/local so the +# binary and libs land on the default $PATH / loader path. +COPY --from=whisper-build /opt/whisper/ /usr/local/ +RUN ldconfig + +# Sanity-check the layout. whisper-cli itself can't be executed here (no driver +# in the build container), so verify the loader can resolve everything *except* +# libcuda.so.1, which the toolkit injects at runtime. +RUN set -eu; \ + missing="$(ldd /usr/local/bin/whisper-cli | awk '/not found/ {print $1}' | grep -v '^libcuda\.so\.1$' || true)"; \ + if [ -n "${missing}" ]; then echo "unresolved libraries: ${missing}"; exit 1; fi; \ + ffmpeg -version >/dev/null && ffprobe -version >/dev/null diff --git a/README.md b/README.md index a920d1f..c646dda 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,13 @@ to extract each chunk to a 16kHz mono wav. Model files are downloaded from Hugging Face on first use and cached on disk. The `stub` adapter has no external dependencies. +On macOS, `brew install whisper-cpp ffmpeg` covers all three, and brew's +`whisper-cli` is Metal-accelerated — so a native run is both the fastest +and the only workable local option. **Do not use Docker for local +development:** the image requires an NVIDIA GPU (`whisper-cli` there is +linked against `libcuda.so.1`), so it cannot start on a Mac at all. See +DEPLOY.md. + ## Configuration The set of registered models lives in `cmd/transcriber/models.go` as typed diff --git a/docker-compose.cpu.yml b/docker-compose.cpu.yml deleted file mode 100644 index bc61bc1..0000000 --- a/docker-compose.cpu.yml +++ /dev/null @@ -1,16 +0,0 @@ -# CPU-only overlay for hosts with no NVIDIA GPU (Mac dev, CPU-only Linux). -# Pair with NO_GPU=true in .env so whisper-cli is also told to use its CPU -# backend — without that flag the CUDA backend initializes, finds no device, -# and every job fails. -# -# docker compose -f docker-compose.yml -f docker-compose.cpu.yml up -d -# -# `!reset` drops the GPU device reservation inherited from docker-compose.yml. -# An empty list (`devices: []`) does NOT work — Compose treats that as "no -# change" and keeps the nvidia reservation, which then fails to start. -# -# Transcription on CPU is 20-50x slower than on the 3090 — fine for a sanity -# check on a short file, not for production throughput. -services: - transcriber: - deploy: !reset null diff --git a/docker-compose.yml b/docker-compose.yml index 47e52ae..e8f7a17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,14 @@ -# Single compose file for all hosts. Every knob comes from `.env` — copy -# .env.example to .env and edit. See DEPLOY.md. +# Single compose file. Every knob comes from `.env` — copy .env.example to .env +# and edit. See DEPLOY.md. # # docker compose pull && docker compose up -d # registry image (preferred) -# docker compose build && docker compose up -d # build locally +# docker compose build && docker compose up -d # build the app image locally # -# GPU access is part of THIS file, not an opt-in overlay, so the short command -# is the correct production command. On a host without an NVIDIA GPU this fails -# loudly ("could not select device driver") rather than silently running 20-50x -# slower on CPU — for those hosts add -f docker-compose.cpu.yml. +# This service REQUIRES an NVIDIA GPU and driver. whisper-cli is dynamically +# linked against libcuda.so.1, which the NVIDIA Container Toolkit injects, so +# without a GPU the binary cannot start at all — there is no CPU fallback in the +# container. For local development on a machine without an NVIDIA GPU, run the +# binary natively instead (`make build`); see README.md. name: transcriber services: @@ -16,10 +17,9 @@ services: build: context: . args: - CUDA_VERSION: ${CUDA_VERSION:-12.6.3} - CUDA_ARCHS: ${CUDA_ARCHS:-86} - WHISPER_CPP_REF: ${WHISPER_CPP_REF:-v1.8.6} - BUILD_JOBS: ${BUILD_JOBS:-} + # Prebuilt whisper.cpp/CUDA base. Bump this to change whisper or CUDA + # versions; it is built separately by .github/workflows/whisper-base.yml. + BASE_IMAGE: ${BASE_IMAGE:-ghcr.io/bcc-code/whisper-cuda:v1.8.6-cuda12.6.3} restart: unless-stopped # BIND_ADDR=0.0.0.0 preserves the previous behaviour: the API has no @@ -38,9 +38,6 @@ services: - "-job-timeout=${JOB_TIMEOUT:-30m}" - "-max-terminal-jobs=${MAX_TERMINAL_JOBS:-200}" - "-log-format=${LOG_FORMAT:-json}" - # Go's flag package accepts an explicit boolean value, so this stays a - # single env toggle instead of a second compose file. - - "-whispercpp-no-gpu=${NO_GPU:-false}" volumes: # ggml model cache — first job downloads ~3 GB, this keeps it. From d0d5a7e3acbd463f7c2396eaa96d0a045979c251 Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 12 Aug 2026 15:22:08 +0200 Subject: [PATCH 7/9] trigger build From a08fb3b61c9b03a203ff4a8b60704953c1353317 Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 12 Aug 2026 15:35:21 +0200 Subject: [PATCH 8/9] fix(build): actually publish whisper image --- .github/workflows/image.yml | 13 ++++ .github/workflows/whisper-base.yml | 103 ++++++++++++++++++++--------- DEPLOY.md | 26 ++++++-- 3 files changed, 105 insertions(+), 37 deletions(-) diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 7185a5d..469b083 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -71,6 +71,19 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Verify the base image exists + # Without this, a missing base fails deep inside buildx as + # "failed to solve: ...: not found" pointing at a FROM line, which + # doesn't hint at the actual fix. + if: env.CAN_PUSH == 'true' + run: | + if docker buildx imagetools inspect "${BASE_IMAGE}" >/dev/null 2>&1; then + echo "base image ok: ${BASE_IMAGE}" + else + echo "::error::Base image ${BASE_IMAGE} not found. It must be built and pushed once before the app image can build. Push a change to Dockerfile.whisper, or run the whisper-base workflow (available in the Actions UI only once that workflow is on the default branch)." + exit 1 + fi + - id: meta uses: docker/metadata-action@v5 with: diff --git a/.github/workflows/whisper-base.yml b/.github/workflows/whisper-base.yml index 7e6cfa7..becb0f5 100644 --- a/.github/workflows/whisper-base.yml +++ b/.github/workflows/whisper-base.yml @@ -1,20 +1,33 @@ name: whisper-base # Builds the whisper.cpp + CUDA base image that the app image FROMs. This is the -# expensive build (15-30 min of nvcc), so it runs only when Dockerfile.whisper -# changes or when dispatched manually — not on every app change. +# expensive build (15-30 min of nvcc), so it is heavily gated: +# +# - Triggers on `push` (any branch) only when Dockerfile.whisper or this file +# changes. `push` evaluates `paths` against that push's own diff, so an +# unrelated commit will not fire it. +# - Deliberately NOT on `pull_request`: for PR events `paths` is evaluated +# against the whole PR diff, so it re-fires on *every* push to a PR branch, +# and PR runs can't publish anyway — which would leave the app image build +# permanently unable to resolve its base. +# - Skips the build entirely when the target tag already exists, so an +# accidental re-trigger costs seconds instead of half an hour. Pass +# `force: true` (or bump a version) to republish. # # The tag encodes both versions, e.g. v1.8.6-cuda12.6.3, so bumping either one -# produces a new image and BASE_IMAGE in .env / docker-compose.yml selects it. -# Nothing consumes `latest` by default; pin the versioned tag. +# produces a new image; BASE_IMAGE in .env / docker-compose.yml / image.yml +# selects it. Pin the versioned tag — nothing should depend on `latest`. +# +# NOTE: because the tag is shared, changing *how* the base is built without +# changing a version will not republish unless you pass force: true. That is +# intentional — it stops an in-progress branch from silently replacing a base +# image the deployed app is running on. on: push: - branches: [main] - paths: [Dockerfile.whisper, .github/workflows/whisper-base.yml] - pull_request: - branches: [main] - paths: [Dockerfile.whisper, .github/workflows/whisper-base.yml] + paths: + - Dockerfile.whisper + - .github/workflows/whisper-base.yml workflow_dispatch: inputs: whisper_ref: @@ -26,6 +39,10 @@ on: cuda_archs: description: "SM arch: 86=RTX 3090, 89=L4/RTX 40xx, 90=H100" default: "86" + force: + description: Rebuild and republish even if the tag already exists + type: boolean + default: false env: REGISTRY: ghcr.io @@ -38,15 +55,42 @@ jobs: WHISPER_REF: ${{ inputs.whisper_ref || 'v1.8.6' }} CUDA_VERSION: ${{ inputs.cuda_version || '12.6.3' }} CUDA_ARCHS: ${{ inputs.cuda_archs || '86' }} - # Fork PRs get a read-only token and cannot touch the registry. - CAN_PUSH: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} permissions: contents: read packages: write steps: - uses: actions/checkout@v5 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve tag and check whether it already exists + id: check + run: | + tag="${IMAGE}:${WHISPER_REF}-cuda${CUDA_VERSION}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + if docker buildx imagetools inspect "${tag}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "${tag} already exists" >> "$GITHUB_STEP_SUMMARY" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Skip notice + if: steps.check.outputs.exists == 'true' && !inputs.force + run: | + echo "Skipping the ~20 min CUDA build: ${{ steps.check.outputs.tag }} is already published." \ + >> "$GITHUB_STEP_SUMMARY" + echo "Re-run this workflow with force: true to republish." >> "$GITHUB_STEP_SUMMARY" + - name: Free up disk space + if: steps.check.outputs.exists != 'true' || inputs.force # ubuntu-latest ships ~14 GB free on /. The CUDA devel image alone is # ~8 GB extracted, plus the whisper build tree and the runtime image — # it does not fit. Drop preinstalled toolchains this build never uses. @@ -61,37 +105,32 @@ jobs: sudo docker image prune -af df -h / - - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - if: env.CAN_PUSH == 'true' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push + if: steps.check.outputs.exists != 'true' || inputs.force uses: docker/build-push-action@v6 with: context: . file: Dockerfile.whisper platforms: linux/amd64 - push: ${{ env.CAN_PUSH == 'true' && github.event_name != 'pull_request' }} + push: true + # `latest` only from the default branch: a feature branch may publish + # the versioned tag to bootstrap itself, but must not move `latest`. tags: | - ${{ env.IMAGE }}:${{ env.WHISPER_REF }}-cuda${{ env.CUDA_VERSION }} - ${{ env.IMAGE }}:latest + ${{ steps.check.outputs.tag }} + ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && format('{0}:latest', env.IMAGE) || '' }} build-args: | WHISPER_CPP_REF=${{ env.WHISPER_REF }} CUDA_VERSION=${{ env.CUDA_VERSION }} CUDA_ARCHS=${{ env.CUDA_ARCHS }} - - name: Next step - if: env.CAN_PUSH == 'true' && github.event_name != 'pull_request' + - name: Summary + if: steps.check.outputs.exists != 'true' || inputs.force run: | - echo "Pushed ${{ env.IMAGE }}:${{ env.WHISPER_REF }}-cuda${{ env.CUDA_VERSION }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "To use it, set in docker-compose.yml / .env:" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "BASE_IMAGE=${{ env.IMAGE }}:${{ env.WHISPER_REF }}-cuda${{ env.CUDA_VERSION }}" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY + { + echo "Published \`${{ steps.check.outputs.tag }}\`" + echo "" + echo "Set this as BASE_IMAGE in \`.env\`, \`docker-compose.yml\`, and \`.github/workflows/image.yml\`:" + echo '```' + echo "BASE_IMAGE=${{ steps.check.outputs.tag }}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/DEPLOY.md b/DEPLOY.md index ba47555..4aa3590 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -27,11 +27,27 @@ Ordinary code changes rebuild only the app image, in seconds. `BASE_IMAGE` in `.env` / `docker-compose.yml` selects the base; the tag encodes both the whisper.cpp ref and the CUDA version. -To bump whisper.cpp or CUDA: run the **whisper-base** workflow -(`Actions → whisper-base → Run workflow`) with the new ref/version, then -update `BASE_IMAGE` in `.env.example`, `docker-compose.yml`, and -`image.yml`. On a fresh clone the base must be built once before the app -image can build at all. +The base must exist before the app image can build at all — the app's +`FROM` resolves it. `image.yml` checks for it up front and fails with an +actionable message rather than a cryptic buildx error. + +**Publishing a base.** `whisper-base.yml` triggers on a push (any branch) +that touches `Dockerfile.whisper`, and via `Actions → whisper-base → Run +workflow` once the workflow is on the default branch — `workflow_dispatch` +is not offered for workflows that only exist on a feature branch, so on a +new branch the push path is how you bootstrap it. + +It **skips the build if the target tag already exists**, so a re-trigger +costs seconds rather than ~20 minutes. Consequently, changing *how* the +base is built without changing a version does not republish: pass +`force: true`. That is deliberate — it stops an in-progress branch from +silently replacing the base image the deployed app is running on. A +feature branch may publish the versioned tag, but only the default branch +moves `latest`. + +**Bumping whisper.cpp or CUDA:** dispatch whisper-base with the new +ref/version, then update `BASE_IMAGE` in `.env.example`, +`docker-compose.yml`, and `image.yml`. ## Preflight From ed1670f8d8cb90cb0f7340c2ab76f438c65e8101 Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 12 Aug 2026 16:09:31 +0200 Subject: [PATCH 9/9] chore(build): remove cache from ci --- .github/workflows/image.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 469b083..1c53a09 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -106,8 +106,11 @@ jobs: labels: ${{ steps.meta.outputs.labels }} build-args: | BASE_IMAGE=${{ env.BASE_IMAGE }} - # Only the frontend and Go stages are cached here — small, so GHA - # cache is a fine fit now that the CUDA build lives elsewhere. - # `not found` on the first run is expected and non-fatal. - cache-from: type=gha - cache-to: type=gha,mode=max + # No build cache, deliberately. Measured on the first green run: the + # cacheable work is pnpm install (8.9s) + generate (8.2s) + go build + # (19.8s) = ~37s, while `cache-to: type=gha,mode=max` spent 271s + # exporting — 79% of a 342s build. mode=max exports *every* stage, + # which here includes the ~3 GB CUDA base image's layers, so it also + # blows GHA's 10 GB per-repo cache limit. Paying 271s to save 37s is + # a bad trade; the rest of the build is registry pull/push, which a + # build cache doesn't help with anyway.