Tips to reduce size of docker images

I have started looking into building my own images to deploy a Meteor app. I started with meteor-docker/image at 467ec350e3a4d6ba93987b8d14eaa8cc79959a67 · zodern/meteor-docker · GitHub and gradually customized it.

One thing I am not really content with is the time it takes to build these images and the size of the resulting image. So here are a few questions:

  • Has anyone been successful in building a Meteor app on top of something lighter than debian:bullseye-slim (meteor-docker/image/Dockerfile at 467ec350e3a4d6ba93987b8d14eaa8cc79959a67 · zodern/meteor-docker · GitHub)? Is it at all possible given this is already a slim image?
  • The final image is 1GB, and the intermediate “build” image is 4GB. It seems the intermediate “build” image has roughly 2 copies of the node_modules dependencies in src/node_modules (meteor npm clean-install), and bundle/programs/server/npm/node_modules (cd bundle/programs/server && meteor npm install), and the .meteor folder is gigantic (~1.6GB of packages and 400MB of package metadata). Is there anything one can do about this?
  • Is there a way to avoid installing dev dependencies that are not required for building (mocha, puppeteer, etc.)?
1 Like

I wonder if all the packages are really necessary.

Creating your own docker images for your deployment experience is a great gain on your setup, offering reusability and environment replication in a single time investment.

From the details provided, it seems you’ve customized the zodern/meteor image to include your app’s source code, build processes, dev dependencies and caches (e.g .meteor/local). This approach can result in larger image sizes due to bundling everything together. I recommend the following:

In my experience, ensuring lightweight Docker images involves splitting them into two categories: development and production.

  • Development image: Contains your git repository, dev dependencies, scripts, QA, testing, and populated caches. It aims to facilitate fast verification processes and app runs, especially useful for CI or staging environments. This image may be relatively large.
  • Production image: Includes only compiled code and necessary dependencies for running the app in production. A good example is the zodern/meteor. Its primary goal is to remain lightweight.

Once you have these images, utilize Docker’s multi-stage images feature to build your production image. This approach involves preparing the build in a heavier environment and then copying the artifacts into the smallest possible image for running the app. In the first stage, use the development image to build the app bundle from source code, and in the second stage, copy the bundle into the lightweight production environment.

Here’s a illustration of the approach I use in my projects:

This approach ensures the final image remains lightweight without unnecessary development artifacts. Additionally, consider further optimizations such as running the app server node_module preparation (cd /built_app/programs/server && npm install) and applying post-cleaning steps like modclean or node-prune during the development stage, then copying the cleaned artifacts into the production image.

I hope this helps and addresses your concerns.

4 Likes

Adding something to help.

I’m using Gitlab CI to do my build and my final images are around 200MB in Gitlab Container Registry. I’m using Node oficial images to run my apps.

See my configurations.

Dockerfile

FROM node:14.21.3-alpine
LABEL maintainer="SBB"

RUN apk --no-cache add \
  bash \
  g++ \
  make \
  python3

ENV METEOR_DISABLE_OPTIMISTIC_CACHING=1
COPY . /bundle
RUN (cd /bundle/programs/server && npm install)
RUN chown -R node /bundle/programs/server/assets
USER node

CMD node /bundle/main.js

and to build it in Gitlab these config:

stages:
  - build
  - package
  - deploy

meteor-app:
  stage: build
  image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/geoffreybooth/meteor-base:2.14
  script:
    - export NODE_ENV=production
    - meteor npm ci
    - meteor build --allow-superuser --directory build/ --architecture os.linux.x86_64
    - cp Dockerfile build/bundle/
    - tar cfz app.tar.gz build/
  artifacts:
    paths:
      - app.tar.gz
  environment:
    name: staging
  only:
    - tags

docker-build:
  stage: package
  image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:24.0.5
  services:
    - docker:24.0.5-dind
  before_script:
    - docker info
    - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.gitlab.com
  script:
    - tar xzf app.tar.gz
    - cd build/bundle/
    - docker build -t registry.gitlab.com/myapp/app:$CI_COMMIT_TAG .
    - docker push registry.gitlab.com/myapp/app:$CI_COMMIT_TAG
  environment:
    name: staging
  only:
    - tags

deploy-staging:
  stage: deploy
  image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:24.0.5
  services:
    - docker:24.0.5-dind
  before_script:
    - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.gitlab.com
  script:
    - docker pull registry.gitlab.com/myapp/app:$CI_COMMIT_TAG
    - docker tag registry.gitlab.com/myapp/app:$CI_COMMIT_TAG registry.gitlab.com/sbbsolutions/myapp/app:staging
    - docker push registry.gitlab.com/myapp/app:staging
    - apk add --update curl
    - curl -XPOST https://webhook_to_deploy_new_version
  environment:
    name: staging
  rules:
    - if: '$CI_COMMIT_TAG =~ /^[\d]+\.[\d]+\.[\d]+-test$/'
      when: always
    - when: never

If you don’t use a CI or don’t use Gitlab CI, you can just get the commands from script section.

@raragao Side question: ave you noticed any problem using this flag to build?

@raragao I am on Meteor 2.13.3 which points to Node 14.21.4 which, as I understood, is not anywhere to be found except at https://static.meteor.com/dev-bundle-node-os/v14.21.4/

@jkuester

Largest entries in final image

$ du -h | sort -hr | head -15
1.2G	./dist/programs
1.2G	./dist
1.2G	.
1.1G	./dist/programs/server/npm/node_modules
1.1G	./dist/programs/server/npm
1.1G	./dist/programs/server
211M	./dist/programs/server/npm/node_modules/meteor
194M	./dist/programs/server/npm/node_modules/@mui
170M	./dist/programs/server/npm/node_modules/@img
166M	./dist/programs/server/npm/node_modules/canvas/build/Release
166M	./dist/programs/server/npm/node_modules/canvas/build
166M	./dist/programs/server/npm/node_modules/canvas
131M	./dist/programs/server/npm/node_modules/@mui/icons-material
98M	./dist/programs/server/npm/node_modules/meteor/babel-compiler/node_modules
98M	./dist/programs/server/npm/node_modules/meteor/babel-compiler

Largest files in final image

$ find . -type f -print0 | du --files0-from=- -h | sort -hr | head -15
101M	./dist/programs/server/npm/node_modules/canvas/build/Release/librsvg-2.so.2
72M	./node/bin/node
20M	./dist/programs/server/npm/node_modules/canvas/build/Release/libharfbuzz.so.0
18M	./dist/programs/server/npm/node_modules/@img/sharp-win32-x64/lib/libvips-42.dll
18M	./dist/programs/server/npm/node_modules/@img/sharp-win32-ia32/lib/libvips-42.dll
18M	./dist/programs/server/npm/node_modules/@img/sharp-libvips-darwin-x64/lib/libvips-cpp.42.dylib
16M	./dist/programs/server/npm/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/libvips-cpp.so.42
16M	./dist/programs/server/npm/node_modules/@img/sharp-libvips-linuxmusl-arm64/lib/libvips-cpp.so.42
16M	./dist/programs/server/npm/node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.42
16M	./dist/programs/server/npm/node_modules/@img/sharp-libvips-linux-arm64/lib/libvips-cpp.so.42
15M	./dist/programs/server/npm/node_modules/@img/sharp-libvips-linux-s390x/lib/libvips-cpp.so.42
15M	./dist/programs/server/npm/node_modules/@img/sharp-libvips-darwin-arm64/lib/libvips-cpp.42.dylib
13M	./dist/programs/server/npm/node_modules/@img/sharp-libvips-linux-arm/lib/libvips-cpp.so.42
12M	./dist/programs/server/npm/node_modules/meteor/babel-compiler/node_modules/typescript/lib/tsserverlibrary.js
12M	./dist/programs/server/npm/node_modules/meteor/babel-compiler/node_modules/typescript/lib/tsserver.js

Largest entries in build step

$ du -h | sort -hr | head -15
4.7G	.
2.0G	./.meteor
1.6G	./.meteor/packages
1.4G	./src
1.2G	./src/node_modules
1.2G	./dist/bundle/programs
1.2G	./dist/bundle
1.2G	./dist
1.1G	./dist/bundle/programs/server/npm/node_modules
1.1G	./dist/bundle/programs/server/npm
1.1G	./dist/bundle/programs/server
627M	./.meteor/packages/meteor-tool/.2.13.3.ntl153.rfqwm++os.linux.x86_64+web.browser+web.browser.legacy+web.cordova/mt-os.linux.x86_64
627M	./.meteor/packages/meteor-tool/.2.13.3.ntl153.rfqwm++os.linux.x86_64+web.browser+web.browser.legacy+web.cordova
627M	./.meteor/packages/meteor-tool
467M	./.meteor/packages/meteor-tool/.2.13.3.ntl153.rfqwm++os.linux.x86_64+web.browser+web.browser.legacy+web.cordova/mt-os.linux.x86_64/dev_bundle

Largest files in build step

$ find . -type f -print0 | du --files0-from=- -h | sort -hr | head -15
396M	./.meteor/package-metadata/v2.0.1/packages.data.db
101M	./src/node_modules/canvas/build/Release/librsvg-2.so.2
101M	./dist/bundle/programs/server/npm/node_modules/canvas/build/Release/librsvg-2.so.2
99M	./.meteor/packages/meteor-tool/.2.13.3.ntl153.rfqwm++os.linux.x86_64+web.browser+web.browser.legacy+web.cordova/mt-os.linux.x86_64/dev_bundle/mongodb/bin/mongod
73M	./.meteor/packages/meteor-tool/.2.13.3.ntl153.rfqwm++os.linux.x86_64+web.browser+web.browser.legacy+web.cordova/mt-os.linux.x86_64/dev_bundle/mongodb/bin/mongos
72M	./node/bin/node
72M	./.meteor/packages/meteor-tool/.2.13.3.ntl153.rfqwm++os.linux.x86_64+web.browser+web.browser.legacy+web.cordova/mt-os.linux.x86_64/dev_bundle/bin/node
20M	./src/node_modules/canvas/build/Release/libharfbuzz.so.0
20M	./dist/bundle/programs/server/npm/node_modules/canvas/build/Release/libharfbuzz.so.0
18M	./src/node_modules/@img/sharp-win32-x64/lib/libvips-42.dll
18M	./src/node_modules/@img/sharp-win32-ia32/lib/libvips-42.dll
18M	./src/node_modules/@img/sharp-libvips-darwin-x64/lib/libvips-cpp.42.dylib
18M	./dist/bundle/programs/server/npm/node_modules/@img/sharp-win32-x64/lib/libvips-42.dll
18M	./dist/bundle/programs/server/npm/node_modules/@img/sharp-win32-ia32/lib/libvips-42.dll
18M	./dist/bundle/programs/server/npm/node_modules/@img/sharp-libvips-darwin-x64/lib/libvips-cpp.42.dylib

@nachocodoner I build everything from source with docker build. .dockerignore contains .meteor/local.

@nachocodoner I have implemented a two-stage build process: first install meteor, install dependencies with meteor npm install, build from sources, run meteor npm install inside the bundle and fetch the necessary node binary, then copy the bundle and the node binary to a new image, configure $PATH, and conclude with CMD ['node', 'dist/main.js'].

@nachocodoner I can try something like that, but I will definitely not rely on those abandoned packacges directly.

Yes, same case here and I have used node:14.21.3-alpine with no problems

No problems using it, but if you remove it, you need to do more steps about permissions in your Dockerfile.

@superfail , the big point here is to use different images and container to each step of the build/deploy process. So, in the end, I’m using a small image of node to run my app. No Meteor installed there.

@raragao I will try to use COPY --chown=... instead.

@raragao I am going to try to find a different base image for the final image. Meteor claims that the only dependency of the bundle is the Node binary with the version which matches meteor node --version. Is this completely true?

I think so. But I didn’t understand why you are trying to find a different base image to final image. That one is the official NODE image and has the smallest size. FROM node:14.21.3-alpine

Anyway, good look.

@raragao Because there is a reason for 14.21.4 to exist, i.e. “to incorporate security updates”.

I ended up building the final image on top of a distroless image.

Okay, but if you are worried about security, you have two options:

  1. Use this 14.21.3 image version and update the node version with commands in your Dockerfile;
  2. Migrate your app to version 3, that has node version updated (I know is not ready yet, but it is very close in my point of view).

UPDATED

One more option:

@superfail , I changed my Dockerfile to this:

FROM meteor/node:14.21.4-alpine3.17
LABEL maintainer="SBB"

ENV METEOR_DISABLE_OPTIMISTIC_CACHING=1
COPY . /bundle
RUN (cd /bundle/programs/server && npm install)

CMD node /bundle/main.js

The image size is almost the same of the previous version.

Thanks. I am installing the correct node version as follows:

# Build step

RUN curl "https://static.meteor.com/dev-bundle-node-os/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz" | \
  tar -C "/home/app/node" -xzf - "node-v${NODE_VERSION}-linux-x64" --strip-components=1

# Final image
COPY --from=build --chown=app:app /home/app/node/bin /home/app/node/bin

ENV PATH="/home/app/node/bin"

BTW, there does not seem to be a way to check the SHASUMS, the same way it can be done with official Node releases.

Does anybody know if the following Meteor npm packages are required in production:

bundle/programs/server/npm/node_modules/meteor/babel-compiler
bundle/programs/server/npm/node_modules/meteor/minifier-css
bundle/programs/server/npm/node_modules/meteor/boilerplate-generator
bundle/programs/server/npm/node_modules/meteor/react-fast-refresh

Together they take up ~92MB of layer space. Why do they end up in the production build anyway?

I realize that this app depends on sharp, canvas, pdfjs, and @mui which together already take half of the layer space. So maybe illusory to think one could improve things further.