Docker

Docker - Hard

Page 2 of 3

What Alpine Actually Trades Away

Alpine-based images (node:20-alpine, python:3.12-alpine, and so on) are popular because they're small - often a tenth the size of the equivalent Debian-based image. What doesn't always get explained is why they're smaller, and what that trade-off can cost you.

Alpine uses musl as its C standard library, instead of glibc, which is what Debian, Ubuntu, and most "normal" Linux distributions use. musl is smaller and simpler, which is most of where Alpine's size savings actually come from. The problem: a lot of software - especially compiled native extensions for languages like Python and Node - is built and tested against glibc, and makes assumptions musl doesn't satisfy. This doesn't always fail loudly. Sometimes it fails to build at all (a clear, if annoying, error). Sometimes it builds fine and then produces subtly wrong behavior at runtime, or fails only for certain inputs - the kind of bug that costs you a genuinely confusing afternoon before you trace it back to the base image.

A second, less obvious Alpine quirk: musl's DNS resolver behaves differently from glibc's in some edge cases (notably around retrying and IPv6 handling), which has caused real, hard-to-reproduce intermittent connectivity issues for some applications.

None of this means "never use Alpine" - for a huge number of apps, especially ones without native dependencies, it's a genuinely good default with no downside. The point is knowing the trade-off exists, so if you hit a bizarre, hard-to-explain bug in an Alpine-based image, "is this actually a musl-vs-glibc issue" is one of the first things worth checking, not the last. A common, pragmatic compromise for apps with native dependencies is a Debian-based slim image instead - still meaningfully smaller than the full image, without musl's compatibility surprises.


Tags Are Not Versions

latest looks like it should mean "the newest, most current version." It doesn't. latest is just a tag - a label someone attached to some specific image at some point - and by convention it's often whatever was most recently pushed without an explicit tag. That could genuinely be from six months ago, if nobody's pushed anything since, or it could be from an hour ago. There's no guarantee either way, and nothing stops latest from pointing at a completely different, even incompatible, build than it did yesterday.

This causes two real, recurring problems. First, docker pull my-app:latest run today and the same command run next week can silently pull two different images - breaking the entire point of "I tested this exact thing" if what actually ships isn't what you tested. Second, when something breaks in production and everyone's pulling latest, you can't even be sure two people debugging the same incident are looking at the same image.

The fix is to pin to something immutable. A specific version tag is better than latest:

FROM node:20.11.1-slim

But even a specific tag CAN technically be overwritten and re-pushed (rare, but possible, and a real supply-chain risk). The genuinely immutable reference is an image digest - a content hash that can only ever refer to the exact bytes it was generated from:

FROM node:20.11.1-slim@sha256:3f8f4c9b...
docker inspect --format='{{index .RepoDigests 0}}' node:20.11.1-slim   # get the digest for a tag you already have

For production builds, pinning to a digest - not just a version tag - is the only way to guarantee that "the image I built and tested" and "the image that's actually running" are provably the same bytes.


BuildKit and Cache Mounts

Modern Docker builds run on BuildKit, a more capable builder than the old default. Two BuildKit features are worth knowing because they solve real, common pain points the old builder couldn't.

Cache mounts let a package manager's own cache directory persist across builds, without that cache ending up baked into the final image (which the older RUN caching approach couldn't avoid):

RUN --mount=type=cache,target=/root/.npm \
    npm install

This is a genuinely different thing from the layer caching discussed earlier - layer caching skips a step entirely if nothing changed; a cache mount speeds up a step that DOES need to rerun, by reusing previously-downloaded packages instead of re-downloading them from scratch.

Multi-platform builds let you build a single image that works on both x86 and ARM (Apple Silicon Macs, ARM-based cloud instances) from one Dockerfile:

docker buildx build --platform linux/amd64,linux/arm64 -t my-app --push .

    Welcome to OpsQuiz!

    Real scenario-based DevOps questions, hands-on practice, and clear explanations for every answer.