本文是 原文 的摘录,算是作为一个笔记。
对那些人可能有用:
- 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.
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