ลดขนาด Docker Image ของ NestJS/Prisma กับ Nx Monorepo ประมาณ 80% ด้วย esbuild Bundle

เจ็บปวดกันมาเท่าไหร่กับ ขนาดของ project nodejs ที่ใหญ่มโหฬาร วันนี้ผมจะมาแชร์ประสบการณ์ Workaround ของผมว่าจะลด 80% ได้ยังไง ด้วย esbuild Bundle

Unsplash ID: jOqJbvo1P9g
REPOSITORY            TAG           IMAGE ID       CREATED       SIZE
remote-state-api debian f42e4b8e75cb 4 days ago 2.64GB
REPOSITORY            TAG            IMAGE ID       CREATED       SIZE
remote-state-api alpine-bundle 022a0feda515 4 days ago 376MB

Stack ที่ใช้

du -sh ./node_modules
495M ./node_modules
  • แต่ข้อเสียคือ การ config ให้ tsconfig, eslint, jest ให้มีการใช้งานร่วมกันระหว่างโปรเจ็คได้ ค่อนข้างใช้เวลาเยอะ และ เราไม่สามารถใช้ความสามารถ Generator ของ Nx ได้ ที่สามารถเสก Template TypeScript Project ง่ายๆ
  • ตัวอย่าง Monorepo ที่ลักษณะคล้ายๆ กันก็คือจะมี Yarn Workspace, pnpm Workspace, Lerna, turborepo
  • แต่ข้อเสียคือ Learning Curve สูงกว่าแบบแรกแน่นอน แต่อย่าลืมว่า การ config ให้ tsconfig, eslint, jest ให้มีการใช้งานร่วมกันระหว่างโปรเจ็คได้ ก็ใช้เวลาเยอะเช่นกัน
  • อีกข้อดีและข้อเสีย ก็คือการที่มี package.json ที่เดียวในตำแหน่ง root ของ repo เท่านั้น ซึ่งข้อดีก็คือทำให้เราจัดการ package version ไม่ซ้ำซ้อนกัน เช่น Library ที่ใช้งานร่วมกันก็ควรมี dependencies ที่มี version เดียวกัน ดังนั้นการเปลี่ยน version ทุก project จึงเป็นเรื่องไม่ค่อยอยากทำ ส่วนข้อเสียก็คือ เวลาเราจะ deploy หรือ publish บาง project ขนาดของ node_modules จะอ้วนมากๆ
  • ตัวอย่าง Template ที่ Nx มีให้ใช้ก็เยอะมากๆ หลักๆ คือ React, Angular, Vue, Nestjs และพวก Library Project ต่างๆ ดู plugin ทั้งหมดได้ใน Nx Packages

จากมีมที่เค้าชอบแซวๆ กันใน node_modules

ต่อๆ

Step 1: First Docker Image

REPOSITORY            TAG           IMAGE ID       CREATED       SIZE
remote-state-api debian f42e4b8e75cb 4 days ago 2.64GB
REPOSITORY            TAG       IMAGE ID       CREATED        SIZE
node 18 c6b41dff69c8 20 hours ago 942MB

ตัวอย่าง Dockerfile ที่ใช้ใน Step นี้

FROM node:18 As development
RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm
WORKDIR /app
COPY ./dist/apps/remote-state-server .
COPY apps/remote-state-server/prisma ./prisma/
COPY package.json pnpm-lock.yaml ./
ENV PORT=3333
EXPOSE ${PORT}
RUN pnpm fetch --prod
RUN pnpm install
RUN pnpx prisma generate
# We don't have the existing sqlite file
# So, we will create a fresh sqlite every time when build
# This migration should be run every time when build
RUN pnpx prisma migrate deploy
CMD node ./main.jsd
nx run remote-state-server:build:production

Step 2: สวัสดี Alpine Image

REPOSITORY            TAG           IMAGE ID       CREATED       SIZE
remote-state-api alpine f42e4b8e75cb 4 days ago 1.18GB
REPOSITORY            TAG           IMAGE ID       CREATED       SIZE
node 18-alpine 8a6b96edfa16 9 days ago 167MB

ตัวอย่าง Dockerfile

###################
# BUILD FOR DEVELOPMENT
###################

FROM node:18 As development
RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm
WORKDIR /app
COPY --chown=node:node . .
RUN pnpm install --frozen-lockfile
RUN pnpx nx run remote-state-server:build:production
USER node

###################
# BUILD FOR PRODUCTION
###################

FROM node:18 As build
RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm
WORKDIR /app
COPY --chown=node:node package.json pnpm-lock.yaml ./
COPY --chown=node:node apps/remote-state-server/prisma ./prisma/
COPY --chown=node:node --from=development /app/dist/apps/remote-state-server .
ENV NODE_ENV production
RUN pnpm install --prod --frozen-lockfile
USER node

###################
# PRODUCTION
###################

FROM node:18-alpine As production

WORKDIR /app
COPY --chown=node:node --from=build /app .

RUN apk add --update --no-cache openssl1.1-compat curl
# We don't have the existing sqlite file
# So, we will create a fresh sqlite every time when build
# This migration should be run every time when build
RUN npx prisma migrate deploy
# Prepare prima library
RUN npx prisma generate

# Clean up with https://github.com/tj/node-prune
RUN curl -sf https://gobinaries.com/tj/node-prune | sh

# Run cleanup necessary dependencies Ref: Fix npm by https://bobbyhadz.com/blog/npm-fix-the-upstream-dependency-conflict-installing-npm-packages
# RUN npm prune --force --legacy-peer-deps --production
RUN /usr/local/bin/node-prune

# remove unused dependencies
# https://tsh.io/blog/reduce-node-modules-for-better-performance/
# https://medium.com/@alpercitak/nest-js-reducing-docker-container-size-4c2672369d30
RUN rm -rf node_modules/rxjs/src/
RUN rm -rf node_modules/rxjs/bundles/
RUN rm -rf node_modules/rxjs/_esm5/
RUN rm -rf node_modules/rxjs/_esm2015/
RUN rm -rf node_modules/swagger-ui-dist/*.map

ENV PORT=3333
EXPOSE ${PORT}

CMD [ "node", "main.js" ]

Step 3: จัดการ dependencies ที่ไม่เกี่ยวข้องกับโปรเจ็คนั้นๆ ออกไปจาก Nx

  1. เราสามารถใช้ depcheck เพื่อดู Missing dependencies ใน Project เรา ส่วนใหญ่แล้ว Missing dependencies จะเป็นการอ้างอิงภายใน monorepo เดียวกัน เพราะมันไม่ได้อยู่ใน package.json ที่อยู่ root ของ repo
  2. เราสามารถใช้ tool ของ Nx ในการ Export Project Graph to JSON ได้โดยใช้ nx graph --file=output.json
REPOSITORY            TAG             IMAGE ID       CREATED       SIZE
remote-state-api alpine-unused 907254a67d28 4 days ago 831MB

Step 4: Bundle Nestjs โดยใช้ esbuild

import { build, BuildOptions } from 'esbuild';
const buildConfig: BuildOptions = {
// ...
external: [
'cache-manager',
'@nestjs/microservices',
'class-transformer/storage',
],
// ...
};
await build(buildConfig);

Final Solution

import fs from 'fs';
import path from 'path';
import { program } from 'commander';
import { build, BuildOptions } from 'esbuild';
import { typecheckPlugin } from '@jgoz/esbuild-plugin-typecheck';
import { esbuildDecorators } from '@anatine/esbuild-decorators';

const cwd = process.cwd();
const outfile = path.resolve(cwd, 'output.js');
const tsconfig = path.resolve(cwd, 'tsconfig.json');

const isVerbose = process.env.verbose === 'true' ? true : false;

interface ICommandOption {
output: string;
watch: boolean;
}

program
.description('Script for building atom CLI')
.option('-w, --watch', 'Enable watch mode')
.option('-o, --output <path>', 'output file')
.action(async () => {
const opts = program.opts() as ICommandOption;
await main({
watch: opts.watch ?? false,
output: opts.output ? path.resolve(opts.output) : 'dist',
});
});

program.parse(process.argv);

async function main(option: ICommandOption) {

const config: BuildOptions = {
entryPoints: ['apps/remote-state-server/src/main.ts'],
bundle: true,
platform: 'node',
target: ['node18', 'es2021'],
outdir: option.output,
tsconfig,
plugins: [
typecheckPlugin(),
esbuildDecorators({
tsconfig,
cwd,
}),
],
};
console.log(option.output);
const buildConfig: BuildOptions = {
external: [
'commander',
'cache-manager',
'@nestjs/microservices',
'prisma',
'@prisma/client',
'kafkajs',
'mqtt',
'amqplib',
'amqp-connection-manager',
'nats',
'@grpc/grpc-js',
'@grpc/proto-loader',
'@nestjs/websockets/socket-module',
'class-transformer/storage',
],
...config,
};

const developConfig: BuildOptions = {
...config,
external: ['commander'],
watch: {
onRebuild,
},
};

await build(option.watch ? developConfig : buildConfig);
}

function onRebuild(error: any, result: any): void {
if (error) {
console.error('watch build failed');
if (isVerbose) console.error(result, error);
} else console.log(new Date().toISOString() + ' watch build succeeded ');
}
// project.json
{
"targets": {
"build-esbuild": {
"executor": "nx:run-commands",
"options": {
"commands": [
"tsx src/scripts/build.ts --output '../../dist/apps/remote-state-server'"
],
"parallel": false,
"cwd": "apps/remote-state-server"
}
}
}
###################
# BUILD
###################

FROM node:18 As development
RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm

WORKDIR /app
COPY --chown=node:node . .
RUN pnpm install --frozen-lockfile
RUN pnpx nx run remote-state-server:build-esbuild
USER node

###################
# Install Dependencies
###################

FROM node:18 As build
RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm

WORKDIR /app
COPY --chown=node:node apps/remote-state-server/prisma ./prisma/
COPY --chown=node:node --from=development /app/dist/apps/remote-state-server .
ENV NODE_ENV production
RUN pnpm install @prisma/client@^4.7.1
USER node

###################
# PRODUCTION
###################

FROM node:18-alpine As production

WORKDIR /app

COPY --chown=node:node --from=build /app .

# For Prisma client in Alpine
# Fix "Error: Unable to establish a connection to query-engine-node-api library. It seems there is a problem with your OpenSSL installation!"
# Ref: https://github.com/prisma/prisma/issues/14073
RUN apk add --update --no-cache openssl1.1-compat

# We don't have the existing sqlite file
# So, we will create a fresh sqlite every time when build
# This migration should be run every time when build
RUN npx prisma migrate deploy
# Prepare prima library
RUN npx prisma generate

ENV PORT=3333
EXPOSE ${PORT}

CMD [ "node", "main.js" ]

สรุป

REPOSITORY            TAG            IMAGE ID       CREATED       SIZE
remote-state-api alpine-bundle 022a0feda515 4 days ago 376MB

แหล่งอ้างอิง

Docker

Docker Read more

Kube

BFF

--

--

Web developers with ASP.Net, MSSQL, Azure working in Remote Office 100%

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store