From 8298454c635817b39f614cec4d64b2bba786f7f5 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 17:01:02 +0700 Subject: [PATCH] =?UTF-8?q?perf(docker):=20=D0=B1=D0=B0=D0=BD=D0=B4=D0=BB?= =?UTF-8?q?=20API,=20brotli-=D1=81=D1=82=D0=B0=D1=82=D0=B8=D0=BA=D0=B0,=20?= =?UTF-8?q?wget-healthcheck,=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D1=8B=20?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D1=83=D1=80=D1=81=D0=BE=D0=B2,=20=D0=B2?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=BE=D1=80=D0=BD=D1=8B=D0=B5=20=D1=87=D0=B0?= =?UTF-8?q?=D0=BD=D0=BA=D0=B8=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 1 + apps/api/package.json | 2 +- apps/api/test/deploy-config.test.ts | 67 +++++++++++++++++++++++++++++ apps/api/tsup.config.ts | 25 +++++++++++ apps/web/test/budget.test.ts | 35 +++++++++++++++ apps/web/vite.config.ts | 16 +++++++ deploy/docker-compose.traefik.yml | 20 ++++++--- deploy/docker/authportal/Dockerfile | 36 +++++++++++----- docker-compose.yml | 14 +++++- scripts/ci/web-budget.mjs | 48 +++++++++++++++++++++ 10 files changed, 244 insertions(+), 20 deletions(-) create mode 100644 apps/api/test/deploy-config.test.ts create mode 100644 apps/api/tsup.config.ts create mode 100644 apps/web/test/budget.test.ts create mode 100644 scripts/ci/web-budget.mjs diff --git a/.dockerignore b/.dockerignore index c3dabe3..ae3b9a7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -42,6 +42,7 @@ package-lock.json data *.exe **/*.test.ts +**/test/** **/*.spec.ts coverage .coverage diff --git a/apps/api/package.json b/apps/api/package.json index e573a2a..65fd2ab 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "tsx watch src/server.ts", - "build": "tsup src/server.ts --format esm --dts", + "build": "tsup", "lint": "eslint .", "typecheck": "tsc --noEmit", "start": "node dist/server.js", diff --git a/apps/api/test/deploy-config.test.ts b/apps/api/test/deploy-config.test.ts new file mode 100644 index 0000000..5070671 --- /dev/null +++ b/apps/api/test/deploy-config.test.ts @@ -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', + ) + }) +}) diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts new file mode 100644 index 0000000..b896c6f --- /dev/null +++ b/apps/api/tsup.config.ts @@ -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, +}) diff --git a/apps/web/test/budget.test.ts b/apps/web/test/budget.test.ts new file mode 100644 index 0000000..3f5e0e5 --- /dev/null +++ b/apps/web/test/budget.test.ts @@ -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 { + 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/) + }) +}) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index bafa262..6280de4 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -18,6 +18,22 @@ export default defineConfig(({ mode }) => ({ ? [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: { alias: { '@': path.resolve(__dirname, './src'), diff --git a/deploy/docker-compose.traefik.yml b/deploy/docker-compose.traefik.yml index ca2cdee..4750566 100644 --- a/deploy/docker-compose.traefik.yml +++ b/deploy/docker-compose.traefik.yml @@ -56,6 +56,18 @@ services: restart: unless-stopped init: true 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: - traefik # No host ports — only Traefik publishes 80/443. @@ -93,13 +105,7 @@ services: - traefik.http.middlewares.auth-portal-compress.compress=true - traefik.http.routers.auth-portal.middlewares=auth-portal-compress 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 timeout: 5s retries: 3 diff --git a/deploy/docker/authportal/Dockerfile b/deploy/docker/authportal/Dockerfile index 210d889..ea26725 100644 --- a/deploy/docker/authportal/Dockerfile +++ b/deploy/docker/authportal/Dockerfile @@ -22,27 +22,40 @@ COPY apps/api apps/api COPY packages/ui packages/ui COPY packages/shared packages/shared COPY packages/db packages/db +# API собирается tsup в один файл (все зависимости внутри, кроме нативных). RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ - pnpm turbo build --filter=web --filter=@authportal/api \ - && pnpm --filter @authportal/api deploy --prod /out \ + pnpm turbo build --filter=web --filter=@authportal/api +# Рантайм-слой: бандл + статика (только brotli) + нативные модули с теми же версиями, что в lockfile. +RUN mkdir -p /out/dist \ + && cp apps/api/dist/server.js /out/dist/server.js \ && cp -r apps/web/dist /out/static \ - && find /out/static -type f \( -name '*.js' -o -name '*.css' \) -exec sh -c \ - 'gzip -k9 "$1" && brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \ - && rm -rf /out/src /out/test /out/.turbo \ - && rm -rf /out/node_modules/@authportal/db/src /out/node_modules/@authportal/db/scripts /out/node_modules/@authportal/db/.turbo \ - && rm -rf /out/node_modules/@authportal/shared/src /out/node_modules/@authportal/shared/.turbo \ - && 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 + && find /out/static -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' \) \ + -exec sh -c 'brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \ + && 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')}}))" +RUN --mount=type=cache,target=/root/.npm,sharing=locked \ + cd /out && npm install --omit=dev --no-audit --no-fund \ + && 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 WORKDIR /app # Не-root runtime; /data — bind mount с хоста, на сервере нужен # `chown -R 1000:1000 ./data` (см. deploy/docker-compose.traefik.yml). -RUN apk add --no-cache ca-certificates \ - && mkdir -p /data && chown node:node /data +RUN mkdir -p /data && chown node:node /data ARG VERSION=dev ARG GIT_SHA=unknown 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 \ + NODE_OPTIONS=--max-old-space-size=160 \ + UV_THREADPOOL_SIZE=4 \ STATIC_DIR=/app/static \ DATABASE_URL=sqlite:/data/app.db \ SERVER_PORT=8080 \ @@ -54,6 +67,7 @@ USER node EXPOSE 8080 STOPSIGNAL SIGTERM VOLUME ["/data"] +# busybox wget вместо запуска второго процесса node каждые 30 с. 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"] diff --git a/docker-compose.yml b/docker-compose.yml index 7698e0b..2da4970 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,8 +25,20 @@ services: restart: unless-stopped init: true 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: - 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 timeout: 5s retries: 3 diff --git a/scripts/ci/web-budget.mjs b/scripts/ci/web-budget.mjs new file mode 100644 index 0000000..3f83214 --- /dev/null +++ b/scripts/ci/web-budget.mjs @@ -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) + } +}