10 best practices to containerize Node.js web applications with Docker

407 阅读4分钟

本文是 原文 的摘录,算是作为一个笔记。

对那些人可能有用:

  • your aim is to build a frontend application using server-side rendering (SSR) Node.js capabilities for React.
  • you’re looking for advice on how to properly build a Node.js Docker image for your microservices, running Fastify, NestJS or other application frameworks.

Download the cheatsheet here.

A simple Node.js Docker image build

Basic Dockerfile instructions for building Node.js Docker images:

FROM node
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Copy that to a file named Dockerfile, then build and run it.

docker build . -t nodejs-tutorial
docker run -p 3000:3000 nodejs-tutorial

It’s simple, and it works.

The only problem? It is full of mistakes and bad practices for building Node.js Docker images. Avoid the above by all means.

1. Use explicit and deterministic Docker base image tags

Avoid FROM node

  • Avoid FROM node:lts
  • Avoid FROM node:14-alpine

Instead of generic image aliases, use SHA256 hashes or specific image version tags for deterministic builds. For example:

  • FROM node:lts-alpine@sha256:5c4c0dd64aa
  • FROM node:14.2.0-alpine3.11

可以通过 docker pull 获取 node:lts-alpine 的 digest

docker pull node:lts-alpine
lts-alpine: Pulling from library/node
0a6724ff3fcd: Already exists
9383f33fa9f3: Already exists
b6ae88d676fe: Already exists
565e01e00588: Already exists
Digest: sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
Status: Downloaded newer image for node:lts-alpine
docker.io/library/node:lts-alpine

运行过上面的命令后,可以通过下面的命令列出所有 images 的 digest:

docker images --digests
REPOSITORY     TAG         DIGEST         IMAGE ID       CREATED             SIZE
node
lts-alpine
sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
51d926a5599d
2 weeks ago
116MB

所以上面的配置可以改为

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

2. Install only production dependencies in the Node.js Docker image

npm ci prevents surprises in a continuous integration (CI) flow because it halts if any deviations from the lockfile are made.

--only=production only install production dependencies in a deterministic way. 默认情况会下载所有的 dependencies,包括 devDependencies,有可能引入安全隐患。

RUN npm ci --only=production

将配置改为

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

3. Optimize Node.js tooling for production

When you build your Node.js Docker image for production, you want to ensure that all frameworks and libraries are using the optimal settings for performance and security.

This brings us to add the following Dockerfile directive:

ENV NODE_ENV production

At first glance, this looks redundant, since we already specified only production dependencies in thenpm install phase—so why is this necessary?

Developers mostly associate the NODE_ENV=production environment variable setting with the installation of production-related dependencies, however, this setting also has other effects which we need to be aware of.

Some frameworks and libraries may only turn on the optimized configuration that is suited to production if that NODE_ENV environment variable is set to production. Putting aside our opinion on whether this is a good or bad practice for frameworks to take, it is important to know this.

As an example, the Express documentation outlines the importance of setting this environment variable for enabling performance and security related optimizations:

The performance impact of the NODE_ENV variable could be very significant.

The kind folks at Dynatrace have put together a blog post which details the drastic effects of omitting NODE_ENV in your Express applications.

Many of the other libraries that you are relying on may also expect this variable to be set, so we should set this in our Dockerfile.

The updated Dockerfile should now read as follows with the NODE_ENV environment variable setting baked in:

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

4. Don’t run containers as root

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . /usr/src/app
RUN npm ci --only=production
USER node
CMD "npm" "start"

5. Properly handle events to safely terminate a Node.js Docker web application

Docker creates processes as PID 1, and they must inherently handle process signals to function properly. This is why you should avoid any of these variations:

  • CMD “npm” “start”
  • CMD [“yarn”, “start”]
  • CMD “node” “server.js”
  • CMD “start-app.sh”

Instead, use a lightweight init system, such as dumb-init, to properly spawn the Node.js runtime process with signals support:

CMD [“dumb-init”, “node”, “server.js”]

6. Graceful tear down for your Node.js web applications

Avoid an abrupt termination of a running Node.js application that halts live connections. Instead, use a process signal event handler:

async function closeGracefully(signal) {
   console.log(`*^!@4=> Received signal to terminate: ${signal}`)
 
   await fastify.close()
   // await db.close() if we have a db connection in this app
   // await other things we should cleanup nicely
   process.exit()
}
process.on('SIGINT', closeGracefully)
process.on('SIGTERM', closeGracefully)

7. Find and fix security vulnerabilities in your Node.js docker image

Remember how we discussed the importance of small Docker base images for our Node.js applications? Let’s put this test into practice.

I’m going to use the Snyk CLI to test our Docker image. You can sign up for a free Snyk account here.

npm install -g snyk
snyk auth
snyk container test node@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a --file=Dockerfile

8. Use multi-stage builds

Prevent sensitive information leak

比如私有 npm 源,需要 auth token,可以这么做:

ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   rm -rf .npmrc

在 build 时传入 token

docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234

你以为到此就做得很不错了,但是看看下面这个命令,token 被打出来了:

 docker history nodejs-tutorial
 
 IMAGE          CREATED              CREATED BY  
 ...
 <missing>      About a minute ago   RUN |1 NPM_TOKEN=1234 /bin/sh -c echo "//reg…   5.71MB    buildkit.dockerfile.v0

Introducing multi-stage builds for Node.js Docker images

# --------------> The build image
FROM node:latest AS build
ARG NPM_TOKEN
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production && \
   rm -f .npmrc
 
# --------------> The production image
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
RUN apk add dumb-init
ENV NODE_ENV production
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

As you can see, I chose a bigger image for the build stage because I might need tooling like gcc (the GNU Compiler Collection) to compile native npm packages, or for other needs.

In the second stage, there’s a special notation for the COPY directive that copies the node_modules/ folder from the build Docker image into this new production base image.

Also, now, do you see that NPM_TOKEN passed as build argument to the build intermediary Docker image? It’s not visible anymore in the docker history nodejs-tutorial command output because it doesn’t exist in our production docker image.

9. Keeping unnecessary files out of your Node.js Docker images

Use .dockerignore to ensure:

  • Local artifacts of node_modules/ aren’t copied into the container image.
  • sensitive files, such as .npmrc, .env or others, aren’t leaked into the container image.
  • a small Docker base image without redundant and unnecessary files.

Docker has a .dockerignore which will ensure it skips sending any glob pattern matches inside it to the Docker daemon.

.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore

10. Mounting secrets into the Docker build image

One thing to note about the .dockerignore file is that it is an all or nothing approach and can’t be turned on or off per build stages in a Docker multi-stage build.

Why is it important? Ideally, we would want to use the .npmrc file in the build stage, as we may need it because it includes a secret npm token to access private npm packages. Perhaps it also needs a specific proxy or registry configuration to pull packages from.

This means that it makes sense to have the .npmrc file available to the build stage—however, we don’t need it at all in the second stage for the production image, nor do we want it there as it may include sensitive information, like the secret npm token.

One way to mitigate this .dockerignore caveat is to mount a local file system that will be available for the build stage, but there’s a better way.

Docker supports a relatively new capability referred to as Docker secrets, and is a natural fit for the case we need with .npmrc. Here is how it works:

  • When we run the docker build command we will specify command line arguments that define a new secret ID and reference a file as the source of the secret.
  • In the Dockerfile, we will add flags to the RUN directive to install the production npm, which mounts the file referred by the secret ID into the target location—the local directory .npmrc file which is where we want it available.
  • The .npmrc file is mounted as a secret and is never copied into the Docker image.
  • Lastly, let’s not forget to add the .npmrc file to the contents of the .dockerignore file so it doesn’t make it into the image at all, for either the build nor production images.

Let’s see how all of it works together. First the updated .dockerignore file:

.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
.npmrc

Then, the complete Dockerfile, with the updated RUN directive to install npm packages while specifying the .npmrc mount point:

# --------------> The build image
FROM node:latest AS build
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN --mount=type=secret,mode=0644,id=npmrc,target=/usr/src/app/.npmrc npm ci --only=production
 
# --------------> The production image
FROM node:lts-alpine
RUN apk add dumb-init
ENV NODE_ENV production
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

And finally, the command that builds the Node.js Docker image:

$ docker build . -t nodejs-tutorial --secret id=npmrc,src=.npmrc

Note:  Secrets are a new feature in Docker and if you’re using an older version, you might need to enable it Buildkit as follows:

$ DOCKER_BUILDKIT=1 docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234 --secret id=npmrc,src=.npmrc