Files
AFFiNE-Mirror/packages/backend/server/scripts/docker-clean.mjs
T
DarkSky c1c19be271 feat(server): cleanup image (#15145)
#### PR Dependency Tree


* **PR #15145** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Release Notes

* **Chores**
* Improved Docker build cleanup by deduplicating identical static files
using content hashing and hardlinks to reduce package size.
* Expanded pruning of unnecessary runtime and build artifacts (including
Prisma-related files) and broader removal of disposable `node_modules`
contents.
* Updated cleanup flow to focus on deduplication and targeted artifact
removal for faster, leaner deployments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-23 13:07:27 +08:00

771 lines
18 KiB
JavaScript

import { spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_APP_ROOT = path.resolve(SCRIPT_DIR, '..');
const APP_ROOT = process.env.APP_ROOT ?? DEFAULT_APP_ROOT;
const TARGETARCH = process.env.TARGETARCH ?? '';
const TARGETVARIANT = process.env.TARGETVARIANT ?? '';
const ALLOW_RUN = process.env.AFFINE_DOCKER_CLEAN === '1';
const VERBOSE = process.env.AFFINE_DOCKER_CLEAN_VERBOSE === '1';
function log(message) {
console.log(`[docker-clean] ${message}`);
}
function debug(message) {
if (VERBOSE) {
log(message);
}
}
async function exists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function safeReadDir(dirPath) {
try {
return await fs.readdir(dirPath);
} catch {
return [];
}
}
async function rmrf(targetPath) {
await fs.rm(targetPath, { recursive: true, force: true });
}
async function fileSize(filePath) {
const stat = await fs.lstat(filePath).catch(() => null);
return stat?.isFile() ? stat.size : 0;
}
async function walkFiles(rootDir) {
if (!(await exists(rootDir))) {
return [];
}
const files = [];
const stack = [rootDir];
while (stack.length) {
const current = stack.pop();
let entries;
try {
entries = await fs.readdir(current, { withFileTypes: true });
} catch (err) {
debug(`skip unreadable dir ${current}: ${err?.message ?? String(err)}`);
continue;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const dirent of entries) {
const fullPath = path.join(current, dirent.name);
if (dirent.isDirectory()) {
stack.push(fullPath);
} else if (dirent.isFile()) {
files.push(fullPath);
}
}
}
files.sort();
return files;
}
async function sha256(filePath) {
const hash = crypto.createHash('sha256');
const handle = await fs.open(filePath, 'r');
try {
for await (const chunk of handle.readableWebStream()) {
hash.update(Buffer.from(chunk));
}
} finally {
await handle.close().catch(() => {});
}
return hash.digest('hex');
}
async function hardlinkDuplicate(canonicalPath, duplicatePath) {
const tempPath = path.join(
path.dirname(duplicatePath),
`.docker-clean-link-${process.pid}-${Date.now()}-${path.basename(
duplicatePath
)}`
);
try {
await fs.link(canonicalPath, tempPath);
await fs.rename(tempPath, duplicatePath);
return true;
} catch (err) {
await fs.rm(tempPath, { force: true }).catch(() => {});
debug(
`failed to hardlink ${duplicatePath} -> ${canonicalPath}: ${
err?.message ?? String(err)
}`
);
return false;
}
}
function hasCompatibleHardlinkMetadata(canonicalStat, duplicateStat) {
return (
canonicalStat.mode === duplicateStat.mode &&
canonicalStat.uid === duplicateStat.uid &&
canonicalStat.gid === duplicateStat.gid
);
}
async function hardlinkDuplicateFiles(rootDir) {
const files = await walkFiles(rootDir);
const bySize = new Map();
for (const filePath of files) {
const size = await fileSize(filePath);
if (size === 0) {
continue;
}
const sizedFiles = bySize.get(size);
if (sizedFiles) {
sizedFiles.push(filePath);
} else {
bySize.set(size, [filePath]);
}
}
let linked = 0;
let savedBytes = 0;
for (const [size, sizedFiles] of bySize) {
if (sizedFiles.length < 2) {
continue;
}
const byHash = new Map();
for (const filePath of sizedFiles) {
let digest;
try {
digest = await sha256(filePath);
} catch (err) {
debug(`failed to hash ${filePath}: ${err?.message ?? String(err)}`);
continue;
}
const canonicalPath = byHash.get(digest);
if (!canonicalPath) {
byHash.set(digest, filePath);
continue;
}
const [canonicalStat, duplicateStat] = await Promise.all([
fs.lstat(canonicalPath).catch(() => null),
fs.lstat(filePath).catch(() => null),
]);
if (
!canonicalStat ||
!duplicateStat ||
!hasCompatibleHardlinkMetadata(canonicalStat, duplicateStat)
) {
continue;
}
if (
canonicalStat.dev === duplicateStat.dev &&
canonicalStat.ino === duplicateStat.ino
) {
continue;
}
if (await hardlinkDuplicate(canonicalPath, filePath)) {
linked += 1;
savedBytes += size;
}
}
}
return { linked, savedBytes };
}
async function deleteFilesByExtension(rootDir, extension) {
if (!(await exists(rootDir))) {
return 0;
}
let deleted = 0;
const stack = [rootDir];
while (stack.length) {
const current = stack.pop();
let dir;
try {
dir = await fs.opendir(current);
} catch (err) {
debug(`skip unreadable dir ${current}: ${err?.message ?? String(err)}`);
continue;
}
try {
for await (const dirent of dir) {
const fullPath = path.join(current, dirent.name);
if (dirent.isDirectory()) {
stack.push(fullPath);
continue;
}
if (
(dirent.isFile() || dirent.isSymbolicLink()) &&
dirent.name.endsWith(extension)
) {
try {
await fs.unlink(fullPath);
deleted += 1;
} catch (err) {
debug(
`failed to delete ${fullPath}: ${err?.message ?? String(err)}`
);
}
}
}
} finally {
await dir.close().catch(() => {});
}
}
return deleted;
}
async function deleteFilesByPredicate(rootDir, shouldDelete) {
if (!(await exists(rootDir))) {
return { deleted: 0, bytes: 0 };
}
let deleted = 0;
let bytes = 0;
const stack = [rootDir];
while (stack.length) {
const current = stack.pop();
let entries;
try {
entries = await fs.readdir(current, { withFileTypes: true });
} catch (err) {
debug(`skip unreadable dir ${current}: ${err?.message ?? String(err)}`);
continue;
}
for (const dirent of entries) {
const fullPath = path.join(current, dirent.name);
if (dirent.isDirectory()) {
stack.push(fullPath);
continue;
}
if (!dirent.isFile() || !shouldDelete(fullPath, dirent.name)) {
continue;
}
const size = await fileSize(fullPath);
try {
await fs.unlink(fullPath);
deleted += 1;
bytes += size;
} catch (err) {
debug(`failed to delete ${fullPath}: ${err?.message ?? String(err)}`);
}
}
}
return { deleted, bytes };
}
async function deleteDirsByName(rootDir, names, shouldPreserve = () => false) {
if (!(await exists(rootDir))) {
return { deleted: 0, bytes: 0 };
}
let deleted = 0;
let bytes = 0;
const stack = [rootDir];
while (stack.length) {
const current = stack.pop();
let entries;
try {
entries = await fs.readdir(current, { withFileTypes: true });
} catch (err) {
debug(`skip unreadable dir ${current}: ${err?.message ?? String(err)}`);
continue;
}
for (const dirent of entries) {
if (!dirent.isDirectory()) {
continue;
}
const fullPath = path.join(current, dirent.name);
if (shouldPreserve(fullPath)) {
stack.push(fullPath);
continue;
}
if (names.has(dirent.name)) {
const dirBytes = await directoryBytes(fullPath);
try {
await rmrf(fullPath);
deleted += 1;
bytes += dirBytes;
} catch (err) {
debug(`failed to delete ${fullPath}: ${err?.message ?? String(err)}`);
}
} else {
stack.push(fullPath);
}
}
}
return { deleted, bytes };
}
async function directoryBytes(rootDir) {
let bytes = 0;
for (const filePath of await walkFiles(rootDir)) {
bytes += await fileSize(filePath);
}
return bytes;
}
function formatMiB(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)}MiB`;
}
function normalizeTargetKey(arch, variant) {
// BuildKit: TARGETARCH=arm TARGETVARIANT=v7
if (arch === 'arm' && variant === 'v7') {
return 'armv7';
}
return `${arch}${variant ?? ''}`;
}
function serverNativeArch(targetKey) {
switch (targetKey) {
case 'amd64':
return 'x64';
case 'arm64':
return 'arm64';
case 'armv7':
case 'arm':
return 'armv7';
default:
return '';
}
}
async function pruneServerNative(distDir, keepArch) {
if (!keepArch) {
return;
}
const keepName = `server-native.${keepArch}.node`;
const entries = await safeReadDir(distDir);
await Promise.all(
entries.map(async name => {
if (
name.startsWith('server-native.') &&
name.endsWith('.node') &&
name !== keepName
) {
await fs.rm(path.join(distDir, name), { force: true }).catch(() => {});
}
})
);
}
function cpuPruneRegexes(targetKey) {
switch (targetKey) {
case 'arm64':
return [/-linux-x64-/, /-linux-x64$/, /-linux-arm-/, /-linux-arm$/];
case 'amd64':
return [/-linux-arm64-/, /-linux-arm64$/, /-linux-arm-/, /-linux-arm$/];
case 'armv7':
case 'arm':
return [/-linux-x64-/, /-linux-x64$/, /-linux-arm64-/, /-linux-arm64$/];
default:
return [];
}
}
function shouldPruneDir(name, regexes) {
return regexes.some(re => re.test(name));
}
async function pruneOptionalNativeDeps(nodeModulesDir, regexes) {
if (!regexes.length || !(await exists(nodeModulesDir))) {
return;
}
const topLevel = await safeReadDir(nodeModulesDir);
for (const name of topLevel) {
const fullPath = path.join(nodeModulesDir, name);
const stat = await fs.lstat(fullPath).catch(() => null);
if (!stat?.isDirectory()) {
continue;
}
if (name.startsWith('@')) {
const scopedEntries = await safeReadDir(fullPath);
for (const scopedName of scopedEntries) {
const scopedFullPath = path.join(fullPath, scopedName);
const scopedStat = await fs.lstat(scopedFullPath).catch(() => null);
if (!scopedStat?.isDirectory()) {
continue;
}
if (shouldPruneDir(scopedName, regexes)) {
await rmrf(scopedFullPath).catch(() => {});
}
}
continue;
}
if (shouldPruneDir(name, regexes)) {
await rmrf(fullPath).catch(() => {});
}
}
}
function preferredPrismaTargets(targetKey) {
switch (targetKey) {
case 'arm64':
return ['linux-arm64-openssl-3.0.x', 'linux-arm64-openssl-1.1.x'];
case 'amd64':
return ['debian-openssl-3.0.x', 'debian-openssl-1.1.x'];
case 'armv7':
case 'arm':
return ['linux-arm-openssl-3.0.x', 'linux-arm-openssl-1.1.x'];
default:
return [];
}
}
async function pickExistingPrismaTarget(prismaClientDir, candidates) {
const entries = new Set(await safeReadDir(prismaClientDir));
for (const target of candidates) {
if (entries.has(`libquery_engine-${target}.so.node`)) {
return target;
}
}
return '';
}
async function prunePrismaQueryEngines(dirPath, keepTarget) {
if (!keepTarget || !(await exists(dirPath))) {
return;
}
const keepName = `libquery_engine-${keepTarget}.so.node`;
const entries = await safeReadDir(dirPath);
if (!entries.includes(keepName)) {
return;
}
for (const name of entries) {
if (
name.startsWith('libquery_engine-') &&
name.endsWith('.so.node') &&
name !== keepName
) {
await fs.rm(path.join(dirPath, name), { force: true }).catch(() => {});
}
}
}
function runPrismaVersion(prismaBinPath, cwd) {
const result = spawnSync(prismaBinPath, ['-v'], {
cwd,
env: process.env,
stdio: VERBOSE ? 'inherit' : 'ignore',
});
return result.status === 0;
}
async function prunePrismaEngines(appRoot, targetKey) {
const prismaClientDir = path.join(
appRoot,
'node_modules',
'.prisma',
'client'
);
const prismaPkgDir = path.join(appRoot, 'node_modules', 'prisma');
const prismaEnginesDir = path.join(
appRoot,
'node_modules',
'@prisma',
'engines'
);
const prismaBinPath = path.join(appRoot, 'node_modules', '.bin', 'prisma');
if (!(await exists(prismaClientDir))) {
return;
}
const keepTarget = await pickExistingPrismaTarget(
prismaClientDir,
preferredPrismaTargets(targetKey)
);
if (!keepTarget) {
debug('no prisma keepTarget detected, skip prisma pruning');
return;
}
await prunePrismaQueryEngines(prismaClientDir, keepTarget);
await prunePrismaQueryEngines(prismaPkgDir, keepTarget);
const keepSchemaEngine = path.join(
prismaEnginesDir,
`schema-engine-${keepTarget}`
);
if ((await exists(prismaBinPath)) && !(await exists(keepSchemaEngine))) {
runPrismaVersion(prismaBinPath, appRoot);
}
if (!(await exists(keepSchemaEngine))) {
debug(`missing ${keepSchemaEngine}, skip pruning @prisma/engines`);
return;
}
const keepLibQueryEngine = `libquery_engine-${keepTarget}.so.node`;
const entries = await safeReadDir(prismaEnginesDir);
for (const name of entries) {
const isEngine =
name.startsWith('schema-engine-') || name.startsWith('libquery_engine-');
if (!isEngine) {
continue;
}
const keep =
name === `schema-engine-${keepTarget}` || name === keepLibQueryEngine;
if (!keep) {
await fs
.rm(path.join(prismaEnginesDir, name), { force: true })
.catch(() => {});
}
}
}
async function prunePrismaRuntimeArtifacts(nodeModulesDir) {
const prismaClientRuntimeDir = path.join(
nodeModulesDir,
'@prisma',
'client',
'runtime'
);
const prismaClientCopyRuntimeDir = path.join(
nodeModulesDir,
'prisma',
'prisma-client',
'runtime'
);
let deleted = 0;
let bytes = 0;
for (const runtimeDir of [
prismaClientRuntimeDir,
prismaClientCopyRuntimeDir,
]) {
const result = await deleteFilesByPredicate(
runtimeDir,
(_filePath, name) => {
return (
name.startsWith('query_engine_bg.') ||
name.startsWith('query_compiler_bg.')
);
}
);
deleted += result.deleted;
bytes += result.bytes;
}
return { deleted, bytes };
}
function isNodeModulesPackageRoot(nodeModulesDir, dirPath) {
const relative = path.relative(nodeModulesDir, dirPath);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
return false;
}
const segments = ['node_modules', ...relative.split(path.sep)];
const targetIndex = segments.length - 1;
for (let i = 0; i < segments.length - 1; i += 1) {
if (segments[i] !== 'node_modules') {
continue;
}
const packageIndex = i + 1;
if (!segments[packageIndex]) {
continue;
}
if (segments[packageIndex].startsWith('@')) {
if (targetIndex === packageIndex + 1) {
return true;
}
continue;
}
if (targetIndex === packageIndex) {
return true;
}
}
return false;
}
async function pruneNodeModulesArtifacts(nodeModulesDir) {
const disposableDirs = new Set([
'.github',
'.husky',
'benchmark',
'benchmarks',
'coverage',
'example',
'examples',
'test',
'testing',
'tests',
'__tests__',
]);
const disposableFilenames = new Set([
'.npmignore',
'.yarn-metadata.json',
'CHANGELOG',
'CHANGELOG.md',
'HISTORY.md',
'README',
'README.md',
]);
const disposableExtensions = [
'.cts',
'.d.cts',
'.d.mts',
'.d.ts',
'.markdown',
'.md',
'.mts',
'.ts',
'.tsbuildinfo',
'.tsx',
];
const dirResult = await deleteDirsByName(
nodeModulesDir,
disposableDirs,
dirPath => isNodeModulesPackageRoot(nodeModulesDir, dirPath)
);
const fileResult = await deleteFilesByPredicate(
nodeModulesDir,
(_filePath, name) => {
if (name.toLowerCase().startsWith('license')) {
return false;
}
return (
disposableFilenames.has(name) ||
disposableExtensions.some(extension => name.endsWith(extension))
);
}
);
return {
deletedDirs: dirResult.deleted,
deletedFiles: fileResult.deleted,
bytes: dirResult.bytes + fileResult.bytes,
};
}
const targetKey = normalizeTargetKey(TARGETARCH, TARGETVARIANT);
log(`root=${APP_ROOT} target=${targetKey || '(unknown)'}`);
if (!ALLOW_RUN) {
log('skip (set AFFINE_DOCKER_CLEAN=1 to enable)');
process.exit(0);
}
const deletedStaticMaps = await deleteFilesByExtension(
path.join(APP_ROOT, 'static'),
'.map'
);
const deletedNodeModulesMaps = await deleteFilesByExtension(
path.join(APP_ROOT, 'node_modules'),
'.map'
);
debug(`deleted static maps: ${deletedStaticMaps}`);
debug(`deleted node_modules maps: ${deletedNodeModulesMaps}`);
const staticDedupe = await hardlinkDuplicateFiles(
path.join(APP_ROOT, 'static')
);
log(
`hardlinked duplicate static files: ${staticDedupe.linked}, saved ${formatMiB(
staticDedupe.savedBytes
)}`
);
const distDir = path.join(APP_ROOT, 'dist');
await pruneServerNative(distDir, serverNativeArch(targetKey));
await pruneOptionalNativeDeps(
path.join(APP_ROOT, 'node_modules'),
cpuPruneRegexes(targetKey)
);
await prunePrismaEngines(APP_ROOT, targetKey);
const nodeModulesDir = path.join(APP_ROOT, 'node_modules');
const prismaRuntimeArtifacts =
await prunePrismaRuntimeArtifacts(nodeModulesDir);
log(
`deleted prisma runtime artifacts: ${prismaRuntimeArtifacts.deleted}, saved ${formatMiB(
prismaRuntimeArtifacts.bytes
)}`
);
const nodeModulesArtifacts = await pruneNodeModulesArtifacts(nodeModulesDir);
log(
`deleted node_modules artifacts: ${nodeModulesArtifacts.deletedFiles} files, ${
nodeModulesArtifacts.deletedDirs
} dirs, saved ${formatMiB(nodeModulesArtifacts.bytes)}`
);
await Promise.all([
rmrf(path.join(nodeModulesDir, 'typescript')).catch(() => {}),
rmrf(path.join(nodeModulesDir, '@types')).catch(() => {}),
rmrf(path.join(APP_ROOT, 'src')).catch(() => {}),
rmrf(path.join(APP_ROOT, '.gitignore')).catch(() => {}),
rmrf(path.join(APP_ROOT, '.dockerignore')).catch(() => {}),
rmrf(path.join(APP_ROOT, '.env.example')).catch(() => {}),
rmrf(path.join(APP_ROOT, 'ava.config.js')).catch(() => {}),
rmrf(path.join(APP_ROOT, 'tsconfig.json')).catch(() => {}),
rmrf(path.join(APP_ROOT, 'config.example.json')).catch(() => {}),
]);