如何为npm workspaces单仓中的单个Node.js应用构建精简Docker镜像
解决Monorepo下Node.js应用的Docker镜像瘦身问题
背景
我有一个包含4个Node.js包的monorepo,结构如下:
/ └── apps ├── app1 ( "dependencies": { "lib1": "../../libs/lib1" } ) └── app2 ( "dependencies": { "lib2": "../../libs/lib2", "express": "^4.18.2" } ) └── libs └── lib1 ( "dependencies": { "lib2": "../lib2", "puppeteer": "^21.1.1" } ) └── lib2 ( "dependencies": { "node-gyp": "^9.4.0" } ) └── node_modules └── package.json ( { ... "workspaces": ["apps/app*", "libs/lib*"], ... } )
核心需求:
- app1不需要express
- app2不需要lib1和puppeteer
要为app1和app2构建Docker镜像,要求:
- 镜像仅包含应用实际需要的node_modules
- 解析内部依赖的软链接,将实际代码纳入镜像而非软链接
当前尝试的方案及问题
我用GitLab CI缓存.npm目录,通过npm ci --cache .npm --prefer-offline -w "$NODE_APP_DIR"安装单个工作区的依赖,同时采用多阶段构建:
# Stage 1: Create an intermediate image for npm install FROM node:20.5.1-alpine3.18 as builder # Install build dependencies RUN apk update && apk --no-cache add build-base python3 # Copy the application code into the builder image COPY . /mono-repo # Set the working directory WORKDIR /mono-repo # Set the NODE_APP_DIR ARG ARG NODE_APP_DIR # Run npm install in the workspace RUN npm ci --cache .npm --prefer-offline -w "$NODE_APP_DIR" # Remove everything that the app doesn't need RUN export EXCLUDE_PATHS="$(npm ls -p -w "$NODE_APP_DIR" | sed 1d)" && echo "DEPENDENCIES: $EXCLUDE_PATHS" && find "$PWD" -name "*" | grep -vE "$(echo -en "$EXCLUDE_PATHS" | sed 's/[]\/$*.^|[]/\\&/g' | sed 's/^/^/' | tr '\n' '|')" # Stage 2: Create the final production image FROM node:20.5.1-alpine3.18 # Copy everything that's left from the builder image COPY --from=builder /mono-repo /mono-repo # Set the working directory WORKDIR /mono-repo # Set the NODE_APP_DIR ARG ARG NODE_APP_DIR # CMD for the final image CMD npm start --workspace "$NODE_APP_DIR"
构建命令:
docker build --build-args NODE_APP_DIR="apps/app2" -t app2:test
但删除不必要文件的步骤未生效,镜像仍包含大量冗余node_modules。问题出在:npm ls -p输出的路径格式和find结果不匹配,正则处理复杂且容易出错,无法准确筛选需要保留的文件。
优化方案
方案1:用npm pack处理内部依赖,复制必要文件到独立目录
核心思路是为目标应用创建独立目录,仅复制应用代码和所有依赖(包括内部lib的打包文件),避免复制整个monorepo。
修改builder阶段:
# Stage 1: Build and package dependencies FROM node:20.5.1-alpine3.18 as builder RUN apk update && apk --no-cache add build-base python3 jq COPY . /mono-repo WORKDIR /mono-repo ARG NODE_APP_DIR # 安装目标工作区的依赖 RUN npm ci --cache .npm --prefer-offline -w "$NODE_APP_DIR" # 创建目标目录 RUN mkdir -p /app # 复制应用代码到目标目录 COPY "$NODE_APP_DIR"/ /app/ # 初始化app的package.json和node_modules RUN cp "$NODE_APP_DIR/package.json" /app/ RUN mkdir -p /app/node_modules # 处理内部依赖:将libs下的依赖打包后解压到app的node_modules RUN for lib in $(jq -r '.dependencies | keys[]' "/mono-repo/$NODE_APP_DIR/package.json" | grep -E '^lib'); do \ cd "/mono-repo/libs/$lib" && npm pack; \ tar -xzf "$lib"*.tgz -C /app/node_modules; \ mv /app/node_modules/package /app/node_modules/"$lib"; \ rm "$lib"*.tgz; \ done # 复制外部依赖到app的node_modules RUN cp -R /mono-repo/node_modules/* /app/node_modules/ # 清理外部依赖中不需要的包 RUN npm prune --production --prefix /app # Stage 2: 最终镜像 FROM node:20.5.1-alpine3.18 COPY --from=builder /app /app WORKDIR /app CMD ["npm", "start"]
方案2:用npm list生成依赖列表,结合rsync复制必要文件
避免删除操作,直接复制需要的文件到目标目录:
# Stage 1: 准备依赖列表和代码 FROM node:20.5.1-alpine3.18 as builder RUN apk update && apk --no-cache add build-base python3 rsync COPY . /mono-repo WORKDIR /mono-repo ARG NODE_APP_DIR RUN npm ci --cache .npm --prefer-offline -w "$NODE_APP_DIR" # 生成需要保留的路径列表:应用代码 + 所有依赖 RUN echo "$NODE_APP_DIR" > /keep-list.txt RUN npm ls -p -w "$NODE_APP_DIR" | sed 1d >> /keep-list.txt # 创建目标目录并复制需要的内容 RUN mkdir -p /app RUN rsync -av --files-from=/keep-list.txt /mono-repo/ /app/ # 解析内部软链接:将libs的实际内容复制到node_modules对应的位置 RUN find /app/node_modules -type l -name 'lib*' | while read link; do \ target=$(readlink "$link"); \ rm "$link"; \ cp -R "/mono-repo/$target" "$link"; \ done # Stage 2: 最终镜像 FROM node:20.5.1-alpine3.18 COPY --from=builder /app /app WORKDIR /app CMD ["npm", "start"]
方案3:使用pnpm替代npm(若可切换包管理器)
pnpm的monorepo支持更高效,默认创建硬链接而非软链接,且可通过pnpm deploy直接生成仅含生产依赖的目录:
# Stage 1: 部署应用 FROM node:20.5.1-alpine3.18 as builder RUN apk update && apk --no-cache add build-base python3 RUN npm install -g pnpm COPY . /mono-repo WORKDIR /mono-repo ARG NODE_APP_DIR RUN pnpm install --store=.pnpm-store --filter "$NODE_APP_DIR"... RUN pnpm deploy --filter "$NODE_APP_DIR" /app # Stage 2: 最终镜像 FROM node:20.5.1-alpine3.18 COPY --from=builder /app /app WORKDIR /app CMD ["node", "index.js"] # 根据实际启动文件调整
这些方案都能避免复制整个monorepo,确保镜像仅包含应用需要的内容,同时处理内部依赖的链接问题。
内容的提问来源于stack exchange,提问作者Forivin
相关产品推荐
相关产品推荐

