Engineering

Migrating a Node.js application to Chainguard Images

Adrian Mouat, Staff DevRel Engineer
June 5, 2024
copied

This blog demonstrates how to build a minimal and secure containerized Node.js application. We'll walk through porting a Node.js application from the Node official image to the Chainguard Node Image (also available on the Docker Hub). We'll review how to work through a few common mistakes and end up with fewer CVEs and a smaller container. Although the focus here is on Node.js, the same principles apply to migrating any applications to Chainguard Images.

Note that this blog is excerpted from a larger tutorial which ports a multi-container example application to use Chainguard Images

The image we are porting is dnmonster. The dnmonster container hosts an API which returns an identicon based on the input it's given, which we’ll demonstrate below.

-- CODE language-bash -- docker run -d -p 8080:8080 amouat/dnmonster curl --output ./monster.png localhost:8080/monster/wolfi?size=100

In this example, we give dnmonster the input "wolfi," for which it will produce the following image:

Image of identicon produced by dnmonster with the input "wolfi."

The first thing I had to do was update the dependencies so everything compiled. I then moved the application from the older restify module to the more modern express module. The code at this point can be found on the v1 branch of the identidock-cg GitHub repository.

At this stage we have the following Dockerfile:

-- CODE language-bash -- FROM node RUN apt-get update && apt-get install -yy --no-install-recommends \ libcairo2-dev libjpeg62-turbo-dev libpango1.0-dev libgif-dev \ librsvg2-dev build-essential g++ #Create non-root user RUN groupadd -r dnmonster && useradd -r -g dnmonster dnmonster RUN install -d -o dnmonster -g dnmonster /home/dnmonster RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY package.json /usr/src/app/ RUN npm install COPY ./src /usr/src/app RUN chown -R dnmonster:dnmonster /usr/src/app USER dnmonster EXPOSE 8080 CMD [ "npm", "start" ] You can clone and build the image with: git clone https://github.com/chainguard-dev/identidock-cg.git cd identidock-cg git switch v1 cd dnmonster docker build --pull -t dnmonster .

Building this Dockerfile results in a Dockerfile that (at the time of writing) is 1.22 GB in size and has 114 known vulnerabilities according to Docker Scout.

The first step in moving to Chainguard Images is to try switching the image name in to check if anything breaks. In this case, we’ll begin with the developer variant of the Node image. This means changing the first line of the Dockerfile from:

-- CODE language-bash -- FROM node

To:

-- CODE language-bash -- FROM cgr.dev/chainguard/node:latest-dev

Unlike the cgr.dev/chainguard/node:latest image, the :latest-dev version includes a shell and package manager, which we will need for some of the build steps. In general, it’s better to use the more minimal :latest version where possible in order to keep the size down and reduce the tooling available to attackers. Often the :latest-dev image can be used as a build step in a multi-stage, with a more minimal image such as :latest used in the final production image.

If you try building this image, you’ll find that it breaks in several places. The image needs to install various libraries so that it can compile the node-canvas dependency, and this looks a bit different in Debian than it does in Wolfi (the OS powering Chainguard Images).

In Wolfi, we first need to switch to the root user to install software and we use apk add instead of apt-get. We then need to figure out the Wolfi equivalents of the various Debian packages, which may not always have a one-to-one correspondence. There are tools to help here — you can consult our migration guides and use apk tools (like apk search libjpeg), but searching the Wolfi GitHub repository for package names will often provide you with what you’re looking for.

The start of the Dockerfile looks like this after making the changes:

-- CODE language-bash -- FROM cgr.dev/chainguard/node:latest-dev USER root RUN apk update && apk add \ cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \ librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev

The next change we need to make is to the RUN groupadd … line. Chainguard Images use BusyBox by default, which means groupadd needs to become addgroup. Rewrite the line so that it looks like this:

-- CODE language-bash -- RUN addgroup dnmonster && adduser -D -G dnmonster dnmonster RUN install -d -o dnmonster -g dnmonster /home/dnmonster

Finally, the default entrypoint for the Chainguard Image is /usr/bin/node. If we leave the CMD as it is, it will be interpreted as an argument to node, which isn’t what we want. The Docker official image uses an entrypoint script to interpret commands, but this can’t be done in the cgr.dev/chainguard/node:latest image due to the lack of a shell and we want the :latest-dev entrypoint to match. The easiest fix is to change the CMD command to ENTRYPOINT which will override the /usr/bin/node command:

-- CODE language-bash -- ENTRYPOINT [ "npm", "start" ]

Once you’ve made all these changes, you should have a Dockerfile that looks like:

-- CODE language-bash -- FROM cgr.dev/chainguard/node:latest-dev USER root RUN apk update && apk add \ cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \ librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev #Create non-root user RUN addgroup dnmonster && adduser -D -G dnmonster dnmonster RUN install -d -o dnmonster -g dnmonster /home/dnmonster RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY package.json /usr/src/app/ RUN npm install COPY ./src /usr/src/app RUN chown -R dnmonster:dnmonster /usr/src/app USER dnmonster EXPOSE 8080 ENTRYPOINT [ "npm", "start" ]

At this point, we have a version of dnmonster that works and is equivalent to the previous version. We can build this image:

-- CODE language-bash -- docker build --pull -t dnmonster-cg . …

If we look at the size and vulnerability count:

-- CODE language-bash -- $ docker images dnmonster-cg REPOSITORY TAG IMAGE ID CREATED SIZE dnmonster-cg latest c50ad3559edc About a minute ago 932MB $ docker scout cves dnmonster-cg ✓ SBOM of image already cached, 463 packages indexed ✓ No vulnerable package detected ## Overview │ Analyzed Image ────────────────────┼────────────────────────────── Target │ dnmonster-cg:latest digest │ c50ad3559edc platform │ linux/arm64 vulnerabilities │ 0C 0H 0M 0L size │ 326 MB packages │ 463 ## Packages and Vulnerabilities No vulnerable packages detected

So the image is significantly smaller at 932MB, but more importantly we've eliminated all 114 vulnerabilities.

But we can still do more. In particular, although 932MB is significantly smaller than the previous version, it's still a large image. To get the size down, we can use a multi-stage build where the built assets are copied into a minimal production image, which doesn't include build tooling or dependencies required only during development.

Ideally, we would use the cgr.dev/chainguard/node:latest image for this, but we also need to install the dependencies for node-canvas , which means we need an image with apk tools. Normally, I'd use a latest-dev image for this, but in node's case, the latest-dev image is pretty large due to the inclusion of build tooling such as C compilers that can be required by node modules. Instead, we're going to use the wolfi-base image and install nodejs as a package.

To do this replace the Dockerfile with the following:

-- CODE language-bash -- FROM cgr.dev/chainguard/node:latest-dev as build USER root RUN apk update && apk add \ cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \ librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev #Create non-root user RUN addgroup dnmonster && adduser -D -G dnmonster dnmonster RUN install -d -o dnmonster -g dnmonster /home/dnmonster RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY package.json /usr/src/app/ RUN npm install COPY ./src /usr/src/app RUN chown -R dnmonster:dnmonster /usr/src/app USER dnmonster EXPOSE 8080 ENTRYPOINT [ "npm", "start" ] FROM cgr.dev/chainguard/wolfi-base RUN apk update && apk add nodejs \ cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \ librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev WORKDIR /app COPY --from=build /usr/src/app /app EXPOSE 8080 ENTRYPOINT [ "node", "server.js" ]

We’ve added an as build statement to the first FROM line and added a second build that starts on the line FROM cgr.dev/chainguard/wolfi-base. The second build installs the required dependencies (including Node.js) before copying the build artifacts from the first image. We also changed the entrypoint to execute Node directly, as the image no longer contains npm.

Build and investigate the image:

-- CODE language-bash -- ❯ docker build --pull -t dnmonster-multi . … ❯ docker images dnmonster-multi REPOSITORY TAG IMAGE ID CREATED SIZE dnmonster-multi latest e339f4ee2274 4 minutes ago 345MB ❯ docker scout cves dnmonster-multi ✓ Image stored for indexing ✓ Indexed 263 packages ✓ No vulnerable package detected ## Overview │ Analyzed Image ────────────────────┼────────────────────────────── Target │ dnmonster-multi:latest digest │ e339f4ee2274 platform │ linux/arm64 vulnerabilities │ 0C 0H 0M 0L size │ 122 MB packages │ 263 ## Packages and Vulnerabilities No vulnerable packages detected

This results in an image that is now only 345MB in size and still has zero CVEs.

We're most of the way now, but there are still a couple of finishing touches to make. The first one is to remove the dnmonster user. The wolfi-base image defines a nonroot user, so we can make the build a little less complicated by using that user directly. The second one is to add in a process manager. We have node running as the root process (PID 1) in the container, which isn't ideal as it doesn't handle some of the responsibilities that come with running as PID 1, such as forwarding signals to subprocesses. You can see this most clearly when you try to stop the image — it takes several seconds as the process doesn’t respond to the SIGTERM signal sent by Docker and has to be hard killed with SIGKILL. To fix this, we can add tini, a small init for containers.

The tini binary will run as PID 1, launch npm as a subprocess and take care of PID 1 responsibilities.

The final Dockerfile looks like this:

-- CODE language-bash -- FROM cgr.dev/chainguard/node:latest-dev as build USER root RUN apk update && apk add \ tini cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \ librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev RUN mkdir -p /usr/src/app WORKDIR /usr/src/app ENV NODE_ENV production COPY package.json /usr/src/app/ RUN npm install COPY ./src /usr/src/app FROM cgr.dev/chainguard/wolfi-base RUN apk update && apk add tini nodejs \ cairo-dev libjpeg-turbo-dev pango-dev giflib-dev \ librsvg-dev glib-dev harfbuzz-dev fribidi-dev expat-dev libxft-dev WORKDIR /app COPY --from=build /usr/src/app /app EXPOSE 8080 ENTRYPOINT ["tini", "--" ] CMD [ "node", "server.js" ]

This version is also available in the main branch of the repository.

Build it:

-- CODE language-bash -- docker build --pull -t dnmonster-final . …

And run it to prove it still works:

-- CODE language-bash -- docker run -d -p 8080:8080 dnmonster-final ... curl --output ./monster.png 'localhost:8080/monster/wolfi?size=100'

There are still more tweaks that could be made, but for the purposes of this blog, we've made excellent progress. For more Node.js tips, Bret Fisher has some excellent resources on building Node.js containers in this github repo.

Conclusion

We've taken an old Node.js application that had a relatively large image and ported it over to Chainguard Images. Along the way, we've seen how to deal with common issues around migrating to minimal containers and ended up with an excellent result — a small image with zero CVEs. If you want to get started migrating your own application, check out our migration guides and top tips.

Related articles

Ready to lock down your supply chain?

Talk to our customer obsessed, community-driven team.