🐆 perf: Build Dev Branch Images on Native ARM and Cheapen Asset Brotli (#15441)

The dev-branch image build takes 20-40 minutes and has hit its 130-minute
timeout. Neither cause was in the bundler.

Per-step timings from run 33424998112 show QEMU is ~84% of the work:

  npm ci             52.6s (amd64)  vs  333.2s (arm64, QEMU)  — 6.3x
  npm run frontend   90.4s (amd64)  vs  442.8s (arm64, QEMU)  — 4.9x

So build each platform on a runner of its own architecture, push by digest,
and stitch the digests into the multi-platform manifest lists in a merge job.
The tags consumers pull are unchanged. Layer caching goes to GHCR beside the
image rather than `type=gha`: the Actions cache is capped at 10GB repo-wide
and is branch-scoped, so `dev` and `main` runs would not share it, while a
registry cache has no cap and is reusable by the other four image workflows
when this pattern is rolled out to them.

Separately, vite-plugin-compression2 defaults to brotli quality 11, which
costs ~13s of CPU on this 18.4MB bundle against ~0.47s for gzip-9, and the
plugin's scheduler serializes quality >= 10 as a high-memory operation so it
cannot use the runner's cores. Quality 5 compresses in ~0.2s and still lands
under gzip (4.3MB vs 5.0MB). `.br` is only served when
ENABLE_STATIC_ASSET_BROTLI is set, which is off by default, so the 0.5MB the
top quality buys is paid for on every build of every platform and rarely
collected. The client build goes from ~11s to ~4s; gzip output is unchanged.

This is a pilot on the dev-branch workflow only. dev-images,
dev-staging-images, main-image-workflow and tag-images still use QEMU and
still have no layer cache.
This commit is contained in:
Danny Avila 2026-08-31 22:05:13 -04:00 • committed by GitHub
parent bd24e45489
commit d3ea6f415d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 133 additions and 17 deletions

View file

@ -33,29 +33,51 @@ concurrency:
cancel-in-progress: true
jobs:
# Each platform builds on a runner of its own architecture and pushes an
# untagged, digest-addressed manifest. Building linux/arm64 on an amd64 runner
# needs QEMU, which measured 5-6x slower than native on this image
# (npm ci 52.6s -> 333.2s, npm run frontend 90.4s -> 442.8s) and made the
# emulated leg ~84% of the build. The `merge` job below stitches the digests
# into the multi-platform manifest lists that consumers actually pull.
build:
runs-on: ubuntu-latest
timeout-minutes: 130
name: Build ${{ matrix.image_name }} (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- target: api-build
file: Dockerfile.multi
image_name: lc-dev-api
platform: linux/amd64
arch: amd64
runner: ubuntu-latest
- target: api-build
file: Dockerfile.multi
image_name: lc-dev-api
platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
- target: node
file: Dockerfile
image_name: lc-dev
platform: linux/amd64
arch: amd64
runner: ubuntu-latest
- target: node
file: Dockerfile
image_name: lc-dev
platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
steps:
# Check out the repository
- name: Checkout
uses: actions/checkout@v5
# Set up QEMU
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
# Set up Docker Buildx
# Set up Docker Buildx (no QEMU: the runner is already the target arch)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@ -85,21 +107,100 @@ jobs:
echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
# Build and push Docker images for each target
- name: Build and push Docker images
# Build and push a digest-only image for this one platform. The layer cache
# lives in GHCR beside the image (no 10GB Actions-cache cap, and readable
# from every branch and workflow, unlike type=gha which is branch-scoped).
# The cache ref is per-architecture so the two platform jobs cannot clobber
# each other's manifest.
- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.file }}
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.platform }}
target: ${{ matrix.target }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:buildcache-${{ matrix.arch }}
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:buildcache-${{ matrix.arch }},mode=max
outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }},docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}",push-by-digest=true,name-canonical=true,push=true
build-args: |
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
BUILD_DATE=${{ env.BUILD_DATE }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.image_name }}-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Combine the per-architecture digests into one multi-platform manifest list
# per registry, and apply the tags consumers pull.
merge:
name: Merge manifests (${{ matrix.image_name }})
runs-on: ubuntu-latest
needs: build
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
image_name: [lc-dev-api, lc-dev]
steps:
- name: Download digests
uses: actions/download-artifact@v7
with:
pattern: digests-${{ matrix.image_name }}-*
merge-multiple: true
path: /tmp/digests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# The digests are identical across registries (the manifest is
# content-addressed), so the same set sources both manifest lists.
- name: Create manifest lists and push
working-directory: /tmp/digests
env:
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}
run: |
set -euo pipefail
shopt -s nullglob
digests=(*)
if [ "${#digests[@]}" -eq 0 ]; then
echo "No digests downloaded; refusing to publish an empty manifest." >&2
exit 1
fi
echo "Merging ${#digests[@]} platform digests: ${digests[*]}"
for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do
docker buildx imagetools create \
-t "${image}:${{ github.sha }}" \
-t "${image}:latest" \
"${digests[@]/#/${image}@sha256:}"
done
- name: Inspect image
run: |
docker buildx imagetools inspect ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}

View file

@ -1,11 +1,12 @@
import react from '@vitejs/plugin-react';
import fs from 'fs';
import path from 'path';
import { constants } from 'zlib';
import { defineConfig } from 'vite';
import { createRequire } from 'module';
import { VitePWA } from 'vite-plugin-pwa';
import { compression } from 'vite-plugin-compression2';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import { compression, defineAlgorithm } from 'vite-plugin-compression2';
import type { Plugin } from 'vite';
const require = createRequire(import.meta.url);
@ -178,6 +179,20 @@ export default defineConfig(({ command }) => ({
...(buildSourceMap ? [sourcemapExclude({ excludeNodeModules: true })] : []),
compression({
threshold: 10240,
/**
* Brotli's default quality of 11 costs ~13s of single-threaded CPU on this bundle
* against ~0.5s for gzip, and the plugin's scheduler serializes quality >= 10 as a
* high-memory operation. Quality 5 compresses in ~0.2s, still lands under gzip
* (4.3MB vs 5.0MB), and parallelizes. `.br` is only served when
* ENABLE_STATIC_ASSET_BROTLI is set, so the extra 0.5MB buys far less than it costs
* on every build of every platform.
*/
algorithms: [
defineAlgorithm('gzip', { level: 9 }),
defineAlgorithm('brotliCompress', {
params: { [constants.BROTLI_PARAM_QUALITY]: 5 },
}),
],
}),
],
optimizeDeps: {