perf(docker): бандл API, brotli-статика, wget-healthcheck, лимиты ресурсов, вендорные чанки web
This commit is contained in:
1 parent
1d7ea35a23
commit
8298454c63
10 files changed
+244
-20
No files matched your search
@@ -42,6 +42,7 @@ package-lock.json
|
|||||||
data
|
data
|
||||||
*.exe
|
*.exe
|
||||||
**/*.test.ts
|
**/*.test.ts
|
||||||
|
**/test/**
|
||||||
**/*.spec.ts
|
**/*.spec.ts
|
||||||
coverage
|
coverage
|
||||||
.coverage
|
.coverage
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"build": "tsup src/server.ts --format esm --dts",
|
"build": "tsup",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '../../..')
|
||||||
|
const read = (p: string) => readFileSync(resolve(root, p), 'utf-8')
|
||||||
|
|
||||||
|
const dockerfile = read('deploy/docker/authportal/Dockerfile')
|
||||||
|
const composes = {
|
||||||
|
'docker-compose.yml': read('docker-compose.yml'),
|
||||||
|
'deploy/docker-compose.traefik.yml': read('deploy/docker-compose.traefik.yml'),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Dockerfile', () => {
|
||||||
|
it('graceful stop и ограничение ресурсов Node', () => {
|
||||||
|
expect(dockerfile).toMatch(/^STOPSIGNAL SIGTERM$/m)
|
||||||
|
expect(dockerfile).toMatch(/NODE_OPTIONS=--max-old-space-size=\d+/)
|
||||||
|
expect(dockerfile).toMatch(/UV_THREADPOOL_SIZE=4/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('healthcheck без запуска node', () => {
|
||||||
|
const hc = dockerfile.split('HEALTHCHECK')[1]!.split('\n').slice(0, 2).join(' ')
|
||||||
|
expect(hc).toContain('wget')
|
||||||
|
expect(hc).not.toContain('"node"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('запуск не от root, рантайм без тулчейна и pnpm deploy', () => {
|
||||||
|
expect(dockerfile).toMatch(/^USER node$/m)
|
||||||
|
const runtime = dockerfile.split('AS runtime')[1]!
|
||||||
|
expect(runtime).not.toMatch(/apk add/)
|
||||||
|
expect(dockerfile).not.toMatch(/pnpm .*deploy/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('статика сжимается только brotli', () => {
|
||||||
|
expect(dockerfile).toContain('brotli')
|
||||||
|
expect(dockerfile).not.toMatch(/gzip -k/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('нативные модули проверяются при сборке', () => {
|
||||||
|
expect(dockerfile).toContain('native modules ok')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.each(Object.entries(composes))('%s', (_name, text) => {
|
||||||
|
it('init, grace period и лимиты ресурсов', () => {
|
||||||
|
expect(text).toMatch(/init: true/)
|
||||||
|
expect(text).toMatch(/stop_grace_period: 15s/)
|
||||||
|
expect(text).toMatch(/mem_limit: 256m/)
|
||||||
|
expect(text).toMatch(/pids_limit: \d+/)
|
||||||
|
expect(text).toMatch(/read_only: true/)
|
||||||
|
expect(text).toMatch(/cap_drop:\s*\n\s*- ALL/)
|
||||||
|
expect(text).toMatch(/no-new-privileges:true/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('healthcheck через wget', () => {
|
||||||
|
expect(text).toMatch(/"wget", "-q"/)
|
||||||
|
expect(text).not.toMatch(/fetch\('http:\/\/127\.0\.0\.1:8080\/health'\)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('traefik', () => {
|
||||||
|
it('включено сжатие ответов', () => {
|
||||||
|
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
|
||||||
|
'middlewares.auth-portal-compress.compress=true',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'tsup'
|
||||||
|
|
||||||
|
// Нативные модули остаются внешними (ставятся в образ отдельно),
|
||||||
|
// всё остальное (fastify, jose, drizzle, workspace-пакеты) бандлится в один файл.
|
||||||
|
const NATIVE = ['better-sqlite3', '@node-rs/argon2']
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/server.ts'],
|
||||||
|
format: ['esm'],
|
||||||
|
target: 'node22',
|
||||||
|
external: NATIVE,
|
||||||
|
noExternal: [/^(?!better-sqlite3$|@node-rs\/argon2).+/],
|
||||||
|
// CJS-зависимости внутри ESM-бандла используют require/__dirname.
|
||||||
|
banner: {
|
||||||
|
js: [
|
||||||
|
"import { createRequire as __cr } from 'node:module';",
|
||||||
|
"import { fileURLToPath as __fu } from 'node:url';",
|
||||||
|
"import { dirname as __dn } from 'node:path';",
|
||||||
|
'const require = __cr(import.meta.url);',
|
||||||
|
'const __filename = __fu(import.meta.url);',
|
||||||
|
'const __dirname = __dn(__filename);',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
clean: true,
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
// @ts-expect-error — .mjs без типов
|
||||||
|
import { checkBudget } from '../../../scripts/ci/web-budget.mjs'
|
||||||
|
|
||||||
|
function dist(files: Record<string, string>): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'web-budget-'))
|
||||||
|
for (const [name, body] of Object.entries(files)) {
|
||||||
|
writeFileSync(join(dir, name), body)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('web-budget', () => {
|
||||||
|
it('в бюджете → без ошибок', () => {
|
||||||
|
const dir = dist({ 'index-abc.js': 'a'.repeat(100), 'x-1.js': 'b'.repeat(100) })
|
||||||
|
const { errors, sizes } = checkBudget(dir, { entryGz: 1000, totalGz: 2000 })
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
expect(sizes.entryGz).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('превышение entry и total → ошибки', () => {
|
||||||
|
const noise = Array.from({ length: 5000 }, (_, i) => `v${i}=${Math.random()}`).join(';')
|
||||||
|
const dir = dist({ 'index-abc.js': noise })
|
||||||
|
const { errors } = checkBudget(dir, { entryGz: 10, totalGz: 10 })
|
||||||
|
expect(errors).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('нет entry-чанка → ошибка', () => {
|
||||||
|
const dir = dist({ 'other.js': 'x' })
|
||||||
|
expect(checkBudget(dir).errors[0]).toMatch(/entry chunk/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -18,6 +18,22 @@ export default defineConfig(({ mode }) => ({
|
|||||||
? [visualizer({ filename: 'dist/stats.html', gzipSize: true })]
|
? [visualizer({ filename: 'dist/stats.html', gzipSize: true })]
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
|
build: {
|
||||||
|
// Вендорные чанки меняются реже кода приложения → лучше кэш браузера между релизами.
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
advancedChunks: {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
name: 'vendor-react',
|
||||||
|
test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/,
|
||||||
|
},
|
||||||
|
{ name: 'vendor-tanstack', test: /node_modules[\\/]@tanstack[\\/]/ },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
stop_grace_period: 15s
|
stop_grace_period: 15s
|
||||||
|
# Лимиты ресурсов: heap Node (NODE_OPTIONS в образе) рассчитан на 256 МиБ.
|
||||||
|
mem_limit: 256m
|
||||||
|
memswap_limit: 256m
|
||||||
|
cpus: "1.0"
|
||||||
|
pids_limit: 128
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
depends_on:
|
depends_on:
|
||||||
- traefik
|
- traefik
|
||||||
# No host ports — only Traefik publishes 80/443.
|
# No host ports — only Traefik publishes 80/443.
|
||||||
@@ -93,13 +105,7 @@ services:
|
|||||||
- traefik.http.middlewares.auth-portal-compress.compress=true
|
- traefik.http.middlewares.auth-portal-compress.compress=true
|
||||||
- traefik.http.routers.auth-portal.middlewares=auth-portal-compress
|
- traefik.http.routers.auth-portal.middlewares=auth-portal-compress
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"]
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"node",
|
|
||||||
"-e",
|
|
||||||
"fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
|
||||||
]
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -22,27 +22,40 @@ COPY apps/api apps/api
|
|||||||
COPY packages/ui packages/ui
|
COPY packages/ui packages/ui
|
||||||
COPY packages/shared packages/shared
|
COPY packages/shared packages/shared
|
||||||
COPY packages/db packages/db
|
COPY packages/db packages/db
|
||||||
|
# API собирается tsup в один файл (все зависимости внутри, кроме нативных).
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||||
pnpm turbo build --filter=web --filter=@authportal/api \
|
pnpm turbo build --filter=web --filter=@authportal/api
|
||||||
&& pnpm --filter @authportal/api deploy --prod /out \
|
# Рантайм-слой: бандл + статика (только brotli) + нативные модули с теми же версиями, что в lockfile.
|
||||||
|
RUN mkdir -p /out/dist \
|
||||||
|
&& cp apps/api/dist/server.js /out/dist/server.js \
|
||||||
&& cp -r apps/web/dist /out/static \
|
&& cp -r apps/web/dist /out/static \
|
||||||
&& find /out/static -type f \( -name '*.js' -o -name '*.css' \) -exec sh -c \
|
&& find /out/static -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' \) \
|
||||||
'gzip -k9 "$1" && brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \
|
-exec sh -c 'brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \
|
||||||
&& rm -rf /out/src /out/test /out/.turbo \
|
&& node -e "const v=(p)=>require(p).version;require('fs').writeFileSync('/out/package.json',JSON.stringify({name:'authportal-runtime',private:true,type:'module',dependencies:{'better-sqlite3':v('./packages/db/node_modules/better-sqlite3/package.json'),'@node-rs/argon2':v('./apps/api/node_modules/@node-rs/argon2/package.json')}}))"
|
||||||
&& rm -rf /out/node_modules/@authportal/db/src /out/node_modules/@authportal/db/scripts /out/node_modules/@authportal/db/.turbo \
|
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
|
||||||
&& rm -rf /out/node_modules/@authportal/shared/src /out/node_modules/@authportal/shared/.turbo \
|
cd /out && npm install --omit=dev --no-audit --no-fund \
|
||||||
&& find /out/dist /out/node_modules/@authportal -type f \( -name '*.d.ts' -o -name '*.map' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete
|
&& rm -rf node_modules/better-sqlite3/deps node_modules/better-sqlite3/src \
|
||||||
|
node_modules/better-sqlite3/build/Release/obj node_modules/better-sqlite3/build/Release/.deps \
|
||||||
|
node_modules/better-sqlite3/build/deps \
|
||||||
|
&& node -e "const D=require('better-sqlite3');new D(':memory:').prepare('select 1').get();import('@node-rs/argon2').then(m=>m.hash('x')).then(()=>console.log('native modules ok'))"
|
||||||
|
|
||||||
FROM ${BASE_NODE} AS runtime
|
FROM ${BASE_NODE} AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Не-root runtime; /data — bind mount с хоста, на сервере нужен
|
# Не-root runtime; /data — bind mount с хоста, на сервере нужен
|
||||||
# `chown -R 1000:1000 ./data` (см. deploy/docker-compose.traefik.yml).
|
# `chown -R 1000:1000 ./data` (см. deploy/docker-compose.traefik.yml).
|
||||||
RUN apk add --no-cache ca-certificates \
|
RUN mkdir -p /data && chown node:node /data
|
||||||
&& mkdir -p /data && chown node:node /data
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG GIT_SHA=unknown
|
ARG GIT_SHA=unknown
|
||||||
ARG BUILD_TIME=
|
ARG BUILD_TIME=
|
||||||
|
LABEL org.opencontainers.image.title="auth-portal" \
|
||||||
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
|
org.opencontainers.image.revision="${GIT_SHA}" \
|
||||||
|
org.opencontainers.image.created="${BUILD_TIME}"
|
||||||
|
# Куча V8 ограничена под контейнер 256 МиБ (RSS = куча + нативная память + argon2);
|
||||||
|
# при другом mem_limit переопределите NODE_OPTIONS.
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
NODE_OPTIONS=--max-old-space-size=160 \
|
||||||
|
UV_THREADPOOL_SIZE=4 \
|
||||||
STATIC_DIR=/app/static \
|
STATIC_DIR=/app/static \
|
||||||
DATABASE_URL=sqlite:/data/app.db \
|
DATABASE_URL=sqlite:/data/app.db \
|
||||||
SERVER_PORT=8080 \
|
SERVER_PORT=8080 \
|
||||||
@@ -54,6 +67,7 @@ USER node
|
|||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
STOPSIGNAL SIGTERM
|
STOPSIGNAL SIGTERM
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
# busybox wget вместо запуска второго процесса node каждые 30 с.
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"]
|
||||||
CMD ["node", "dist/server.js"]
|
CMD ["node", "dist/server.js"]
|
||||||
+13
-1
@@ -25,8 +25,20 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
stop_grace_period: 15s
|
stop_grace_period: 15s
|
||||||
|
# Лимиты ресурсов: heap Node (NODE_OPTIONS в образе) рассчитан на 256 МиБ.
|
||||||
|
mem_limit: 256m
|
||||||
|
memswap_limit: 256m
|
||||||
|
cpus: "1.0"
|
||||||
|
pids_limit: 128
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Бюджет размера web-бандла: node scripts/ci/web-budget.mjs [dist/assets]
|
||||||
|
// Лимиты — gzip-байты; переопределяются env WEB_BUDGET_ENTRY_GZ / WEB_BUDGET_TOTAL_GZ.
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { gzipSync } from 'node:zlib'
|
||||||
|
|
||||||
|
export const DEFAULT_LIMITS = {
|
||||||
|
entryGz: 25 * 1024,
|
||||||
|
totalGz: 560 * 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measure(dir) {
|
||||||
|
let total = 0
|
||||||
|
let entry = 0
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
if (!name.endsWith('.js')) continue
|
||||||
|
const gz = gzipSync(readFileSync(join(dir, name))).length
|
||||||
|
total += gz
|
||||||
|
if (/^index-.*\.js$/.test(name)) entry = Math.max(entry, gz)
|
||||||
|
}
|
||||||
|
return { entryGz: entry, totalGz: total }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkBudget(dir, limits = DEFAULT_LIMITS) {
|
||||||
|
const sizes = measure(dir)
|
||||||
|
const errors = []
|
||||||
|
if (sizes.entryGz === 0) errors.push('entry chunk index-*.js не найден')
|
||||||
|
if (sizes.entryGz > limits.entryGz)
|
||||||
|
errors.push(`entry gzip ${sizes.entryGz} > ${limits.entryGz}`)
|
||||||
|
if (sizes.totalGz > limits.totalGz)
|
||||||
|
errors.push(`total gzip ${sizes.totalGz} > ${limits.totalGz}`)
|
||||||
|
return { sizes, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
|
const dir = process.argv[2] ?? 'apps/web/dist/assets'
|
||||||
|
const limits = {
|
||||||
|
entryGz: Number(process.env.WEB_BUDGET_ENTRY_GZ ?? DEFAULT_LIMITS.entryGz),
|
||||||
|
totalGz: Number(process.env.WEB_BUDGET_TOTAL_GZ ?? DEFAULT_LIMITS.totalGz),
|
||||||
|
}
|
||||||
|
const { sizes, errors } = checkBudget(dir, limits)
|
||||||
|
console.log('web bundle (gzip):', sizes, 'limits:', limits)
|
||||||
|
if (errors.length) {
|
||||||
|
console.error('BUDGET EXCEEDED:\n - ' + errors.join('\n - '))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user