Marc Wäckerlin
Für eine libertäre Gesellschaft

Image from Dockerfile

September 25, 2024

Views: 15

After the Docker Container Introduction, the next step is to deploy a sample project in a Docker container. For this, we create a basic NestJS service with a simple GET / route that returns Hello World!.

Simple NestJS Project

First download and install Node.js and npm, they also install the nice little tool npx, then create a project test-nestjs-app by running:

npx @nestjs/cli new test-nestjs-app --package-manager npm

Inside the project directory, build and start it:

cd test-nestjs-app
npm i
npm start

Then load: http://localhost:3000/ to get Hello World!

Now let’s deploy this in a Docker container!

Dockerfile

Create a file named Dockerfile in the project path, with multi-stage builds:

# Stage 1: Build the application
FROM mwaeckerlin/nodejs-build AS build
COPY --chown=${BUILD_USER} . .
RUN npm install
RUN npm run build
ENV NODE_ENV 'production'
RUN rm -rf node_modules
RUN npm install

# Stage 2: Production
FROM mwaeckerlin/nodejs AS production
COPY --from=build /app/dist /app/dist
COPY --from=build /app/node_modules /app/node_modules
EXPOSE 3000
CMD ["/usr/bin/node", "/app/dist/main"]

ENV NODE_ENV 'production' – This line sets the environment variable NODE_ENV to production. This is crucial for Node.js applications as it tells the app to run in production mode. Production mode typically means optimizations such as better performance, less verbose logging, and more efficient memory usage. It also prevents development dependencies from being installed or used, which could otherwise bloat the container and introduce security risks.

RUN rm -rf node_modules – This command deletes the entire node_modules folder. In the build stage, both production and development dependencies are installed, but for the final production image, only the production dependencies are necessary. Removing node_modules ensures that all dependencies will be reinstalled correctly with the appropriate production flags, minimizing the final image size.

RUN npm install – This command is used to install only the production dependencies after the rm -rf node_modules command has cleared out the old installation. Since NODE_ENV is set to production, this command will skip the development dependencies, ensuring that the final image only contains what’s necessary for running the application in production, thus making the image leaner and more secure.

This Dockerfile first builds the application in a separate stage using mwaeckerlin/nodejs-build. The final production image uses the smaller and more secure mwaeckerlin/nodejs image, which contains only Node.js without unnecessary shell access, reducing the attack surface.

Simple Build

You can test your Dockerfile by building the image:

docker build -t hello-world .

This executes all steps in your Dockerfile and end with:

Successfully built 7832a2178747
Successfully tagged hello-world:latest

Where the random number is the name of your image generated by Docker. With option -t hello-world, you created an image name hello-world, with no repository and default tag name latest.

Run in Docker

Now you may run your image in a new container:

docker run --name hello-world --init -it --rm -p 5000:3000 hello-world

Option --rm removes the container when it is stopped and options --init -it allow you to stop with ctrl+c. Option -p 5000:3000 maps port 5000 of localhost to port 3000 of your application, so you can access the app in your image with http://localhost:5000 in your browser.

Security Rules

Creating secure and minimal Docker images is crucial for production environments. By optimizing the base images and eliminating unnecessary components, you can reduce the attack surface while keeping the image size small. In this section, we will explore how to create images that are smaller than Google’s Distroless images, using examples from mwaeckerlin/nodejs, mwaeckerlin/nginx, and mwaeckerlin/python. Additionally, we will discuss the benefits of using mwaeckerlin/scratch as an improved empty base image.

mwaeckerlin/nodejs

The mwaeckerlin/nodejs image is designed to be as minimal as possible, containing only Node.js and its shared libraries. This image excludes unnecessary components like a shell or package manager, which significantly reduces its size. In fact, it is even smaller than the popular gcr.io/distroless/nodejs image:

  • Google Distroless: ~164MB
  • mwaeckerlin/nodejs: ~56MB

By focusing on including only the essential dependencies, the mwaeckerlin/nodejs image reduces both the size and potential attack surface, making it ideal for production environments. The default CMD for running a Node.js app can be set to match the build output (e.g., CMD ["/usr/bin/node", "/app/dist/main"]).

mwaeckerlin/nginx

Similarly, the mwaeckerlin/nginx image is a minimal Nginx web server build, stripped of unnecessary modules and components. This reduces the size and complexity of the image, while still providing a full Nginx server for production use. By removing unused modules and configurations, the image is kept small, but still fully functional.

mwaeckerlin/python

The mwaeckerlin/python image is another example of a highly optimized and minimal base. It includes only the Python runtime and its dependencies, without additional utilities or shell access. This image is significantly smaller than typical Python Docker images, making it ideal for lightweight Python-based microservices.

Using mwaeckerlin/scratch

mwaeckerlin/scratch is an improved version of the scratch base image, which is often used as the foundation for minimal containers. Unlike the standard scratch image, mwaeckerlin/scratch includes a pre-configured non-root user and essential environment variables that help ensure best practices are followed in container security.

The inclusion of a non-root user is important for security, as it prevents containers from running with elevated privileges, reducing the risk of container breakout attacks. Additionally, environment variables can be predefined to ensure consistency and security across various environments.

Security Best Practices

  • Run as Non-Root: Ensure all processes in your container run as a non-root user, as done by default in mwaeckerlin/scratch.
  • Minimal Dependencies: Avoid unnecessary dependencies, keeping your image small and reducing the attack surface.
  • No Shell Access: By removing shell access in images like mwaeckerlin/nodejs and mwaeckerlin/nginx, you significantly lower the risk of privilege escalation.
  • Multi-Stage Builds: Use multi-stage builds to separate build-time dependencies from the final production image.

By following these practices and using optimized base images like mwaeckerlin/scratch, mwaeckerlin/nodejs, and mwaeckerlin/nginx, you can create secure, minimal Docker images for your applications.

Creating a Minimal Nginx Docker Image with Dependencies

The following code efficiently creates a minimal Nginx Docker image by packaging only the necessary files and libraries required to run Nginx. The goal is to reduce the image size while keeping it secure.

RUN tar cph \
    /app /etc/nginx /usr/lib/nginx/modules /var/lib/nginx \
    /run/nginx /var/log/nginx \
    $(which nginx) \
    $(for f in $(which nginx) /usr/lib/nginx/modules/*; do \
    ldd $f | sed -n 's,.* => \([^ ]*\) .*,\1,p'; \
    done 2> /dev/null) 2> /dev/null \
    | tar xpC /root/

tar Command: The Tape Archive tar compresses and transfers only the relevant files from the Nginx installation, such as:

  • Nginx Binary: The executable ($(which nginx)) that runs Nginx.
  • Nginx Modules: Libraries used by Nginx (/usr/lib/nginx/modules).
  • Configs & Logs: Configuration files (/etc/nginx) and runtime directories (/run/nginx, /var/lib/nginx).

Dependency Extraction: The ldd command is used to extract all dynamic library dependencies required by Nginx and its modules. Nginx dynamically loads libraries at runtime. The ldd command shows which libraries are needed by the binary and modules, so they can be included in the final image.

For example, ldd $(which nginx) lists all shared libraries (like libc.so, libpcre.so) necessary for the binary to function.

The extracted list is piped into tar, which copies only those required libraries into the final image, ensuring the container has everything it needs to run, without any excess.

Benefits of this Approach

  • Minimalism: The image is built using the scratch base, which starts completely empty. This ensures that only what is explicitly copied via tar ends up in the final image.
  • No Shell or Extra Tools: By avoiding common image components like shells or package managers, the attack surface is reduced, making the image more secure.
  • Runtime User: The image runs as a non-root user, further enhancing security by limiting permissions.

Result

The final Nginx image is highly optimized, lightweight, and secure, containing only the necessary binaries, libraries, and configuration files to run the Nginx server. This reduces not only the image size but also the potential for vulnerabilities, making it safer for production use.

comments title