Add production compose stack and stop idle load in containers
The development stack was running permanently on a server: polling file watchers, a healthcheck that rendered a full page every five seconds and no memory limit grew next dev to 10 GB and pushed the host into swap. - add compose.prod.yaml running compiled output in separate api/web services - make the Dockerfile multi-stage with dev and prod targets, prune devDependencies and run the runtime image as node instead of root - bake API_INTERNAL_URL at build time; next start ignores it at runtime because rewrite destinations are resolved into routes-manifest.json - drop CHOKIDAR_USEPOLLING and WATCHPACK_POLLING - probe /health instead of /, which redirects to /projects and made every healthcheck render the project list - give every service a memory limit and forbid swap in production - rename the development compose project to leistungsbilanz-dev so its down command cannot target the production stack - bind development ports to localhost - close the http server and the SQLite handle on SIGTERM/SIGINT - match probe user agents in the navigation log filter; Node's fetch sends one, so the previous check never matched - exit docker-start.sh when either supervised process dies - remove drizzle.config.js, a compiled copy drizzle-kit never reads, and the pre-Next index.html/styles.css leftovers Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a17e2e3f4b
commit
01fa527b9c
14 changed files with 424 additions and 118 deletions
8
src/db/client.ts
Normal file → Executable file
8
src/db/client.ts
Normal file → Executable file
|
|
@ -7,3 +7,11 @@ const defaultDatabaseContext = createDatabaseContext(
|
|||
|
||||
export const db = defaultDatabaseContext.db;
|
||||
|
||||
/**
|
||||
* Closes the process-wide SQLite handle. Only the composition root calls this,
|
||||
* during shutdown, so pending writes are flushed before the process exits.
|
||||
*/
|
||||
export function closeDefaultDatabase(): void {
|
||||
defaultDatabaseContext.close();
|
||||
}
|
||||
|
||||
|
|
|
|||
11
src/proxy.ts
Normal file → Executable file
11
src/proxy.ts
Normal file → Executable file
|
|
@ -4,10 +4,13 @@ import { createLogger } from "./shared/logging/logger";
|
|||
|
||||
const logger = createLogger("web:navigation");
|
||||
|
||||
// Node's fetch() sends "User-Agent: node", so the previous "no User-Agent"
|
||||
// check never matched and every healthcheck was logged as page navigation.
|
||||
const PROBE_USER_AGENT = /^(node|curl|wget|go-http-client|kube-probe)\b/i;
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
// The Docker healthcheck hits "/" every few seconds with no User-Agent
|
||||
// header; skip it so real navigation isn't drowned out in the logs.
|
||||
if (request.headers.get("user-agent")) {
|
||||
const userAgent = request.headers.get("user-agent") ?? "";
|
||||
if (userAgent && !PROBE_USER_AGENT.test(userAgent)) {
|
||||
logger.info("page request", {
|
||||
method: request.method,
|
||||
path: request.nextUrl.pathname,
|
||||
|
|
@ -17,5 +20,5 @@ export function proxy(request: NextRequest) {
|
|||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|api|health).*)"],
|
||||
};
|
||||
|
|
|
|||
34
src/server/index.ts
Normal file → Executable file
34
src/server/index.ts
Normal file → Executable file
|
|
@ -4,6 +4,7 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
|||
import { projectRouter } from "./routes/project.routes.js";
|
||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
||||
import { closeDefaultDatabase } from "../db/client.js";
|
||||
|
||||
const logger = createLogger("api");
|
||||
const app = express();
|
||||
|
|
@ -62,8 +63,35 @@ process.on("unhandledRejection", (reason) => {
|
|||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => logger.info("received SIGTERM"));
|
||||
process.on("SIGINT", () => logger.info("received SIGINT"));
|
||||
let shuttingDown = false;
|
||||
|
||||
function shutdown(signal: NodeJS.Signals): void {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
logger.info("shutting down", { signal });
|
||||
|
||||
// Without this the process only logged the signal and kept running, so every
|
||||
// stop waited for Docker's grace period and ended in SIGKILL mid-write.
|
||||
const forceExit = setTimeout(() => {
|
||||
logger.warn("shutdown timed out, exiting anyway");
|
||||
process.exit(1);
|
||||
}, 15_000);
|
||||
forceExit.unref();
|
||||
|
||||
server.close((error) => {
|
||||
if (error) logger.error("http server close failed", toErrorMeta(error));
|
||||
try {
|
||||
closeDefaultDatabase();
|
||||
} catch (closeError) {
|
||||
logger.error("database close failed", toErrorMeta(closeError));
|
||||
}
|
||||
logger.info("shutdown complete");
|
||||
process.exit(error ? 1 : 0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
setInterval(() => {
|
||||
const memory = process.memoryUsage();
|
||||
|
|
@ -74,6 +102,6 @@ setInterval(() => {
|
|||
});
|
||||
}, heartbeatIntervalMs).unref();
|
||||
|
||||
app.listen(port, () => {
|
||||
const server = app.listen(port, () => {
|
||||
logger.info("server started", { port });
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue