Skip to main content

Building Custom Container Images

Building custom container images provides a reproducible and isolated environment for applications, ensuring consistency across development, testing, and production. This process involves defining the image's contents and configuration, typically through a Dockerfile.

Fundamental Principles

Container Images and Layers

A container image is a lightweight, standalone, executable package of software that includes everything needed to run an application: code, runtime, system tools, system libraries, and settings. Images are constructed from a series of read-only layers. Each instruction in a Dockerfile creates a new layer. When an image changes, only the modified layers are rebuilt, optimizing storage and transfer.

The Build Context

The build context refers to the set of files and directories available to the container engine during the image build process. When executing a build command, the current directory (or a specified path) is typically sent as the build context. Only files within this context are accessible to instructions like COPY or ADD. Including only necessary files in the build context significantly improves build performance and reduces image size.

Dockerfile Structure

A Dockerfile is a text file containing a sequence of instructions that the container engine executes to build an image. Each instruction performs an operation, such as installing software, copying files, or setting environment variables.

A typical Dockerfile includes:

  • FROM: Specifies the base image.
  • WORKDIR: Sets the working directory for subsequent instructions.
  • COPY / ADD: Copies files from the build context into the image.
  • RUN: Executes commands during the build process.
  • ENV: Sets environment variables.
  • EXPOSE: Informs the container engine that the container listens on specified network ports at runtime.
  • CMD / ENTRYPOINT: Defines the default command or executable to run when a container starts from the image.

Constructing Images

Basic Build Process

To build an image, navigate to the directory containing the Dockerfile and execute the build command.

docker build -t my-app:1.0 .
  • -t my-app:1.0: Tags the image with a name (my-app) and a version (1.0).
  • .: Specifies the build context as the current directory.

Leveraging Build Arguments

Build arguments (ARG) allow passing values to the builder at build time. This enables dynamic configuration without modifying the Dockerfile itself.

Dockerfile:

FROM alpine:3.18
ARG BUILD_VERSION=1.0.0
RUN echo "Building version: $BUILD_VERSION" > /app/version.txt
CMD cat /app/version.txt

Build Command:

docker build --build-arg BUILD_VERSION=1.0.1 -t my-app:1.0.1 .

Build arguments are not persisted in the final image unless explicitly captured by an ENV instruction.

Managing Secrets During Builds

Directly embedding sensitive information (e.g., API keys, private SSH keys) into a Dockerfile or build arguments compromises image security. Utilize build secret mechanisms to provide secrets securely during the build process without baking them into the final image layers.

Using BuildKit's secret mount type:

Dockerfile:

# syntax=docker/dockerfile:1.4
FROM alpine:3.18
RUN --mount=type=secret,id=mysecret,target=/run/secrets/mysecret \
cat /run/secrets/mysecret > /app/secret_output.txt
# The secret_output.txt will contain the secret during build,
# but it will NOT be part of the final image layer.
# This example is for demonstration; typically, you'd use the secret
# to configure something and then delete any temporary files.

Build Command (requires BuildKit enabled):

DOCKER_BUILDKIT=1 docker build --secret id=mysecret,src=./mysecret.txt -t my-app-secure .

This approach ensures secrets are only available during the specific RUN instruction and are not cached or stored in the image layers.

Optimizing Image Size and Performance

Efficient image building reduces storage costs, accelerates deployments, and improves security.

Multi-Stage Builds

Multi-stage builds separate the build environment from the runtime environment. This significantly reduces the final image size by discarding build-time dependencies and artifacts.

Example: Building a Go application.

# Stage 1: Build the application
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/my-app

# Stage 2: Create the final, minimal image
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/my-app .
CMD ["./my-app"]

The final image only contains the compiled binary and its runtime dependencies, not the Go compiler or source code.

Minimizing Layers

Each RUN, COPY, and ADD instruction creates a new layer. Combining related commands into a single RUN instruction reduces the number of layers and improves build cache efficiency.

Inefficient:

RUN apt-get update
RUN apt-get install -y package1
RUN apt-get install -y package2

Efficient:

RUN apt-get update && \
apt-get install -y package1 package2 && \
rm -rf /var/lib/apt/lists/*

The rm -rf /var/lib/apt/lists/* command cleans up package manager caches, further reducing layer size.

Selecting Base Images

Choose the smallest possible base image that meets application requirements. Alpine Linux images are often preferred for their minimal footprint. For applications requiring specific libraries or environments, consider official slim variants (e.g., python:3.10-slim-buster).

Caching Strategies

The container engine caches layers during the build process. When an instruction changes, only that layer and subsequent layers are rebuilt. To maximize cache hits:

  • Place frequently changing instructions (e.g., COPY . . for application code) later in the Dockerfile.
  • Place less frequently changing instructions (e.g., FROM, RUN apt-get update) earlier.
  • For dependency management, copy package.json or go.mod/go.sum files separately before copying the entire source code to leverage caching for dependency installation.

Example (Node.js):

FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # Cache dependencies
RUN npm install
COPY . . # Application code changes frequently
CMD ["npm", "start"]

Ensuring Image Security

Secure images minimize the attack surface and protect sensitive data.

Least Privilege Principle

Run applications inside containers with the least necessary privileges.

  • Non-root user: Avoid running processes as root within the container. Create a dedicated non-root user and switch to it using the USER instruction.

    FROM alpine:3.18
    RUN adduser -D appuser
    USER appuser
    WORKDIR /app
    COPY --chown=appuser:appuser . .
    CMD ["./my-app"]
  • Minimal dependencies: Install only essential packages and libraries. Remove build tools and development dependencies from the final image using multi-stage builds.

Scanning and Vulnerability Management

Integrate image scanning tools into the CI/CD pipeline to identify known vulnerabilities in base images and installed packages. Regularly update base images to patch security flaws.

Integration with CI/CD Pipelines

Automating image builds and deployments is crucial for efficient software delivery.

Automated Builds

CI/CD systems (e.g., GitLab CI, GitHub Actions, Jenkins) can automatically trigger image builds upon code commits. The pipeline typically:

  1. Fetches the source code.
  2. Executes docker build (or equivalent).
  3. Tags the image with a version (e.g., commit SHA, semantic version).

Registry Push and Pull

After a successful build, the CI/CD pipeline pushes the tagged image to a container registry (e.g., Docker Hub, AWS ECR, Google Container Registry). Deployment tools then pull these images from the registry to deploy applications.

Common Use Cases

Application Deployment

The primary use case involves packaging web applications, microservices, or batch jobs into images for consistent deployment across various environments (development, staging, production).

Development Environments

Custom images can standardize development environments, ensuring all developers work with the same toolchains, dependencies, and configurations. This reduces "it works on my machine" issues.

Testing and QA

Container images provide isolated and reproducible environments for running automated tests (unit, integration, end-to-end). Each test run can use a fresh container instance, preventing state leakage between tests.

Important Considerations

Platform Compatibility

Ensure the base image and application binaries are compatible with the target runtime environment's architecture (e.g., amd64, arm64). Multi-architecture builds can create images compatible with different CPU architectures.

Build Tooling

While docker build is widely used, advanced build tools like BuildKit offer enhanced features:

  • Improved caching: More granular caching for individual instructions.
  • Parallel builds: Builds multiple stages or targets concurrently.
  • Build secrets: Securely handles sensitive information.
  • Output formats: Supports various output formats beyond just images.

Enable BuildKit by setting DOCKER_BUILDKIT=1 in the environment or by configuring the Docker daemon.

Reproducibility Challenges

Achieving truly reproducible builds (where the same Dockerfile and context always produce an identical image hash) can be challenging due to:

  • External dependencies: apt-get update, npm install, or pip install can fetch different versions over time. Pinning dependency versions (e.g., apt-get install -y package=1.2.3) and using lock files (package-lock.json, go.sum, requirements.txt) mitigates this.
  • Timestamps: File timestamps can affect layer hashes. BuildKit and other tools offer options to normalize timestamps.