fix(ci): bound Docker image build resources
This commit is contained in:
@@ -6,6 +6,9 @@ FROM ${NODE_IMAGE} AS build
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 为类型检查和打包留出原生内存余量,容器总内存由构建入口限制为 3GiB。
|
||||||
|
ENV NODE_OPTIONS=--max-old-space-size=2048
|
||||||
|
|
||||||
# Corepack 与 pnpm 依赖均通过 npmmirror 下载,避免构建时访问 npm 官方源过慢。
|
# Corepack 与 pnpm 依赖均通过 npmmirror 下载,避免构建时访问 npm 官方源过慢。
|
||||||
ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
|
ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com
|
||||||
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
|
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
|
||||||
|
|||||||
Vendored
+7
-2
@@ -3,6 +3,7 @@ pipeline {
|
|||||||
options {
|
options {
|
||||||
timestamps()
|
timestamps()
|
||||||
disableConcurrentBuilds()
|
disableConcurrentBuilds()
|
||||||
|
timeout(time: 20, unit: 'MINUTES')
|
||||||
}
|
}
|
||||||
stages {
|
stages {
|
||||||
stage('检查测试发布参数') {
|
stage('检查测试发布参数') {
|
||||||
@@ -27,8 +28,12 @@ pipeline {
|
|||||||
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
|
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
|
||||||
steps {
|
steps {
|
||||||
sh '''set -eu
|
sh '''set -eu
|
||||||
docker compose -f docker-compose.test.yml build --pull=false
|
sh scripts/build-image.sh --pull=false \
|
||||||
docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120
|
--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
|
docker compose -f docker-compose.test.yml exec -T client-web wget -qO- http://127.0.0.1/healthz
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,私有镜像认证由构建节点管理。不得把本地构建结果描述为测试环境已发布。
|
仅保留 `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 配置一致。
|
同机目标默认值:`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` 入口运行。
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -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, '')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -35,7 +35,7 @@ DOCKER_CONFIG=$(mktemp -d)
|
|||||||
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
|
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
|
||||||
trap cleanup EXIT HUP INT TERM
|
trap cleanup EXIT HUP INT TERM
|
||||||
printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry"
|
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 NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \
|
||||||
--build-arg VITE_ADMIN_WEB_ORIGIN="$PUBLIC_WEB_ORIGIN" -t "$image" .
|
--build-arg VITE_ADMIN_WEB_ORIGIN="$PUBLIC_WEB_ORIGIN" -t "$image" .
|
||||||
docker push "$image"
|
docker push "$image"
|
||||||
|
|||||||
Reference in New Issue
Block a user