fix: isolate container builds with shared resource limits [CI SKIP]

This commit is contained in:
2026-11-02 17:31:03 +09:00
parent 9f67ee5719
commit db6d8ad8b4
8 changed files with 183 additions and 33 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ steps:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
commands: commands:
- docker build -t localhost:5000/studio-admin-be:0.1.6 . - sh scripts/build-image.sh -t localhost:5000/studio-admin-be:0.1.6 .
- docker push localhost:5000/studio-admin-be:0.1.6 - docker push localhost:5000/studio-admin-be:0.1.6
when: when:
branch: release branch: release
+2
View File
@@ -7,6 +7,7 @@ RUN npm run build
FROM node:22-bookworm-slim AS runner FROM node:22-bookworm-slim AS runner
WORKDIR /app WORKDIR /app
COPY --from=docker:27.5.1-cli /usr/local/libexec/docker/cli-plugins/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx
ENV NODE_ENV=production ENV NODE_ENV=production
ARG DOCKER_VERSION=27.5.1 ARG DOCKER_VERSION=27.5.1
RUN apt-get update \ RUN apt-get update \
@@ -20,6 +21,7 @@ RUN apt-get update \
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY scripts/build-image.sh scripts/buildkitd.toml ./scripts/
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma/launcher-client ./node_modules/@prisma/launcher-client COPY --from=builder /app/node_modules/@prisma/launcher-client ./node_modules/@prisma/launcher-client
+62
View File
@@ -0,0 +1,62 @@
# Container image build limits
`scripts/build-image.sh` submits image builds to one Docker-container BuildKit
worker named `studio-limited0` (container `buildx_buildkit_studio-limited0`).
The shared worker has an aggregate 2-CPU quota, 8 GiB memory limit, no swap, and
at most two parallel build steps. Concurrent API/CI image builds share these
limits instead of each getting a separate allowance.
The wrapper explicitly selects this worker and verifies its driver, node and
Docker resource configuration before building. Missing Buildx or mismatched
limits fail the build, with no unrestricted fallback. The result is loaded into
Docker so existing push/deployment steps are unchanged. No global Docker daemon
settings or default builder selection are changed.
## Covered source paths
- Admin app component image builds (including LMS FE/BE web images).
- Launcher backend image builds.
- Woodpecker image-build steps for admin FE/BE, auth, platform gateway and manual.
The admin runtime includes Buildx and a copy of the wrapper/config. Other
Woodpecker projects mount the host directory below read-only and invoke the same
wrapper. Rollout of these source changes is required; preparing the worker alone
does not redirect existing callers.
## Server setup
Copy `scripts/build-image.sh` and `scripts/buildkitd.toml` to
`/home/future2/Studio_v2/build-tools/` before deploying the pipeline changes.
Do not remove this directory while those pipelines reference it.
The old host Buildx 0.10.5 does not support the required CPU driver options.
Use the existing Docker 27 CLI image to prepare the worker without replacing the
host CLI or restarting services:
```sh
docker run --rm --cpus=0.5 --memory=128m --memory-swap=128m \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /home/future2/Studio_v2/build-tools:/tools:ro \
docker:27-cli sh /tools/build-image.sh --prepare-only
```
BuildKit's existing server image is pinned by digest, independently of app
versions. Its host network mode supports the existing localhost:5000 registry;
this is a trusted build service, not an untrusted-code security sandbox.
No `BUILD_ARTIFACT_IMAGE` or per-app build image setting is required.
## Scope and rollout limitations
This is not a daemon-wide enforcement mechanism: arbitrary `docker build`,
unmodified GitHub Actions `setup-buildx-action`/`build-push-action` workflows, and
other builders do not automatically use this worker. Those workflows must also
be routed explicitly before claiming every image build on the server is limited.
Do not let CI cleanup remove the shared builder after each job.
Electron/npm builds outside Docker image builds are separate and unchanged.
This does not diagnose or guarantee prevention of power, hardware or kernel
failures. Runtime service limits, deployment behavior and database migrations
are not modified.
Tests: `node --test scripts/build-image.test.cjs` and `npx tsc --noEmit`.
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
set -eu
# All clients use the same worker container on the Docker host. Limits are
# aggregate, including builds submitted simultaneously by API and CI clients.
docker_bin=${DOCKER_BIN:-docker}
builder=studio-limited
node=${builder}0
container=buildx_buildkit_${node}
directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
image=moby/buildkit@sha256:28a898719c18a33f4e8000685287fa36fd0dd9560c6440227d3a732d79bb41d8
"$docker_bin" buildx version >/dev/null
if ! "$docker_bin" buildx inspect "$builder" >/dev/null 2>&1; then
config_flag=--config
if "$docker_bin" buildx create --help | grep -q -- --buildkitd-config; then
config_flag=--buildkitd-config
fi
# Another caller may have registered the builder in this client config first.
"$docker_bin" buildx create --name "$builder" --node "$node" \
--driver docker-container \
--driver-opt "image=$image,memory=8g,memory-swap=8g,cpu-period=100000,cpu-quota=200000,network=host,restart-policy=unless-stopped" \
"$config_flag" "$directory/buildkitd.toml" >/dev/null \
|| "$docker_bin" buildx inspect "$builder" >/dev/null
fi
info=$("$docker_bin" buildx inspect "$builder")
driver=$(printf '%s\n' "$info" | awk '$1 == "Driver:" {print $2}')
[ "$driver" = docker-container ] || { echo 'Refusing unrestricted build driver' >&2; exit 1; }
names=$(printf '%s\n' "$info" | awk '$1 == "Name:" {print $2}')
[ "$names" = "$(printf '%s\n%s' "$builder" "$node")" ] || {
echo 'Refusing unexpected builder nodes' >&2; exit 1
}
"$docker_bin" buildx inspect "$builder" --bootstrap >/dev/null
limits=$("$docker_bin" inspect "$container" --format '{{.HostConfig.CpuPeriod}} {{.HostConfig.CpuQuota}} {{.HostConfig.Memory}} {{.HostConfig.MemorySwap}}')
[ "$limits" = '100000 200000 8589934592 8589934592' ] || {
echo "Refusing build: unexpected worker limits: $limits" >&2
exit 1
}
echo '[limits] shared image builder: 2 CPUs, 8 GiB RAM, no swap'
if [ "${1:-}" = --prepare-only ]; then exit 0; fi
exec "$docker_bin" buildx build --builder "$builder" --load --progress plain "$@"
+64
View File
@@ -0,0 +1,64 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
function run(overrides = {}, args = ['-t', 'test:limited', '.']) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'build-limits-test-'));
try {
const fake = path.join(dir, 'docker');
fs.writeFileSync(fake, `#!${process.execPath}
const fs = require('node:fs');
const a = process.argv.slice(2);
fs.appendFileSync(process.env.CALLS, JSON.stringify(a) + '\\n');
if (a[0] === 'buildx' && a[1] === 'inspect') {
if (process.env.ABSENT && !fs.existsSync(process.env.CREATED)) process.exit(1);
console.log('Name: studio-limited\\nDriver: ' + (process.env.DRIVER || 'docker-container') + '\\nNodes:\\nName: ' + (process.env.NODE || 'studio-limited0'));
}
if (a[0] === 'buildx' && a[1] === 'create') {
if (a.includes('--help')) console.log('--buildkitd-config');
else fs.writeFileSync(process.env.CREATED, 'yes');
}
if (a[0] === 'inspect') console.log(process.env.LIMITS || '100000 200000 8589934592 8589934592');
`, { mode: 0o755 });
const result = spawnSync('/bin/sh', [path.join(__dirname, 'build-image.sh'), ...args], {
encoding: 'utf8',
env: { ...process.env, DOCKER_BIN: fake, CALLS: path.join(dir, 'calls'), CREATED: path.join(dir, 'created'), ...overrides },
});
const calls = fs.readFileSync(path.join(dir, 'calls'), 'utf8').trim().split('\n').map(JSON.parse);
return { ...result, calls };
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('uses the shared worker and loads the result for existing docker push', () => {
const result = run();
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.calls.at(-1), ['buildx', 'build', '--builder', 'studio-limited', '--load', '--progress', 'plain', '-t', 'test:limited', '.']);
});
test('creates a bounded worker without changing the default builder', () => {
const result = run({ ABSENT: '1' });
assert.equal(result.status, 0, result.stderr);
const create = result.calls.find((a) => a[1] === 'create' && !a.includes('--help'));
const options = create[create.indexOf('--driver-opt') + 1];
for (const value of ['memory=8g', 'memory-swap=8g', 'cpu-period=100000', 'cpu-quota=200000']) assert.ok(options.includes(value));
assert.ok(!create.includes('--use'));
});
for (const overrides of [{ LIMITS: '0 0 0 0' }, { DRIVER: 'docker' }, { NODE: 'other-node' }]) {
test(`refuses an unbounded or unexpected worker: ${JSON.stringify(overrides)}`, () => {
const result = run(overrides);
assert.notEqual(result.status, 0);
assert.ok(!result.calls.some((a) => a[0] === 'buildx' && a[1] === 'build'));
});
}
test('prepare-only never builds an application', () => {
const result = run({}, ['--prepare-only']);
assert.equal(result.status, 0, result.stderr);
assert.ok(!result.calls.some((a) => a[0] === 'buildx' && a[1] === 'build'));
});
+5
View File
@@ -0,0 +1,5 @@
[worker.oci]
max-parallelism = 2
[registry."localhost:5000"]
http = true
+5 -30
View File
@@ -296,6 +296,9 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
) { ) {
const image = this.resolveImage(job.image!); const image = this.resolveImage(job.image!);
const builder = this.configService.get<string>('BUILD_IMAGE_BUILDER', 'docker'); const builder = this.configService.get<string>('BUILD_IMAGE_BUILDER', 'docker');
if (builder !== 'docker') {
throw new Error('Image builds require the resource-limited Docker Buildx worker');
}
const dockerfile = this.configService.get<string>('BUILD_DOCKERFILE', 'Dockerfile'); const dockerfile = this.configService.get<string>('BUILD_DOCKERFILE', 'Dockerfile');
const contextPath = this.configService.get<string>('BUILD_CONTEXT_PATH', '.'); const contextPath = this.configService.get<string>('BUILD_CONTEXT_PATH', '.');
const contextDir = path.resolve(workDir, contextPath); const contextDir = path.resolve(workDir, contextPath);
@@ -328,38 +331,10 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
await append(`[image] rewritten to ${image}`); await append(`[image] rewritten to ${image}`);
} }
if (builder === 'nerdctl') {
const namespace = this.configService.get<string>('BUILD_NERDCTL_NAMESPACE');
const prefix = namespace ? ['--namespace', namespace] : [];
await runCommand({ await runCommand({
jobId: job.id, jobId: job.id,
command: this.configService.get<string>('NERDCTL_BIN', 'nerdctl'), command: '/bin/sh',
args: [...prefix, 'build', ...buildArgs, '-t', image, '-f', dockerfile, '.'], args: [path.resolve(__dirname, '../../scripts/build-image.sh'), ...buildArgs, '-t', image, '-f', dockerfile, '.'],
cwd: contextDir,
append,
onProcess: (child) => this.activeProcesses.set(job.id, child),
onProcessEnd: () => this.activeProcesses.delete(job.id),
});
if (pushEnabled) {
await runCommand({
jobId: job.id,
command: this.configService.get<string>('NERDCTL_BIN', 'nerdctl'),
args: [...prefix, 'push', image],
cwd: contextDir,
append,
onProcess: (child) => this.activeProcesses.set(job.id, child),
onProcessEnd: () => this.activeProcesses.delete(job.id),
});
}
return;
}
await runCommand({
jobId: job.id,
command: this.configService.get<string>('DOCKER_BIN', 'docker'),
args: ['build', ...buildArgs, '-t', image, '-f', dockerfile, '.'],
cwd: contextDir, cwd: contextDir,
append, append,
onProcess: (child) => this.activeProcesses.set(job.id, child), onProcess: (child) => this.activeProcesses.set(job.id, child),
+2 -2
View File
@@ -4,7 +4,7 @@ import { exec, execFile } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises';
import { basename } from 'node:path'; import { basename, resolve } from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
@@ -1087,7 +1087,7 @@ export class LauncherService {
data: { version: packageVersion, uploadPath: dockerfile, downloadUrl: imageTag }, data: { version: packageVersion, uploadPath: dockerfile, downloadUrl: imageTag },
}); });
await runCommand( await runCommand(
`docker build -t ${quote(imageTag)} -f ${quote(dockerfile)} .`, `/bin/sh ${quote(resolve(__dirname, '../../scripts/build-image.sh'))} -t ${quote(imageTag)} -f ${quote(dockerfile)} .`,
workDir, workDir,
append, append,
); );