feat: Optimize Docker build process by adding script to remove unnecessary files (#24450)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Wu Tianwei
2025-08-25 14:44:29 +08:00
committed by GitHub
parent a4d17cb585
commit bcf42362e3
5 changed files with 193 additions and 2 deletions

View File

@@ -34,7 +34,7 @@ COPY --from=packages /app/web/ .
COPY . .
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN pnpm build
RUN pnpm build:docker
# production stage

View File

@@ -27,7 +27,10 @@ const nextConfig = {
basePath,
assetPrefix,
webpack: (config, { dev, isServer }) => {
config.plugins.push(codeInspectorPlugin({ bundler: 'webpack' }))
if (dev) {
config.plugins.push(codeInspectorPlugin({ bundler: 'webpack' }))
}
return config
},
productionBrowserSourceMaps: false, // enable browser source map generation during the production build

View File

@@ -21,6 +21,7 @@
"scripts": {
"dev": "cross-env NODE_OPTIONS='--inspect' next dev",
"build": "next build",
"build:docker": "next build && node scripts/optimize-standalone.js",
"start": "cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public && cross-env PORT=$npm_config_port HOSTNAME=$npm_config_host node .next/standalone/server.js",
"lint": "pnpx oxlint && pnpm eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache",
"lint-only-show-error": "pnpx oxlint && pnpm eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache --quiet",

38
web/scripts/README.md Normal file
View File

@@ -0,0 +1,38 @@
# Production Build Optimization Scripts
## optimize-standalone.js
This script removes unnecessary development dependencies from the Next.js standalone build output to reduce the production Docker image size.
### What it does
The script specifically targets and removes `jest-worker` packages that are bundled with Next.js but not needed in production. These packages are included because:
1. Next.js includes jest-worker in its compiled dependencies
1. terser-webpack-plugin (used by Next.js for minification) depends on jest-worker
1. pnpm's dependency resolution creates symlinks to jest-worker in various locations
### Usage
The script is automatically run during Docker builds via the `build:docker` npm script:
```bash
# Docker build (removes jest-worker after build)
pnpm build:docker
```
To run the optimization manually:
```bash
node scripts/optimize-standalone.js
```
### What gets removed
- `node_modules/.pnpm/next@*/node_modules/next/dist/compiled/jest-worker`
- `node_modules/.pnpm/terser-webpack-plugin@*/node_modules/jest-worker` (symlinks)
- `node_modules/.pnpm/jest-worker@*` (actual packages)
### Impact
Removing jest-worker saves approximately 36KB per instance from the production image. While this may seem small, it helps ensure production images only contain necessary runtime dependencies.

View File

@@ -0,0 +1,149 @@
/**
* Script to optimize Next.js standalone output for production
* Removes unnecessary files like jest-worker that are bundled with Next.js
*/
const fs = require('fs');
const path = require('path');
console.log('🔧 Optimizing standalone output...');
const standaloneDir = path.join(__dirname, '..', '.next', 'standalone');
// Check if standalone directory exists
if (!fs.existsSync(standaloneDir)) {
console.error('❌ Standalone directory not found. Please run "next build" first.');
process.exit(1);
}
// List of paths to remove (relative to standalone directory)
const pathsToRemove = [
// Remove jest-worker from Next.js compiled dependencies
'node_modules/.pnpm/next@*/node_modules/next/dist/compiled/jest-worker',
// Remove jest-worker symlinks from terser-webpack-plugin
'node_modules/.pnpm/terser-webpack-plugin@*/node_modules/jest-worker',
// Remove actual jest-worker packages (directories only, not symlinks)
'node_modules/.pnpm/jest-worker@*',
];
// Function to safely remove a path
function removePath(basePath, relativePath) {
const fullPath = path.join(basePath, relativePath);
// Handle wildcard patterns
if (relativePath.includes('*')) {
const parts = relativePath.split('/');
let currentPath = basePath;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.includes('*')) {
// Find matching directories
if (fs.existsSync(currentPath)) {
const entries = fs.readdirSync(currentPath);
// replace '*' with '.*'
const regexPattern = part.replace(/\*/g, '.*');
const regex = new RegExp(`^${regexPattern}$`);
for (const entry of entries) {
if (regex.test(entry)) {
const remainingPath = parts.slice(i + 1).join('/');
const matchedPath = path.join(currentPath, entry, remainingPath);
try {
// Use lstatSync to check if path exists (works for both files and symlinks)
const stats = fs.lstatSync(matchedPath);
if (stats.isSymbolicLink()) {
// Remove symlink
fs.unlinkSync(matchedPath);
console.log(`✅ Removed symlink: ${path.relative(basePath, matchedPath)}`);
} else {
// Remove directory/file
fs.rmSync(matchedPath, { recursive: true, force: true });
console.log(`✅ Removed: ${path.relative(basePath, matchedPath)}`);
}
} catch (error) {
// Silently ignore ENOENT (path not found) errors
if (error.code !== 'ENOENT') {
console.error(`❌ Failed to remove ${matchedPath}: ${error.message}`);
}
}
}
}
}
return;
} else {
currentPath = path.join(currentPath, part);
}
}
} else {
// Direct path removal
if (fs.existsSync(fullPath)) {
try {
fs.rmSync(fullPath, { recursive: true, force: true });
console.log(`✅ Removed: ${relativePath}`);
} catch (error) {
console.error(`❌ Failed to remove ${fullPath}: ${error.message}`);
}
}
}
}
// Remove unnecessary paths
console.log('🗑️ Removing unnecessary files...');
for (const pathToRemove of pathsToRemove) {
removePath(standaloneDir, pathToRemove);
}
// Calculate size reduction
console.log('\n📊 Optimization complete!');
// Optional: Display the size of remaining jest-related files (if any)
const checkForJest = (dir) => {
const jestFiles = [];
function walk(currentPath) {
if (!fs.existsSync(currentPath)) return;
try {
const entries = fs.readdirSync(currentPath);
for (const entry of entries) {
const fullPath = path.join(currentPath, entry);
try {
const stat = fs.lstatSync(fullPath); // Use lstatSync to handle symlinks
if (stat.isDirectory() && !stat.isSymbolicLink()) {
// Skip node_modules subdirectories to avoid deep traversal
if (entry === 'node_modules' && currentPath !== standaloneDir) {
continue;
}
walk(fullPath);
} else if (stat.isFile() && entry.includes('jest')) {
jestFiles.push(path.relative(standaloneDir, fullPath));
}
} catch (err) {
// Skip files that can't be accessed
continue;
}
}
} catch (err) {
// Skip directories that can't be read
return;
}
}
walk(dir);
return jestFiles;
};
const remainingJestFiles = checkForJest(standaloneDir);
if (remainingJestFiles.length > 0) {
console.log('\n⚠ Warning: Some jest-related files still remain:');
remainingJestFiles.forEach(file => console.log(` - ${file}`));
} else {
console.log('\n✨ No jest-related files found in standalone output!');
}