From d3ea6f415d374983a8358247282faee16ac0b535 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 31 Aug 2026 22:05:13 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=86=20perf:=20Build=20Dev=20Branch=20I?= =?UTF-8?q?mages=20on=20Native=20ARM=20and=20Cheapen=20Asset=20Brotli=20(#?= =?UTF-8?q?15441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/dev-branch-images.yml | 133 +++++++++++++++++++++--- client/vite.config.ts | 17 ++- 2 files changed, 133 insertions(+), 17 deletions(-) diff --git a/.github/workflows/dev-branch-images.yml b/.github/workflows/dev-branch-images.yml index 5a85779b11..48d153d2bb 100644 --- a/.github/workflows/dev-branch-images.yml +++ b/.github/workflows/dev-branch-images.yml @@ -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 }} diff --git a/client/vite.config.ts b/client/vite.config.ts index 8c7830dfdf..a260f96369 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -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: {