docker-entrypoint.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. /**
  2. * Docker entrypoint for GROWI (TypeScript)
  3. *
  4. * Runs directly with Node.js 24 native type stripping.
  5. * Uses only erasable TypeScript syntax (no enums, no namespaces).
  6. *
  7. * Responsibilities:
  8. * - Directory setup (as root): /data/uploads, symlinks, /tmp/page-bulk-export
  9. * - Heap size detection: V8_MAX_HEAP_SIZE → cgroup auto-calc → V8 default
  10. * - Privilege drop: process.setgid + process.setuid (root → node)
  11. * - Migration execution: execFileSync (no shell)
  12. * - App process spawn: spawn with signal forwarding
  13. */
  14. /** biome-ignore-all lint/suspicious/noConsole: Allow printing to console */
  15. import { execFileSync, spawn } from 'node:child_process';
  16. import fs from 'node:fs';
  17. // -- Constants --
  18. const NODE_UID = 1000;
  19. const NODE_GID = 1000;
  20. const CGROUP_V2_PATH = '/sys/fs/cgroup/memory.max';
  21. const CGROUP_V1_PATH = '/sys/fs/cgroup/memory/memory.limit_in_bytes';
  22. const CGROUP_V1_UNLIMITED_THRESHOLD = 64 * 1024 * 1024 * 1024; // 64GB
  23. const HEAP_RATIO = 0.6;
  24. // -- Exported utility functions --
  25. /**
  26. * Recursively chown a directory and all its contents.
  27. */
  28. export function chownRecursive(
  29. dirPath: string,
  30. uid: number,
  31. gid: number,
  32. ): void {
  33. const entries = fs.readdirSync(dirPath, { withFileTypes: true });
  34. for (const entry of entries) {
  35. const fullPath = `${dirPath}/${entry.name}`;
  36. if (entry.isDirectory()) {
  37. chownRecursive(fullPath, uid, gid);
  38. } else {
  39. fs.chownSync(fullPath, uid, gid);
  40. }
  41. }
  42. fs.chownSync(dirPath, uid, gid);
  43. }
  44. /**
  45. * Read a cgroup memory limit file and return the numeric value in bytes.
  46. * Returns undefined if the file cannot be read or the value is "max" / NaN.
  47. */
  48. export function readCgroupLimit(filePath: string): number | undefined {
  49. try {
  50. const content = fs.readFileSync(filePath, 'utf-8').trim();
  51. if (content === 'max') return undefined;
  52. const value = parseInt(content, 10);
  53. if (Number.isNaN(value)) return undefined;
  54. return value;
  55. } catch {
  56. return undefined;
  57. }
  58. }
  59. /**
  60. * Detect heap size (MB) using 3-level fallback:
  61. * 1. V8_MAX_HEAP_SIZE env var
  62. * 2. cgroup v2/v1 auto-calculation (60% of limit)
  63. * 3. undefined (V8 default)
  64. */
  65. export function detectHeapSize(): number | undefined {
  66. // Priority 1: V8_MAX_HEAP_SIZE env
  67. const envValue = process.env.V8_MAX_HEAP_SIZE;
  68. if (envValue != null && envValue !== '') {
  69. const parsed = parseInt(envValue, 10);
  70. if (Number.isNaN(parsed)) {
  71. console.error(
  72. `[entrypoint] V8_MAX_HEAP_SIZE="${envValue}" is not a valid number, ignoring`,
  73. );
  74. return undefined;
  75. }
  76. return parsed;
  77. }
  78. // Priority 2: cgroup v2
  79. const cgroupV2 = readCgroupLimit(CGROUP_V2_PATH);
  80. if (cgroupV2 != null) {
  81. return Math.floor((cgroupV2 / 1024 / 1024) * HEAP_RATIO);
  82. }
  83. // Priority 3: cgroup v1 (treat > 64GB as unlimited)
  84. const cgroupV1 = readCgroupLimit(CGROUP_V1_PATH);
  85. if (cgroupV1 != null && cgroupV1 < CGROUP_V1_UNLIMITED_THRESHOLD) {
  86. return Math.floor((cgroupV1 / 1024 / 1024) * HEAP_RATIO);
  87. }
  88. // Priority 4: V8 default
  89. return undefined;
  90. }
  91. /**
  92. * Build Node.js flags array based on heap size and environment variables.
  93. */
  94. export function buildNodeFlags(heapSize: number | undefined): string[] {
  95. const flags: string[] = ['--expose_gc'];
  96. if (heapSize != null) {
  97. flags.push(`--max-heap-size=${heapSize}`);
  98. }
  99. if (process.env.V8_OPTIMIZE_FOR_SIZE === 'true') {
  100. flags.push('--optimize-for-size');
  101. }
  102. if (process.env.V8_LITE_MODE === 'true') {
  103. flags.push('--lite-mode');
  104. }
  105. return flags;
  106. }
  107. /**
  108. * Setup required directories (as root).
  109. * - /data/uploads with symlink to ./public/uploads
  110. * - /tmp/page-bulk-export with mode 700
  111. */
  112. export function setupDirectories(
  113. uploadsDir: string,
  114. publicUploadsLink: string,
  115. bulkExportDir: string,
  116. ): void {
  117. // /data/uploads
  118. fs.mkdirSync(uploadsDir, { recursive: true });
  119. if (!fs.existsSync(publicUploadsLink)) {
  120. fs.symlinkSync(uploadsDir, publicUploadsLink);
  121. }
  122. chownRecursive(uploadsDir, NODE_UID, NODE_GID);
  123. fs.lchownSync(publicUploadsLink, NODE_UID, NODE_GID);
  124. // /tmp/page-bulk-export
  125. fs.mkdirSync(bulkExportDir, { recursive: true });
  126. chownRecursive(bulkExportDir, NODE_UID, NODE_GID);
  127. fs.chmodSync(bulkExportDir, 0o700);
  128. }
  129. /**
  130. * Drop privileges from root to node user.
  131. * These APIs are POSIX-only and guaranteed to exist in the Docker container (Linux).
  132. */
  133. export function dropPrivileges(): void {
  134. if (process.setgid == null || process.setuid == null) {
  135. throw new Error('Privilege drop APIs not available (non-POSIX platform)');
  136. }
  137. process.setgid(NODE_GID);
  138. process.setuid(NODE_UID);
  139. }
  140. /**
  141. * Log applied Node.js flags to stdout.
  142. */
  143. function logFlags(heapSize: number | undefined, flags: string[]): void {
  144. const source = (() => {
  145. if (
  146. process.env.V8_MAX_HEAP_SIZE != null &&
  147. process.env.V8_MAX_HEAP_SIZE !== ''
  148. ) {
  149. return 'V8_MAX_HEAP_SIZE env';
  150. }
  151. if (heapSize != null) return 'cgroup auto-detection';
  152. return 'V8 default (no heap limit)';
  153. })();
  154. console.log(`[entrypoint] Heap size source: ${source}`);
  155. console.log(`[entrypoint] Node.js flags: ${flags.join(' ')}`);
  156. }
  157. /**
  158. * Run database migration via execFileSync (no shell needed).
  159. * Equivalent to: node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js
  160. */
  161. function runMigration(): void {
  162. console.log('[entrypoint] Running migration...');
  163. execFileSync(
  164. process.execPath,
  165. [
  166. '-r',
  167. 'dotenv-flow/config',
  168. 'node_modules/migrate-mongo/bin/migrate-mongo',
  169. 'up',
  170. '-f',
  171. 'config/migrate-mongo-config.js',
  172. ],
  173. {
  174. stdio: 'inherit',
  175. env: { ...process.env, NODE_ENV: 'production' },
  176. },
  177. );
  178. console.log('[entrypoint] Migration completed');
  179. }
  180. /**
  181. * Spawn the application process and forward signals.
  182. */
  183. function spawnApp(nodeFlags: string[]): void {
  184. const child = spawn(
  185. process.execPath,
  186. [...nodeFlags, '-r', 'dotenv-flow/config', 'dist/server/app.js'],
  187. {
  188. stdio: 'inherit',
  189. env: { ...process.env, NODE_ENV: 'production' },
  190. },
  191. );
  192. // PID 1 signal forwarding
  193. const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP'];
  194. for (const sig of signals) {
  195. process.on(sig, () => child.kill(sig));
  196. }
  197. child.on('exit', (code: number | null, signal: NodeJS.Signals | null) => {
  198. process.exit(code ?? (signal === 'SIGTERM' ? 0 : 1));
  199. });
  200. }
  201. // -- Main entrypoint --
  202. function main(): void {
  203. try {
  204. // Step 1: Directory setup (as root)
  205. setupDirectories(
  206. '/data/uploads',
  207. './public/uploads',
  208. '/tmp/page-bulk-export',
  209. );
  210. // Step 2: Detect heap size and build flags
  211. const heapSize = detectHeapSize();
  212. const nodeFlags = buildNodeFlags(heapSize);
  213. logFlags(heapSize, nodeFlags);
  214. // Step 3: Drop privileges (root → node)
  215. dropPrivileges();
  216. // Step 4: Run migration
  217. runMigration();
  218. // Step 5: Start application
  219. spawnApp(nodeFlags);
  220. } catch (err) {
  221. console.error('[entrypoint] Fatal error:', err);
  222. process.exit(1);
  223. }
  224. }
  225. // Run main only when executed directly (not when imported for testing)
  226. const isMainModule =
  227. process.argv[1] != null &&
  228. (process.argv[1].endsWith('docker-entrypoint.ts') ||
  229. process.argv[1].endsWith('docker-entrypoint.js'));
  230. if (isMainModule) {
  231. main();
  232. }