diff --git a/Dockerfile b/Dockerfile index 77cd790..b89f18e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ FROM ${NODE_IMAGE} AS build WORKDIR /app +# 为类型检查和打包留出原生内存余量,容器总内存由构建入口限制为 3GiB。 +ENV NODE_OPTIONS=--max-old-space-size=2048 + # Corepack 与 pnpm 依赖均通过 npmmirror 下载,避免构建时访问 npm 官方源过慢。 ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com diff --git a/Jenkinsfile b/Jenkinsfile index 19da01a..0c9fed6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,6 +3,7 @@ pipeline { options { timestamps() disableConcurrentBuilds() + timeout(time: 20, unit: 'MINUTES') } stages { stage('检查测试发布参数') { @@ -27,8 +28,12 @@ pipeline { when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { sh '''set -eu - docker compose -f docker-compose.test.yml build --pull=false - docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120 + sh scripts/build-image.sh --pull=false \ + --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ + --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ + --build-arg VITE_ADMIN_WEB_ORIGIN="$VITE_ADMIN_WEB_ORIGIN" \ + -t "client-web-test:$IMAGE_TAG" . + docker compose -f docker-compose.test.yml up -d --no-build --pull never --wait --wait-timeout 120 docker compose -f docker-compose.test.yml exec -T client-web wget -qO- http://127.0.0.1/healthz ''' } diff --git a/README.md b/README.md index 36f3f95..9a54941 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,5 @@ pnpm test 仅保留 `docker-compose.test.yml` 与 test Jenkins Job。发布前注入 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`VITE_ADMIN_WEB_ORIGIN`(管理后台 origin,构建时用于安全代登录)与 `IMAGE_TAG`(Jenkins 从当前提交生成)。`HOST_PORT` 默认 9002 且仅绑定 loopback;宿主机反向代理负责外部入口。可选 `NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE`,私有镜像认证由构建节点管理。不得把本地构建结果描述为测试环境已发布。 同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。 + +Jenkins 的测试及灰度镜像构建均通过 `scripts/build-image.sh`,Compose 只部署已构建镜像。构建预算及节点要求见[部署指南](../../docs/测试环境Jenkins部署指南.md)。离线回归为 `node --test scripts/build-resource-contract.test.mjs`,本机仍经统一 `local-check.ps1` 入口运行。 diff --git a/scripts/build-image.sh b/scripts/build-image.sh new file mode 100644 index 0000000..a617456 --- /dev/null +++ b/scripts/build-image.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +# 构建前至少保留 1GiB 给业务服务,避免在宿主机内存不足时继续启动构建。 +available_kib=$(awk '$1 == "MemAvailable:" { print $2 }' /proc/meminfo) +case "$available_kib" in + ''|*[!0-9]*) echo 'Cannot read host available memory; build refused.' >&2; exit 69 ;; +esac +if [ "$available_kib" -lt 4194304 ]; then + echo 'Docker build requires at least 4GiB available host memory; retry after resources recover.' >&2 + exit 75 +fi + +# 当前节点使用 Docker 26 的经典构建器。显式固定后端,确保资源参数实际生效。 +# 限制覆盖每个 RUN 容器,失败时也清理中间容器,超时由宿主机独立终止。 +exec env DOCKER_BUILDKIT=0 timeout --signal=TERM --kill-after=30s 15m \ + docker build --memory=3g --memory-swap=3g \ + --cpu-period=100000 --cpu-quota=200000 --force-rm "$@" diff --git a/scripts/build-resource-contract.test.mjs b/scripts/build-resource-contract.test.mjs new file mode 100644 index 0000000..12ec275 --- /dev/null +++ b/scripts/build-resource-contract.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const wrapper = join(root, 'scripts/build-image.sh') +const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash' +const shellAvailable = process.platform !== 'win32' || existsSync(bash) + +// 使用替身验证参数、内存预检和退出码,不启动 Docker 或访问部署环境。 +function runBuild(availableMemory, childStatus = '0') { + const directory = mkdtempSync(join(tmpdir(), 'frontend-build-resource-')) + try { + writeFileSync(join(directory, 'awk'), '#!/bin/sh\nprintf "%s\\n" "$TEST_AVAILABLE_MEMORY"\n', { mode: 0o755 }) + writeFileSync(join(directory, 'timeout'), '#!/bin/sh\nprintf "%s\\n" watchdog "$@"\nshift 3\nexec "$@"\n', { mode: 0o755 }) + writeFileSync(join(directory, 'docker'), '#!/bin/sh\nprintf "%s\\n" "builder=$DOCKER_BUILDKIT" "$@"\nexit "$TEST_CHILD_STATUS"\n', { mode: 0o755 }) + return spawnSync(bash, ['-c', + 'testbin=$(cd "$1" && pwd); shift; PATH="$testbin:$PATH"; export PATH; exec sh "$@"', + 'fixture', directory.replaceAll('\\', '/'), wrapper.replaceAll('\\', '/'), + '--build-arg', 'VALUE=with space', '-t', 'web:test', '.'], { + encoding: 'utf8', timeout: 10000, + env: { ...process.env, DOCKER_BUILDKIT: '1', TEST_AVAILABLE_MEMORY: availableMemory, TEST_CHILD_STATUS: childStatus } + }) + } finally { + assert.equal(dirname(resolve(directory)), resolve(tmpdir())) + rmSync(directory, { recursive: true }) + } +} + +test('TEST and gray builds share the resource gate and deployment cannot rebuild implicitly', () => { + const pipeline = readFileSync(join(root, 'Jenkinsfile'), 'utf8') + const production = readFileSync(join(root, 'scripts/deploy-production.sh'), 'utf8') + const dockerfile = readFileSync(join(root, 'Dockerfile'), 'utf8') + for (const source of [pipeline, production]) { + assert.match(source, /sh scripts\/build-image\.sh/) + assert.doesNotMatch(source, /docker (?:compose[^\n]* build|build\s)/) + } + assert.match(pipeline, /timeout\(time: 20, unit: 'MINUTES'\)/) + assert.match(pipeline, /up -d --no-build --pull never --wait/) + assert.ok(pipeline.includes(`-t "${basename(root)}-test:$IMAGE_TAG"`)) + assert.match(dockerfile, /^ENV NODE_OPTIONS=--max-old-space-size=2048$/m) +}) + +test('build forwards quoted arguments, enforces container budgets and propagates failure', { skip: !shellAvailable }, () => { + const result = runBuild('4194304', '23') + assert.equal(result.status, 23, result.stderr) + assert.match(result.stdout, /watchdog\r?\n--signal=TERM\r?\n--kill-after=30s\r?\n15m\r?\ndocker\r?\nbuild/) + assert.match(result.stdout, /builder=0\r?\nbuild/) + for (const argument of ['--memory=3g', '--memory-swap=3g', '--cpu-period=100000', '--cpu-quota=200000', '--force-rm']) { + assert.ok(result.stdout.split(/\r?\n/).includes(argument), argument) + } + assert.match(result.stdout, /--build-arg\r?\nVALUE=with space\r?\n-t\r?\nweb:test\r?\n\./) + assert.equal(runBuild('8388608').status, 0) + assert.equal(runBuild('8388608', '124').status, 124) +}) + +test('low or unreadable host memory refuses to start Docker', { skip: !shellAvailable }, () => { + for (const [memory, status] of [['4194303', 75], ['', 69], ['unknown', 69]]) { + const result = runBuild(memory) + assert.equal(result.status, status, result.stderr) + assert.equal(result.stdout, '') + } +}) diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index ebcb234..a59a72a 100644 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -35,7 +35,7 @@ DOCKER_CONFIG=$(mktemp -d) cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } trap cleanup EXIT HUP INT TERM printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry" -docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ +sh scripts/build-image.sh --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ --build-arg VITE_ADMIN_WEB_ORIGIN="$PUBLIC_WEB_ORIGIN" -t "$image" . docker push "$image"