perf(build): keep test suites out of the image and cache CI builds durably
CI / check (push) Successful in 30s
CI / tests-unit (push) Successful in 1m53s
CI / tests-integration (push) Successful in 1m54s
CI / tests-imaging-browsers (push) Successful in 2m26s
CI / tests-quality (push) Successful in 2m19s
CI / tests-ui (push) Successful in 4m31s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 3m9s
CI / check (push) Successful in 30s
CI / tests-unit (push) Successful in 1m53s
CI / tests-integration (push) Successful in 1m54s
CI / tests-imaging-browsers (push) Successful in 2m26s
CI / tests-quality (push) Successful in 2m19s
CI / tests-ui (push) Successful in 4m31s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 3m9s
Three build-time improvements, all measured on this host. The build context no longer ships the test suites. `next build` type-checks the whole project, so `e2e/` and `integration/` in the context made the image build fail on a type error in test code and pulled @playwright/test, vitest, msw, testcontainers and drizzle-kit into the image. The CI runs those suites on the host. Type-check time in the build drops from 2.2s to 1.3s, and a contract test now fails if application code ever imports from those folders. The CI now exports the build cache to a directory outside the runner workspace: /var/cache/epicnext-cms-buildcache for the release and a separate map for branch preflights, so a branch can never feed the release cache. The workspace is wiped after every job and `docker builder prune` runs after every release, so the next build started with an empty cache and downloaded all 493 packages again (measured: reused 0, downloaded 493). With the durable export the frozen dependency layer is reused (measured: reused 493, downloaded 0) even after a full prune. A cache directory that is not writable only produces a warning; the release is never blocked. Investigated the remaining ~30s of Next.js build tracing: excluding the dev-only packages with `outputFileTracingExcludes` made it slower (35s vs 30s), because the traced output was already minimal (5 packages). The cost is Next's own page-config collection and file tracing over 329 routes, not the dependency set, so no configuration was kept.
This commit is contained in:
1 parent
8ca538f99f
commit
ecd8e5852c
8 files changed
+148
-2
No files matched your search
@@ -26,6 +26,13 @@ bun.lockb
|
|||||||
public/nitro-assets
|
public/nitro-assets
|
||||||
public/swf
|
public/swf
|
||||||
|
|
||||||
|
# Testbronnen draaien op de host (pnpm test:ui / test:integration / test:e2e),
|
||||||
|
# de image heeft ze niet nodig. In de image zouden ze alleen de typecheck van
|
||||||
|
# `next build` meebepalen en de image van @playwright/test, vitest, msw,
|
||||||
|
# testcontainers en drizzle-kit afhankelijk maken.
|
||||||
|
e2e/
|
||||||
|
integration/
|
||||||
|
|
||||||
# Databasebackups (~232MB, dagelijkse rotatie): horen niet in de buildcontext.
|
# Databasebackups (~232MB, dagelijkse rotatie): horen niet in de buildcontext.
|
||||||
# Ze maakten elke context groot en invalideerden bij elke rotatie de
|
# Ze maakten elke context groot en invalideerden bij elke rotatie de
|
||||||
# `COPY . .`-laag, waardoor de Next-build volledig opnieuw draaide.
|
# `COPY . .`-laag, waardoor de Next-build volledig opnieuw draaide.
|
||||||
|
|||||||
@@ -329,6 +329,24 @@ Two caches make repeated builds cheap:
|
|||||||
daemon cache to 4 GB after every deploy. The directory is ignored by both
|
daemon cache to 4 GB after every deploy. The directory is ignored by both
|
||||||
Git and the build context; delete it to force a cold build.
|
Git and the build context; delete it to force a cold build.
|
||||||
|
|
||||||
|
The CI has the same problem on a bigger scale: the runner workspace is wiped
|
||||||
|
after every job and `scripts/docker-prune.sh` trims the daemon cache after
|
||||||
|
every release, so the next build started with an empty cache and downloaded all
|
||||||
|
493 packages again (measured: `reused 0, downloaded 493`). `scripts/ci-deploy.sh`
|
||||||
|
and `scripts/ci-preflight.sh` therefore export the build cache to a directory
|
||||||
|
outside the workspace — `/var/cache/epicnext-cms-buildcache` for the release and
|
||||||
|
`/var/cache/epicnext-cms-preflight-cache` for branch preflights, so a branch can
|
||||||
|
never feed the release cache. With that export in place the same build re-uses
|
||||||
|
the frozen dependency layer (`reused 493, downloaded 0`) even after a full
|
||||||
|
`docker builder prune`. Set `CMS_BUILD_CACHE_DIR` to another path to move the
|
||||||
|
cache, or to an empty value to switch it off.
|
||||||
|
|
||||||
|
The build context excludes `e2e/` and `integration/`. `next build` type-checks
|
||||||
|
the whole project, so shipping the test suites inside the image made a type
|
||||||
|
error in test code break the image build and pulled `@playwright/test`, `vitest`,
|
||||||
|
`msw` and `testcontainers` into it. The CI runs those suites on the host
|
||||||
|
(`pnpm test:ui`, `pnpm test:integration`).
|
||||||
|
|
||||||
### Volumes
|
### Volumes
|
||||||
|
|
||||||
Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#avatar-imaging)) run in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist:
|
Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#avatar-imaging)) run in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist:
|
||||||
|
|||||||
@@ -416,9 +416,26 @@ pnpm install --frozen-lockfile
|
|||||||
pnpm exec playwright install chromium firefox webkit
|
pnpm exec playwright install chromium firefox webkit
|
||||||
|
|
||||||
echo "Building $image"
|
echo "Building $image"
|
||||||
|
# Duurzame buildcache buiten de werkboom. De CI-werkboom wordt na elke job
|
||||||
|
# gewist en `docker builder prune` gooit de buildcache weg, waarna de hele
|
||||||
|
# pnpm-laag opnieuw gedownload moet worden. Een lokale cache-export overleeft
|
||||||
|
# allebei. Een lege CMS_BUILD_CACHE_DIR schakelt de cache uit.
|
||||||
|
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-buildcache}"
|
||||||
|
build_cache_flags=()
|
||||||
|
if [ -n "$build_cache_dir" ]; then
|
||||||
|
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||||
|
build_cache_flags=(
|
||||||
|
--cache-from "type=local,src=$build_cache_dir"
|
||||||
|
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
||||||
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
|
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
|
||||||
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
|
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
|
||||||
|
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||||
check_current
|
check_current
|
||||||
# Read reports from the already-built image; do not start an extra application.
|
# Read reports from the already-built image; do not start an extra application.
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ pnpm exec playwright install chromium
|
|||||||
export NEWS_E2E_IMAGE="$image"
|
export NEWS_E2E_IMAGE="$image"
|
||||||
export NEWS_E2E_RELEASE="$sha"
|
export NEWS_E2E_RELEASE="$sha"
|
||||||
build_attempted=1
|
build_attempted=1
|
||||||
|
# Duurzame buildcache buiten de tijdelijke werkmap, zodat de lagen van een
|
||||||
|
# vorige preflight hergebruikt worden in plaats van opnieuw gedownload. Eigen
|
||||||
|
# map, gescheiden van de release-cache: een branch-preflight mag nooit de cache
|
||||||
|
# van de release-build voeden. Een lege CMS_BUILD_CACHE_DIR schakelt het uit.
|
||||||
|
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-preflight-cache}"
|
||||||
|
build_cache_flags=()
|
||||||
|
if [ -n "$build_cache_dir" ]; then
|
||||||
|
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||||
|
build_cache_flags=(
|
||||||
|
--cache-from "type=local,src=$build_cache_dir"
|
||||||
|
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
||||||
|
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||||
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
||||||
@@ -29,6 +29,7 @@ function simulate(
|
|||||||
scenario: string,
|
scenario: string,
|
||||||
previous = "epicnext-cms-app",
|
previous = "epicnext-cms-app",
|
||||||
both = false,
|
both = false,
|
||||||
|
extraEnv: Record<string, string> = {},
|
||||||
) {
|
) {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "cms-deploy-test-"));
|
const dir = mkdtempSync(join(tmpdir(), "cms-deploy-test-"));
|
||||||
try {
|
try {
|
||||||
@@ -58,6 +59,9 @@ function simulate(
|
|||||||
TEST_SHA: sha,
|
TEST_SHA: sha,
|
||||||
SCENARIO: scenario,
|
SCENARIO: scenario,
|
||||||
DEPLOY_BRANCH: "main",
|
DEPLOY_BRANCH: "main",
|
||||||
|
// Standaard uit: de tests rimen geen cache-map aan op de host.
|
||||||
|
CMS_BUILD_CACHE_DIR: "",
|
||||||
|
...extraEnv,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (result.error) throw result.error;
|
if (result.error) throw result.error;
|
||||||
@@ -180,6 +184,24 @@ describe("deployment transaction", () => {
|
|||||||
expect(result.calls).not.toContain("pnpm db:migrate");
|
expect(result.calls).not.toContain("pnpm db:migrate");
|
||||||
expect(result.calls).not.toContain("docker run");
|
expect(result.calls).not.toContain("docker run");
|
||||||
});
|
});
|
||||||
|
// `docker builder prune` (na elke release) gooit de buildcache weg. Zonder
|
||||||
|
// duurzame export downloadt de volgende build alle packages opnieuw.
|
||||||
|
it("bouwt met een duurzame buildcache buiten de werkboom", () => {
|
||||||
|
const cacheDirectory = mkdtempSync(join(tmpdir(), "cms-deploy-cache-"));
|
||||||
|
try {
|
||||||
|
const cache = join(cacheDirectory, "cache");
|
||||||
|
const result = simulate("success", "epicnext-cms-app", false, {
|
||||||
|
CMS_BUILD_CACHE_DIR: cache,
|
||||||
|
});
|
||||||
|
expect(result.status, result.output).toBe(0);
|
||||||
|
expect(result.calls).toContain(`--cache-from type=local,src=${cache}`);
|
||||||
|
expect(result.calls).toContain(
|
||||||
|
`--cache-to type=local,dest=${cache},mode=max`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(cacheDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("legacy and CI container coexistence", () => {
|
describe("legacy and CI container coexistence", () => {
|
||||||
@@ -248,6 +270,8 @@ function simulateBlueGreen(scenario: string, livePort = 3002) {
|
|||||||
TEST_SHA: sha,
|
TEST_SHA: sha,
|
||||||
SCENARIO: scenario,
|
SCENARIO: scenario,
|
||||||
DEPLOY_BRANCH: "main",
|
DEPLOY_BRANCH: "main",
|
||||||
|
// Standaard uit: de tests rimen geen cache-map aan op de host.
|
||||||
|
CMS_BUILD_CACHE_DIR: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (result.error) throw result.error;
|
if (result.error) throw result.error;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const bash =
|
|||||||
.find(existsSync) ?? "bash")
|
.find(existsSync) ?? "bash")
|
||||||
: "bash";
|
: "bash";
|
||||||
const sha = "d".repeat(40);
|
const sha = "d".repeat(40);
|
||||||
function simulate(scenario: string) {
|
function simulate(scenario: string, extraEnv: Record<string, string> = {}) {
|
||||||
const directory = mkdtempSync(join(tmpdir(), "cms-preflight-test-"));
|
const directory = mkdtempSync(join(tmpdir(), "cms-preflight-test-"));
|
||||||
try {
|
try {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
@@ -48,6 +48,9 @@ function simulate(scenario: string) {
|
|||||||
SCENARIO: scenario,
|
SCENARIO: scenario,
|
||||||
CMS_DEPLOY_DIR: "/must-not-read-production",
|
CMS_DEPLOY_DIR: "/must-not-read-production",
|
||||||
DATABASE_URL: "must-not-use-production",
|
DATABASE_URL: "must-not-use-production",
|
||||||
|
// Standaard uit: de tests rimen geen cache-map aan op de host.
|
||||||
|
CMS_BUILD_CACHE_DIR: "",
|
||||||
|
...extraEnv,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (result.error) throw result.error;
|
if (result.error) throw result.error;
|
||||||
@@ -103,6 +106,24 @@ describe("isolated branch preflight", () => {
|
|||||||
expect(result.remaining).toEqual([]);
|
expect(result.remaining).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hergebruikt een duurzame buildcache buiten de tijdelijke werkmap", () => {
|
||||||
|
const cacheDirectory = mkdtempSync(join(tmpdir(), "cms-preflight-cache-"));
|
||||||
|
try {
|
||||||
|
const cache = join(cacheDirectory, "cache");
|
||||||
|
const result = simulate("success", { CMS_BUILD_CACHE_DIR: cache });
|
||||||
|
expect(result.status, result.output).toBe(0);
|
||||||
|
expect(result.calls).toContain(`--cache-from type=local,src=${cache}`);
|
||||||
|
expect(result.calls).toContain(
|
||||||
|
`--cache-to type=local,dest=${cache},mode=max`,
|
||||||
|
);
|
||||||
|
// De cache mag nooit in de tijdelijke werkmap van de preflight
|
||||||
|
// belanden: die wordt na de run geleegd.
|
||||||
|
expect(result.remaining).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
rmSync(cacheDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("stops after a failed build and cleans only its own attempted image", () => {
|
it("stops after a failed build and cleans only its own attempted image", () => {
|
||||||
const result = simulate("build-failure");
|
const result = simulate("build-failure");
|
||||||
expect(result.status).not.toBe(0);
|
expect(result.status).not.toBe(0);
|
||||||
|
|||||||
@@ -80,3 +80,23 @@ it("no longer publishes container images in CI", () => {
|
|||||||
expect(workflow).not.toContain("publish-container");
|
expect(workflow).not.toContain("publish-container");
|
||||||
expect(workflow).not.toContain("publish-container.sh");
|
expect(workflow).not.toContain("publish-container.sh");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps een duurzame buildcache buiten de CI-werkboom", () => {
|
||||||
|
const preflight = readFileSync("scripts/ci-preflight.sh", "utf8");
|
||||||
|
for (const script of [deploy, preflight]) {
|
||||||
|
// `docker builder prune` gooit de buildcache weg en de CI-werkboom wordt
|
||||||
|
// na elke job gewist. Zonder duurzame export downloadt de volgende build
|
||||||
|
// alle 493 packages opnieuw; gemeten: reused 0 / downloaded 493.
|
||||||
|
expect(script).toContain('--cache-from "type=local,src=$build_cache_dir"');
|
||||||
|
expect(script).toContain(
|
||||||
|
'--cache-to "type=local,dest=$build_cache_dir,mode=max"',
|
||||||
|
);
|
||||||
|
expect(script).toContain("CMS_BUILD_CACHE_DIR");
|
||||||
|
// Zonder schrijftoegang bouwen we gewoon zonder cache; dat mag de
|
||||||
|
// release niet blokkeren.
|
||||||
|
expect(script).toContain("build zonder hergebruikte cache");
|
||||||
|
}
|
||||||
|
// Eigen map voor de preflight: een branch mag de release-cache nooit voeden.
|
||||||
|
expect(deploy).toContain("/var/cache/epicnext-cms-buildcache");
|
||||||
|
expect(preflight).toContain("/var/cache/epicnext-cms-preflight-cache");
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { readFileSync } from "node:fs";
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const dockerfile = readFileSync("Dockerfile", "utf8");
|
const dockerfile = readFileSync("Dockerfile", "utf8");
|
||||||
@@ -128,6 +129,27 @@ it("keeps volatile and regenerated files out of the build context", () => {
|
|||||||
expect(ignored).toMatch(/^public\/vendor\/?$/m);
|
expect(ignored).toMatch(/^public\/vendor\/?$/m);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps de testsuites buiten de buildcontext", () => {
|
||||||
|
const ignored = readFileSync(".dockerignore", "utf8");
|
||||||
|
// `next build` typecheckt het hele project. Met e2e/ en integration/ in de
|
||||||
|
// context faalt de image-build op een typefout in testcode en sleurt hij
|
||||||
|
// @playwright/test, vitest, msw en testcontainers mee. De CI draait die
|
||||||
|
// suites op de host (pnpm test:ui / test:integration).
|
||||||
|
expect(ignored).toMatch(/^e2e\/?$/m);
|
||||||
|
expect(ignored).toMatch(/^integration\/?$/m);
|
||||||
|
// Geen applicatiecode mag naar die mappen importeren: dan zou de build in
|
||||||
|
// de image breken zodra ze uit de context verdwijnen.
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const entry of readdirSync("src", { recursive: true })) {
|
||||||
|
if (!/\.tsx?$/.test(entry)) continue;
|
||||||
|
const source = readFileSync(join("src", entry), "utf8");
|
||||||
|
if (/from\s+["'][^"']*\/(?:e2e|integration)\//.test(source)) {
|
||||||
|
offenders.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a local build cache that survives the daemon cache prune", () => {
|
it("keeps a local build cache that survives the daemon cache prune", () => {
|
||||||
const compose = readFileSync("docker-compose.yml", "utf8");
|
const compose = readFileSync("docker-compose.yml", "utf8");
|
||||||
expect(compose).toContain("cache_from:");
|
expect(compose).toContain("cache_from:");
|
||||||
|
|||||||
Reference in new issue
Block a user