Chapter 7: Language-Specific Optimizations


The general techniques from previous chapters apply to every language. But each runtime has its own package ecosystem, build toolchain, and deployment artifact — and each has specific optimisations that make a significant difference. This chapter covers four major runtimes: Python, Node.js, Go, and Java.


Python

Python is the language where Docker image bloat is most common, because the full python:3.x base image ships with gcc, make, and hundreds of development packages that no production application needs.

Base image: python:3.12-slim-bookworm

Use the slim bookworm variant for production. It provides glibc (required for binary wheels from PyPI), Python, and almost nothing else. Alpine is tempting but almost always causes problems with packages that have C extensions (psycopg2, numpy, pillow, cryptography) because PyPI wheels are compiled for glibc, not musl.

The virtualenv copy pattern

FROM python:3.12-slim-bookworm AS builder
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.12-slim-bookworm
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY app/ /app
USER nobody
CMD ["python", "-m", "app"]

The venv is self-contained: it includes the interpreter path, all packages, and their metadata. Copying it to the runtime stage is clean and predictable.

Pre-compiling .pyc files

Python compiles .py files to .pyc bytecache on first import, writing to __pycache__. In a read-only container filesystem (--read-only flag), this write will fail. Pre-compile during the build:

RUN python -m compileall /app

This also slightly reduces startup time by skipping the compilation step at runtime.

Size targets

Image Typical size
python:3.12 (full) ~1.0 GB
python:3.12-slim-bookworm ~45 MB
slim + typical web app deps 80–150 MB
slim + multi-stage + venv copy 70–120 MB

Full Dockerfile: code/dockerfiles/python_multistage.Dockerfile


Node.js

Node.js images can grow large quickly because node_modules often contains hundreds of packages, including development-only tools.

Separate dev and production dependencies

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev
USER node
CMD ["node", "dist/index.js"]

The key: the runtime stage installs only production dependencies (--omit=dev) and copies only the compiled output (dist/). The full node_modules from the builder stage — including TypeScript, ESLint, webpack, and other dev tools — never reaches the runtime image.

Bundling for minimal runtime deps

For frontend-serving Node applications, consider bundling with esbuild or webpack. A bundled server often has zero node_modules at runtime:

FROM node:20-alpine AS builder
RUN npm ci && npm run bundle

FROM node:20-alpine
COPY --from=builder /app/bundle.js /app/bundle.js
CMD ["node", "/app/bundle.js"]

Alpine vs slim for Node

node:20-alpine is the most common choice. Alpine’s musl libc rarely causes problems for pure-JS packages; issues arise mainly with native addons. If your project uses native addons (sharp, bcrypt, canvas), use node:20-slim instead.

Full Dockerfile: code/dockerfiles/node_multistage.Dockerfile


Go

Go is the ideal Docker citizen: it compiles to a single static binary with no runtime dependencies. The resulting images can be as small as a few megabytes.

Static compilation

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOARCH=amd64 \
    go build -ldflags="-s -w" -o server .

FROM scratch
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

Key flags:

When you need a shell

If you require a shell for health checks, init scripts, or debugging, use Alpine instead of scratch:

FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

This adds ~5 MB but gives you a full shell environment.

CA certificates

FROM scratch has no TLS certificates. If your binary makes HTTPS connections, copy the system certificates:

FROM alpine:3.19 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

Full Dockerfile: code/dockerfiles/go_scratch.Dockerfile


Java

Java images are inherently larger than Go or Python because the JVM itself is substantial. The primary lever is using the JRE (runtime environment) instead of the JDK (development kit), and using Alpine where possible.

JRE only, not JDK

The JDK includes the compiler (javac), jshell, jlink, debug tools, and headers — none of which belong in a production runtime. Use the JRE:

FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -q package -DskipTests

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
USER nobody
ENTRYPOINT ["java", "-jar", "app.jar"]

eclipse-temurin is the Eclipse Foundation’s distribution of OpenJDK, widely regarded as the best community-maintained option. The 21-jre-alpine variant is approximately 180 MB — compared to over 400 MB for 21-jdk.

For applications that do not use the full Java standard library, jlink can produce a minimal custom JRE containing only the modules your application actually uses:

FROM eclipse-temurin:21-jdk AS builder
# ... build the jar ...
RUN jlink --add-modules $(jdeps --ignore-missing-deps \
      --print-module-deps target/app.jar) \
    --output /custom-jre --no-header-files --no-man-pages

FROM debian:bookworm-slim
COPY --from=builder /custom-jre /opt/jre
COPY --from=builder /app/target/app.jar /app/app.jar
ENV PATH="/opt/jre/bin:$PATH"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

A jlink-based JRE for a typical Spring Boot application is typically 50–80 MB, compared to 180 MB for the full JRE.

GraalVM Native Image

For maximum reduction, GraalVM’s native-image compiles Java to a native binary, eliminating the JVM entirely:

Final image size: 50–100 MB (comparable to Python slim)
Startup time: milliseconds instead of seconds
Tradeoff: complex build, limited reflection, longer compile time

This is worth investigating for microservices and serverless deployments where startup time matters, but requires application-level changes to work reliably.

Full Dockerfile: code/dockerfiles/java_jre.Dockerfile


Key Takeaways


← Chapter 6: Build Cache and BuildKit Table of Contents Chapter 8: Analyzing Images →