Bloated Container Images — A Common Problem Nobody Talks About
There’s a pattern I see all the time: a developer finishes their app, builds a Docker image from ubuntu:22.04, and ends up with a 400–500MB image just to run a 50KB Python binary. When I was new to containers, this didn’t seem like a problem — “if it runs, it works,” right? But after a CVE scanner kept firing red alerts over a pile of unused packages sitting in the image, I finally understood why “slim” and “distroless” get mentioned so often.
The core problem is this: when you install a package like libssl3, you don’t just get the SSL library — you also get man pages, docs, locale files, and headers. These take up space and, more importantly, they become potential attack surface whenever a security vulnerability surfaces.
What Is Chisel and How Does It Differ from Alpine or Distroless?
Chisel is a tool developed by Canonical that lets you “slice” Ubuntu packages — taking only the files your application actually needs and discarding everything else. The underlying concept is called package slicing.
Unlike Alpine (which uses musl libc and can cause incompatibility issues with apps compiled for glibc) or Distroless (fixed, hard to customize), Chisel gives you:
- Use genuine Ubuntu packages — no glibc incompatibility concerns
- Choose exactly which slices you need: runtime files only, no docs, no headers
- Combine with Docker multi-stage builds to produce ultra-small images
Each package in Chisel is defined by “slices” — subgroups of files within a package. For example, the libssl3 package may have a libssl3_libs slice (containing only .so files) and a libssl3_dev slice (containing headers for development). The slice syntax is package_slicename.
Installing Chisel on Ubuntu
Chisel is a Go binary that installs quickly without complex dependencies:
# Download the latest binary
CHISEL_VERSION=$(curl -s https://api.github.com/repos/canonical/chisel/releases/latest | grep tag_name | cut -d'"' -f4)
curl -sSL "https://github.com/canonical/chisel/releases/download/${CHISEL_VERSION}/chisel_${CHISEL_VERSION}_linux_amd64.tar.gz" | tar xz
sudo mv chisel /usr/local/bin/
# Verify installation
chisel --version
Or install via snap if you’re running Ubuntu desktop/server:
sudo snap install chisel --channel=latest/stable
To view the available slices for a package:
# List slices for libssl3
chisel info --release ubuntu-22.04 libssl3
# List slices for python3.10
chisel info --release ubuntu-22.04 python3.10
Hands-On: Building a Minimal Image for a Python Application
Let’s use a real-world example — a simple Flask API — and directly compare two build approaches.
The Conventional Approach (ubuntu:22.04 base)
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3 python3-pip && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip3 install -r requirements.txt
COPY app.py .
CMD ["python3", "app.py"]
Result: a ~250MB image, along with hundreds of packages completely unrelated to Flask.
Using Chisel with Multi-Stage Builds
Chisel works best when paired with Docker multi-stage builds — the first stage handles slicing, and the final stage only copies what’s needed:
# Stage 1: Build a minimal rootfs with Chisel
FROM ubuntu:22.04 AS chisel-stage
RUN apt-get update && apt-get install -y curl
RUN CHISEL_VERSION=$(curl -s https://api.github.com/repos/canonical/chisel/releases/latest | grep tag_name | cut -d'"' -f4) && \
curl -sSL "https://github.com/canonical/chisel/releases/download/${CHISEL_VERSION}/chisel_${CHISEL_VERSION}_linux_amd64.tar.gz" | tar xz -C /usr/local/bin/
# Extract only the slices required for Python runtime
RUN chisel cut --release ubuntu-22.04 --root /rootfs \
base-files_base \
base-passwd_data \
libc6_libs \
libssl3_libs \
python3.10_minimal \
python3-minimal_minimal
# Stage 2: Install Python packages into a separate directory
FROM ubuntu:22.04 AS pip-stage
RUN apt-get update && apt-get install -y python3 python3-pip
WORKDIR /app
COPY requirements.txt .
RUN pip3 install --target=/app/packages -r requirements.txt
# Stage 3: Final image - from scratch, only what's needed
FROM scratch
COPY --from=chisel-stage /rootfs /
COPY --from=pip-stage /app/packages /app/packages
COPY app.py /app/
ENV PYTHONPATH=/app/packages
CMD ["/usr/bin/python3", "/app/app.py"]
Image size after using Chisel: approximately 45–60MB, a 75–80% reduction compared to the base Ubuntu image.
Real-World Example: Minimal Image for a Go Binary
With a statically compiled Go application, the image gets even smaller since there’s no Python runtime needed:
# Stage 1: Build Go binary
FROM golang:1.22 AS build-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# Stage 2: Chisel - only ca-certificates and timezone data needed
FROM ubuntu:22.04 AS chisel-stage
RUN apt-get update && apt-get install -y curl
RUN CHISEL_VERSION=$(curl -s https://api.github.com/repos/canonical/chisel/releases/latest | grep tag_name | cut -d'"' -f4) && \
curl -sSL "https://github.com/canonical/chisel/releases/download/${CHISEL_VERSION}/chisel_${CHISEL_VERSION}_linux_amd64.tar.gz" | tar xz -C /usr/local/bin/
RUN chisel cut --release ubuntu-22.04 --root /rootfs \
base-files_base \
ca-certificates_data \
tzdata_zoneinfo
# Stage 3: Final image
FROM scratch
COPY --from=chisel-stage /rootfs /
COPY --from=build-stage /app/server /server
USER 65534:65534
ENTRYPOINT ["/server"]
A static Go binary + Chisel rootfs: the image shrinks to just 10–15MB. And since the base is FROM scratch, there’s no shell, no package manager — the attack surface is nearly zero.
Real Numbers: CVE Count Before and After Chisel
When I first migrated from CentOS to Ubuntu, it took me about a week to get comfortable with the package management system. Back then I defaulted to ubuntu:22.04 as the base image for everything — it was convenient, familiar. But after Trivy reported 47 CVEs in an image I thought was clean, I started taking a harder look.
# Scan CVEs with Trivy - before/after comparison
trivy image my-app:ubuntu-full
# Total: 47 (LOW: 21, MEDIUM: 18, HIGH: 7, CRITICAL: 1)
trivy image my-app:chisel-minimal
# Total: 2 (LOW: 2, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
CVE count dropped from 47 to 2 — primarily because you’ve completely eliminated packages unrelated to the application. No unnecessary packages means no unnecessary vulnerabilities.
Practical Considerations When Using Chisel
- Not every package has a slice: The chisel-releases repository is still growing. If the package you need doesn’t have a slice definition yet, you’ll have to write one yourself or use an alternative approach.
- Debugging is harder: The image has no shell, no
ls, nocat. When you need to debug, usedocker exportto inspect the filesystem, or temporarily add thebusybox_muslslice to a debug build. - FROM scratch needs /etc/passwd: If your app needs to resolve UID/GID, add the
base-passwd_dataslice to your Chisel list. - Dependencies are resolved automatically: If slice A depends on slice B, Chisel pulls B in automatically — no need to list it manually.
Conclusion
Chisel isn’t the right solution for every situation — if your app depends on many complex packages without slice definitions yet, you’ll have extra work ahead. But for production services that demand strong security, it’s a tool worth the investment.
The pattern I use now: full Ubuntu image to build → Chisel slice to create the runtime rootfs → FROM scratch to copy into. Those three steps preserve the entire Ubuntu ecosystem (no glibc incompatibility like Alpine) while still achieving image sizes and security profiles close to Distroless.
If you’re currently using Ubuntu as a base image for your containers and haven’t looked at Chisel yet, two hours of hands-on experimentation and a Trivy scan will show you the difference more clearly than any article can.
