From 1b3fd5e20409e262d19845a0f6edf57e62651fab Mon Sep 17 00:00:00 2001 From: localnerve Date: Thu, 29 Jan 2026 00:50:48 -0500 Subject: [PATCH] @2.9.0 - docker image optimization --- .dockerignore | 4 +- Dockerfile | 79 +++++++++++++++++++++++++++++++++------ docker/build-image.sh | 2 +- docker/docker-compose.yml | 1 + package-lock.json | 4 +- package.json | 2 +- src/test/services.js | 66 +++++++++++++++++++++++--------- 7 files changed, 121 insertions(+), 37 deletions(-) diff --git a/.dockerignore b/.dockerignore index 48164d9..a95af53 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,6 +14,4 @@ coverage .git .gitignore .vscode -src/test/.auth -# The package-lock.json contains info that prevents @localnerve/gulp-imagemin from installing on a different os -package-lock.json \ No newline at end of file +src/test/.auth \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 31f03a3..8150e75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,18 @@ -FROM node:24.12.0-alpine - -ARG UID=1000 -ARG GID=1000 +FROM node:24.12.0-alpine AS builder ARG AUTHZ_URL=http://localhost:9010 ARG AUTHZ_CLIENT_ID=E37D308D-9068-4FCC-BFFB-2AA535014B64 ARG DEV_BUILD=0 -ARG TARGETARCH USER root RUN apk --no-cache add shadow -RUN usermod -u $UID -g node -o node; \ -groupmod -g $GID -o node -RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app - -USER node WORKDIR /home/node/app -COPY --chown=node:node ./ ./ -RUN npm install --loglevel info +# Install all deps (including devDeps for the build step) +COPY package*.json ./ +RUN npm ci + +# Copy source and build +COPY . . RUN if [ "$DEV_BUILD" = "0" ]; then \ echo "Production build"; \ echo "Building with AUTHZ_URL=$AUTHZ_URL, AUTHZ_CLIENT_ID=$AUTHZ_CLIENT_ID"; \ @@ -28,6 +23,66 @@ else \ SW_INSTRUMENT=1 AUTHZ_URL=$AUTHZ_URL AUTHZ_CLIENT_ID=$AUTHZ_CLIENT_ID npm run build:dev; \ fi +# Production runtime stage - minimal size with only production dependencies +FROM node:24.12.0-alpine AS runtime-prod +WORKDIR /home/node/app + +ARG UID=1000 +ARG GID=1000 + +USER root + +RUN apk --no-cache add shadow && \ + usermod -u $UID -g node -o node && \ + groupmod -g $GID -o node && \ + mkdir -p /home/node/app && \ + chown -R node:node /home/node/app + +USER node + +# Fresh install of production dependencies only for minimal size +COPY --from=builder --chown=node:node /home/node/app/package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +# Copy the specific folders required for your start script +COPY --from=builder --chown=node:node /home/node/app/dist ./dist +COPY --from=builder --chown=node:node /home/node/app/src/application/server ./src/application/server + +# Set runtime environment +ENV NODE_ENV=production + +EXPOSE 5000 + +ENTRYPOINT ["npm", "start", "--", "--PORT=5000", "--ENV-PATH=/run/secrets/jam-env.json"] + +# Development runtime stage - includes all dependencies (c8, etc.) for testing +FROM node:24.12.0-alpine AS runtime-dev +WORKDIR /home/node/app + +ARG UID=1000 +ARG GID=1000 + +USER root + +RUN apk --no-cache add shadow && \ + usermod -u $UID -g node -o node && \ + groupmod -g $GID -o node && \ + mkdir -p /home/node/app && \ + chown -R node:node /home/node/app + +USER node + +# Copy all dependencies from builder (includes c8 and other devDependencies) +COPY --from=builder --chown=node:node /home/node/app/package*.json ./ +COPY --from=builder --chown=node:node /home/node/app/node_modules ./node_modules + +# Copy the specific folders required for your start script +COPY --from=builder --chown=node:node /home/node/app/dist ./dist +COPY --from=builder --chown=node:node /home/node/app/src/application/server ./src/application/server + +# Set runtime environment +ENV NODE_ENV=production + EXPOSE 5000 ENTRYPOINT ["npm", "start", "--", "--PORT=5000", "--ENV-PATH=/run/secrets/jam-env.json"] \ No newline at end of file diff --git a/docker/build-image.sh b/docker/build-image.sh index 0c15305..4dd52d7 100755 --- a/docker/build-image.sh +++ b/docker/build-image.sh @@ -27,4 +27,4 @@ export AUTHZ_URL=$(find_envvar AUTHZ_URL) export AUTHZ_CLIENT_ID=$(find_envvar AUTHZ_CLIENT_ID) echo Building "$IMAGE_TAG" image with AUTHZ_URL=$AUTHZ_URL and AUTHZ_CLIENT_ID=$AUTHZ_CLIENT_ID -docker buildx build --tag "$IMAGE_TAG" --secret id=jam-build,src=$HOSTENV_FILE --file $DOCKER_FILE $PROJECT_DIR --build-arg UID=`id -u` --build-arg GID=`id -g` --build-arg AUTHZ_URL --build-arg AUTHZ_CLIENT_ID \ No newline at end of file +docker buildx build --target runtime-prod --tag "$IMAGE_TAG" --secret id=jam-build,src=$HOSTENV_FILE --file $DOCKER_FILE $PROJECT_DIR --build-arg UID=`id -u` --build-arg GID=`id -g` --build-arg AUTHZ_URL --build-arg AUTHZ_CLIENT_ID \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index aeba03a..a08fed5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -5,6 +5,7 @@ services: build: context: ../ dockerfile: ./Dockerfile + target: runtime-prod args: UID: ${UID} GID: ${GID} diff --git a/package-lock.json b/package-lock.json index 6d770ea..88237f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jam-build", - "version": "2.8.9", + "version": "2.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jam-build", - "version": "2.8.9", + "version": "2.9.0", "license": "AGPL-3.0-or-later", "dependencies": { "@localnerve/authorizer-js": "^1.0.5", diff --git a/package.json b/package.json index f9590a8..1d08693 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jam-build", - "version": "2.8.9", + "version": "2.9.0", "description": "An adventurous, scalable, fullstack web application reference project", "main": "index.js", "type": "module", diff --git a/src/test/services.js b/src/test/services.js index 801da0f..39e80a9 100644 --- a/src/test/services.js +++ b/src/test/services.js @@ -38,7 +38,7 @@ async function deleteImageByName (imageName) { } */ -async function checkImageExists (imageName) { +async function checkImageExists(imageName) { const dockerode = (await getContainerRuntimeClient()).container.dockerode; try { await dockerode.getImage(imageName.toString()).inspect(); @@ -50,11 +50,38 @@ async function checkImageExists (imageName) { } } -export async function createAppContainer (authorizerContainer, containerNetwork, mariadbContainer, appImageName) { +/** + * Clean up dangling builder stage images from multi-stage builds. + * The TestContainers reaper cannot track intermediate build stages, + * so we need to manually clean them up. + */ +async function cleanupBuilderImages() { + const dockerode = (await getContainerRuntimeClient()).container.dockerode; + try { + debug('Cleaning up dangling builder stage images...'); + const images = await dockerode.listImages({ + filters: { dangling: ['true'] } + }); + + for (const image of images) { + try { + debug(`Removing dangling image ${image.Id}...`); + await dockerode.getImage(image.Id).remove({ force: false }); + } catch (err) { + debug(`Could not remove image ${image.Id}:`, err.message); + } + } + debug('Builder image cleanup complete.'); + } catch (err) { + debug('Error during builder image cleanup:', err.message); + } +} + +export async function createAppContainer(authorizerContainer, containerNetwork, mariadbContainer, appImageName) { const forceAppBuild = !!(process.env.FORCE_BUILD); debug(`Checking ${appImageName}... FORCE_BUILD=${forceAppBuild}`); - + let appContainerImage; if (await checkImageExists(appImageName) && !forceAppBuild) { debug(`Using image ${appImageName} without building...`); @@ -62,7 +89,8 @@ export async function createAppContainer (authorizerContainer, containerNetwork, } else { const userInfo = os.userInfo(); debug(`Building image ${appImageName}`, userInfo, os.arch(), process.env.AUTHZ_URL, process.env.AUTHZ_CLIENT_ID); - appContainerImage = await GenericContainer.fromDockerfile(path.resolve(thisDir, toRoot)) + + appContainerImage = await GenericContainer.fromDockerfile(path.resolve(thisDir, toRoot), 'Dockerfile') .withBuildArgs({ UID: `${userInfo.uid}`, GID: `${userInfo.gid}`, @@ -72,14 +100,18 @@ export async function createAppContainer (authorizerContainer, containerNetwork, AUTHZ_URL: process.env.AUTHZ_URL, AUTHZ_CLIENT_ID: process.env.AUTHZ_CLIENT_ID }) + .withTarget('runtime-dev') .withCache(true) .build(appImageName, { deleteOnExit: false }); + + // Clean up dangling builder stage images that the reaper cannot track + await cleanupBuilderImages(); } - + debug(`Starting ${appImageName}...`); - + const appContainer = await appContainerImage .withName('jam-build') .withNetwork(containerNetwork) @@ -99,13 +131,13 @@ export async function createAppContainer (authorizerContainer, containerNetwork, }) .withWaitStrategy(Wait.forLogMessage(/listening on port \d+/)) .start(); - + debug(`Container ${appImageName} started.`); return appContainer; } -export async function createDatabaseAndAuthorizer () { +export async function createDatabaseAndAuthorizer() { let client, authorizerContainer; const dbHost = 'mariadb'; @@ -129,17 +161,15 @@ export async function createDatabaseAndAuthorizer () { database: mariadbContainer.getDatabase(), user: 'root', password: mariadbContainer.getRootPassword(), - logger: () => {} // console.log // eslint-disable-line + logger: () => { } // console.log // eslint-disable-line }); - + debug('Creating prerequisite databases and users...'); await client.query('CREATE DATABASE authorizer'); // must exist prior to authorizer - await client.query(`CREATE USER IF NOT EXISTS '${ - process.env.DB_USER - }'@'%' IDENTIFIED BY '${process.env.DB_PASSWORD}'`); - await client.query(`CREATE USER IF NOT EXISTS '${ - process.env.DB_APP_USER - }'@'%' IDENTIFIED BY '${process.env.DB_APP_PASSWORD}';`); + await client.query(`CREATE USER IF NOT EXISTS '${process.env.DB_USER + }'@'%' IDENTIFIED BY '${process.env.DB_PASSWORD}'`); + await client.query(`CREATE USER IF NOT EXISTS '${process.env.DB_APP_USER + }'@'%' IDENTIFIED BY '${process.env.DB_APP_PASSWORD}';`); debug('Starting authorizer container...'); authorizerContainer = await new GenericContainer('localnerve/authorizer:1.5.3') @@ -164,7 +194,7 @@ export async function createDatabaseAndAuthorizer () { // sanity checks - jam_build and authorizer databases exist, authorizer tables exist // await client.query('show databases'); // await client.query('show tables from authorizer'); - + debug('Creating jam_build database tables...'); await client.importFile({ file: path.resolve(thisDir, toRoot, './data/database/002-mariadb-ddl-tables.sql') @@ -187,7 +217,7 @@ export async function createDatabaseAndAuthorizer () { // sanity check - procs should exist and be granted to jbuser and jbadmin // await client.query('SHOW PROCEDURE STATUS WHERE Db = \'jam_build\''); - + } finally { if (client) { await client.end();