Containers from the inside
A container is an ordinary Linux process with walls around it, and an image is a stack of read-only layers. Once you see those two mechanisms, you can write Dockerfiles that build in seconds, ship a fraction of the bytes, and stop cleanly in the middle of a festival rush.
By the end you can
- Explain what namespaces and cgroups do, and why a container is not a small virtual machine
- Read an image as layers and order Dockerfile steps so the build cache does its job
- Write a multi-stage, non-root Dockerfile for the order service and pick a base image with reasons
- Make a container stop cleanly by getting SIGTERM to your code as PID 1
- Push images to GHCR from CI and deploy them by immutable digest instead of a moving tag
The everyday picture
Think of a hotel. Every room shares the same building: one foundation, one water supply, one electricity network. But each guest gets a key that opens only their room, a window that shows only their view, and a meter that cuts the power if they use more than their share.
Each room is also furnished from the same catalogue. The bed, the desk and the lamp are standard, and the hotel does not build new ones for every guest. Guests can move things around and leave their own things. When a guest checks out, housekeeping resets the room to the catalogue layout, and anything the guest left behind is gone.
A container works like a hotel room. The building is the Linux kernel, shared by every container on the machine. The key and the window are namespaces, the power meter is a cgroup, the catalogue furniture is the image's read-only layers, and the things a guest leaves behind live in a thin writable layer that disappears when the container is removed. A virtual machine is a separate house with its own foundation.
A container is a process with walls
When you run docker run, nothing boots. The Docker client asks the Docker daemon to start a container, the daemon hands the job to containerd, and containerd uses a small tool called runc to ask the kernel for two things. Then it starts your program as a normal process.
The first thing is a set of namespaces. Each namespace gives the process a private view of one resource. In its own PID namespace, your node process is PID 1 and sees no other processes. In its own network namespace, it has its own interfaces and its own port 3000. In its own mount namespace, it sees the image's files as /. Other namespaces give it its own hostname (UTS) and its own shared-memory area (IPC).
The second thing is a cgroup. Namespaces control what the process can see; the cgroup controls how much it can use. docker run --memory=512m --cpus=1 writes those limits into the cgroup. If the order service goes over 512 MB, the kernel's out-of-memory killer ends it, even when the machine has 30 GB free. Docker then reports exit code 137 and OOMKilled: true. Too much CPU does not kill anything: the process is slowed down (throttled) instead.
Two practical facts follow. First, containers start fast, usually in well under a second, because there is no kernel to boot. Second, every container on a machine shares that one kernel. A Linux container cannot bring its own kernel, which is why Docker Desktop on a Mac or Windows laptop runs a small Linux virtual machine in the background. It also means isolation is thinner than in a VM. By default, Docker does not turn on the user namespace, so root inside a container is UID 0 on the host kernel, held back by dropped capabilities and security profiles. That is the main reason this lesson runs the order service as an ordinary user. Linux from the command line covers users, processes and signals if you want a refresher.
Quick check
The order service runs in a container on a Linux build server. On the host (outside the container) you run ps aux. What do you see?
A container is a host process in its own namespaces, so the host sees it like any other process, with a host PID that differs from the PID 1 it sees inside. There is no VM hiding it, and the Docker client is not the parent of your app.
Images are stacked layers, and the cache reads them in order
An image is a list of layers plus a small config file that says which command to run, which user, which environment variables. In a Dockerfile, RUN, COPY and ADD each add a layer with the files they changed. Instructions such as ENV, USER, EXPOSE and CMD only change the config.
Each layer is named by the SHA-256 hash of its content, so it is stored and downloaded once. If twenty Mangrove services use the same base image, a cluster node downloads that base once, and a new order service version only costs the layers that changed. When a container starts, an overlay file system (overlay2 on most Linux hosts) stacks the layers into one tree and adds a thin writable layer on top. When the app changes a file that came from the image, the kernel first copies the whole file up into the writable layer (copy-on-write). When the container is removed, the writable layer is deleted. Data that must survive belongs in Postgres, object storage or a volume.
Deleting a file in a later layer does not remove it from the image. The later layer only records a marker that hides the file in the merged view. The bytes are still in the earlier layer, and anyone who pulls the image can unpack that layer and read them. That is why RUN apt-get install … && rm -rf /var/lib/apt/lists/* must be one RUN, and why a secret copied in and deleted later is still a leaked secret.
.npmrc is invisible inside the container, but its bytes are still in the COPY .npmrc layer that every registry and every node stores.The same stack drives the build cache. Docker walks the Dockerfile from the top. A step can reuse its old layer only if every step before it was reused and its own inputs are unchanged. For COPY and ADD, the inputs are a checksum of the files being copied. For RUN, the input is the command text: Docker does not look at what the command would download. Once one step misses, every step after it runs again.
So instruction order is a speed decision. If COPY . . comes before RUN npm ci, any change to any file, even a README, invalidates the copy, and npm ci reinstalls every package. If you copy package.json and package-lock.json first, run npm ci, and copy the source after, a normal code change reuses the installed packages and only reruns the compile. The RUN rule has a trap too: RUN apt-get update on its own line is cached forever, so a later apt-get install may use a stale package list. Keep update and install in one RUN.
Two more tools help. The build context is everything in the folder you build from, minus what .dockerignore excludes. Without that file, node_modules, .git and your .env go into the context, slow every build, can break the cache on every commit, and can end up in the image. And a BuildKit cache mount, RUN --mount=type=cache,target=/root/.npm npm ci, keeps npm's download cache between builds, so even when the install step reruns it downloads less. BuildKit has been Docker's default builder since Docker Engine 23.0.
Quick check
To install a private package, a Dockerfile does COPY .npmrc ./ (it holds a registry token), then RUN npm ci, then RUN rm .npmrc. Is the token in the pushed image?
Layers are read-only, so the delete is a marker in a later layer that hides the file from the merged view. The bytes stay in the COPY layer. Use a BuildKit secret mount, shown in the warning box below, so the token never lands in any layer. The user the container runs as does not matter to someone reading the layer archive.
Small and safe: multi-stage builds, base images and users
Building the order service needs TypeScript, the Nest CLI, test tools and all dev dependencies. Running it needs Node, the compiled dist folder and the production packages. A multi-stage build keeps those apart: the first stage installs everything and compiles, and the final stage starts from a clean base and copies in only the results. Nothing from the build stage reaches the final image unless you copy it. Shipping safely: containers, CI/CD and releases introduced the pattern; here you choose the base image on purpose.
| Base image | C library | Shell and package manager | Use it for |
|---|---|---|---|
node:22 (full Debian) | glibc | Yes, plus compilers and git | Build stages that compile native modules; too big to ship |
node:22-bookworm-slim | glibc | Yes (apt) | A safe default for build and runtime |
node:22-alpine | musl | Yes (apk) | Small images, if every native module supports musl |
gcr.io/distroless/nodejs22-debian12 | glibc | No | Runtime only: least to attack, harder to debug |
The C library column matters more than the size. Alpine uses musl instead of glibc. A native module compiled on Debian (a password hashing library, an image resizer, some database drivers) will not load on Alpine, and a few behaviours differ, DNS lookups among them (version dependent). Alpine works well when you build inside an Alpine stage and test there. Distroless images go further: they hold Node and the libraries it needs, with no shell and no package manager, so an attacker who gets in has very few tools, and a scanner finds fewer packages to report. The price is debugging: there is no sh to docker exec into. Whatever you pick, keep the build stage and the runtime on the same Node major version and the same C library, so native modules built in one run in the other.
Then take away root. The official Node images include a user called node (UID 1000), and the distroless :nonroot images use UID 65532. Set USER with a number, because Kubernetes can only check a "must not run as root" rule when the user is numeric (Kubernetes for application teams uses this). Leave the copied files owned by root: the app can read its own code but cannot change it. At run time you can go further with --read-only for the root file system and --cap-drop ALL, which a Node API listening on port 3000 does not need.
Finally, one process per container. If the order service, a nightly cron job and nginx share one container under a process supervisor, you cannot restart, scale or read the logs of one without the others, and nobody knows which process the orchestrator is watching. Run each as its own container, and write logs to standard output so the platform collects them.
Quick check
The order service now runs on a distroless image. During an incident, the on-call engineer types docker exec -it orders sh and gets "executable file not found". What is the right move?
The missing shell is the security feature working. Distroless publishes :debug variants with a small busybox shell, and Kubernetes can attach a temporary debug container next to the running one. Shipping a full image for every day of the year to make one incident easier undoes the gain, and a distroless image has no apt at all.
Stopping cleanly: PID 1 and signals
Every deploy stops old containers. docker stop sends SIGTERM to the container's PID 1, waits 10 seconds by default, and then sends SIGKILL. Kubernetes does the same with a default grace period of 30 seconds. Your app gets one chance to stop accepting new requests, finish the ones in flight, close its Postgres and Redis connections and exit. If it misses that chance, requests are cut mid-way and the container exits with code 137 (128 + 9, killed by SIGKILL).
Three things make SIGTERM miss your code. The first is the shell form of CMD. CMD node dist/main.js is run as /bin/sh -c "node dist/main.js", so PID 1 may be the shell, and a shell does not pass SIGTERM on to its child (some shells replace themselves with a single command, so do not count on it either way). The exec form, CMD ["node", "dist/main.js"], starts node directly as PID 1. The second is npm start as the command: npm becomes PID 1, adds a process between the signal and your app, and its signal handling has changed between npm versions. Run node directly.
The third is subtle. The kernel treats PID 1 specially: a signal whose default action is "terminate" is ignored by PID 1 unless the process has installed a handler for it. Node installs no SIGTERM handler by default, so node as PID 1 with no handler ignores docker stop and waits to be killed. In NestJS, app.enableShutdownHooks() installs the handlers and runs your shutdown hooks. PID 1 also has a second job: it must collect (reap) exited child processes. If the service starts child processes, for example to render PDF invoices, add a tiny init such as tini as PID 1, or use docker run --init locally; it forwards signals and reaps children.
Health checks have the same two worlds. The Dockerfile HEALTHCHECK instruction makes Docker run a command inside the container every few seconds and mark it healthy or unhealthy. Docker Compose can wait for "healthy" before starting a dependent service, but plain Docker does not restart an unhealthy container, and Kubernetes ignores HEALTHCHECK completely. On Kubernetes you declare readiness, liveness and startup probes in the pod spec instead (ci-03). A HEALTHCHECK that calls curl also fails on a distroless image, which has no curl.
Quick check
The order service image has HEALTHCHECK CMD node dist/health.js. On the Kubernetes cluster, a pod hangs and stops answering, but it is never restarted. Why?
Kubernetes reads liveness, readiness and startup probes from the pod spec and never runs the Dockerfile HEALTHCHECK. Without a liveness probe nothing decides the pod is stuck. The form of the command and its interval do not matter, because the instruction is never used.
Tags, digests and the registry
A registry stores images. Mangrove's go to GitHub Container Registry as ghcr.io/mangrove/order-service. Each image has a manifest that lists its layers by hash, and the SHA-256 hash of that manifest is the image's digest. A tag such as :3f9c2e1 or :latest is a name that points at a digest, and anyone with push rights can move it. A digest cannot move: if one byte changes, the digest changes.
That gives you a simple rule. Tag images for people, deploy them by digest. CI tags each build with the git commit so humans can find it, and the deploy step uses ghcr.io/mangrove/order-service@sha256:…, the exact bytes that passed the tests in staging. The same applies to the base image: node:22-bookworm-slim points at a new digest every time Node or Debian ships a patch. Pinning FROM node:22-bookworm-slim@sha256:… makes builds repeatable, and a bot such as Dependabot or Renovate opens a pull request when a new digest appears, so you still get security fixes, through review. One tag can also point at an index that lists one image per CPU architecture (for example amd64 and arm64), and each machine pulls the one that matches.
Before an image reaches staging, a scanner such as Trivy or Grype compares the packages inside it with public vulnerability lists. Smaller images give shorter, more useful reports. Secure supply chain and delivery metrics turns that into a policy, adds signatures and SBOMs, and pins the GitHub Actions used below to full commit SHAs.
Build it
Build a small, non-root, cache-friendly image for mangrove/order-service, prove it stops cleanly, and push it to GHCR from GitHub Actions with a digest you can deploy.
-
Keep the build context small
Create
.dockerignorein the repository root. Everything listed here never reaches the builder, so it cannot bust the cache or leak into a layer.Output# .dockerignore node_modules dist coverage .git .github .env* *.log -
Write the multi-stage Dockerfile
The build stage uses Debian slim with glibc; the runtime is distroless on the same Debian 12 base and the same Node major, so native modules carry over. Comments sit on their own lines.
Dockerfile# syntax=docker/dockerfile:1 # ---- build: all dependencies, compile TypeScript ---- FROM node:22-bookworm-slim AS build WORKDIR /app # package files first: this layer changes only when dependencies change COPY package.json package-lock.json ./ # npm's download cache lives in a cache mount, never in a layer RUN --mount=type=cache,target=/root/.npm npm ci # source last: a code change reruns only the steps below COPY tsconfig.json tsconfig.build.json nest-cli.json ./ COPY src ./src # compile, then drop dev dependencies from node_modules RUN npm run build && npm prune --omit=dev # ---- runtime: Node only, no shell, no npm ---- FROM gcr.io/distroless/nodejs22-debian12:nonroot WORKDIR /app ENV NODE_ENV=production # files stay owned by root: the app can read its code, not change it COPY --from=build /app/package.json ./ COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist # numeric user, so Kubernetes can verify "not root" USER 65532:65532 EXPOSE 3000 # the image's entrypoint is node; exec form, so node is PID 1 CMD ["dist/main.js"]In production you would also pin both
FROMlines by digest, as described above. -
Handle SIGTERM in the app
Turn on Nest's shutdown hooks and close your connections in a hook. On SIGTERM, Nest stops the HTTP server and calls the hooks.
TypeScript// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableShutdownHooks(); // installs SIGTERM and SIGINT handlers await app.listen(process.env.PORT ?? 3000, '0.0.0.0'); } bootstrap(); // src/db/db.service.ts import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Pool } from 'pg'; @Injectable() export class DbService implements OnApplicationShutdown { constructor(@Inject('PG_POOL') private readonly pool: Pool) {} async onApplicationShutdown(signal?: string) { console.log(`shutting down on ${signal}`); // goes to stdout, so the platform keeps it await this.pool.end(); // close Postgres connections cleanly } } -
Build, inspect and test the stop
docker historylists each layer with its size and the instruction that made it, so you can see where the bytes are. Then start the container and time a stop.Terminaldocker build -t order-service:dev . docker history order-service:dev # one line per layer, newest first docker image inspect order-service:dev --format '{{.Config.User}}' docker run -d --name os-test -p 3000:3000 --memory=512m order-service:dev time docker stop os-test # SIGTERM now, SIGKILL after 10 s docker inspect os-test --format '{{.State.ExitCode}}' docker rm os-testWith the old
CMD npm run start:prod, the stop looks like this (your timings will differ):Output65532:65532 os-test real 0m10.42s user 0m0.02s sys 0m0.01s 137About 10 seconds and exit code 137 means the signal never reached your code. With the Dockerfile above, the stop returns in about a second, the log shows "shutting down on SIGTERM", and the exit code is 0 or 143 (128 + 15), depending on how the process ends after cleanup. Either is a clean stop; 137 is the one to fix.
-
Build and push from GitHub Actions, with a shared cache
Add this job to the workflow from ci-01. The
GITHUB_TOKENcan push to GHCR when the job grantspackages: write. Theghacache stores layers between runs, because every runner starts empty.YAMLimage: needs: test if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: read packages: write # push to ghcr.io with GITHUB_TOKEN outputs: digest: ${{ steps.build.outputs.digest }} steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: build uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/mangrove/order-service:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max # also cache the build stage's layers - run: echo "Digest ${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"Later jobs read
needs.image.outputs.digestand deployghcr.io/mangrove/order-service@<digest>, never the tag. The action versions are the major versions current when this was written; check for newer ones.
Bring it to your team
Ask these in every pull request that touches a Dockerfile or the image job.
- "Which base image is this, is it pinned by digest, and what opens a pull request when it gets a security fix?"
- "If I change one line in src, which steps rerun, locally and in CI?"
- "What is in the build context? Show me the .dockerignore."
- "What is PID 1, and what happens in the first second after it gets SIGTERM?"
- "Which user does the container run as, and can that user change the app's own files?"
- "Which digest is in staging right now, and will production run exactly that one?"
Red flags
FROM node:latest, or a final stage that still holds compilers and dev dependenciesCOPY . .beforenpm ci, or no.dockerignoreat all- Tokens passed with
ARG,ENVor a copied file, even one deleted later - A shell-form
CMDornpm startas the container command - The API, a cron job and nginx in one container under a supervisor
- Deploying the
latesttag, or fixing things by hand withdocker execinside a running container
Practice
Predict where the build cache breaks
Write rebuiltSteps(steps, changedFiles, ignored). steps is the Dockerfile as a list of { instruction, reads }, where reads lists what a COPY reads from the build context (empty for other steps). A read pattern is an exact path ("package.json"), a folder ending in a slash ("src/", which matches every file under it) or "." (the whole context). changedFiles lists the paths changed since the last build. ignored (optional, default empty) lists .dockerignore entries in the same exact-or-folder form; ignored files are not in the context, so they never invalidate anything. Return the index of the first step whose cache is invalidated (every later step reruns too), or -1 if the whole build comes from the cache.
function rebuiltSteps(steps, changedFiles, ignored = []) {
// 1. drop changed files that .dockerignore keeps out of the context
// 2. find the first step that reads one of the remaining files
return -1;
}
const good = [
{ instruction: 'FROM node:22-bookworm-slim AS build', reads: [] },
{ instruction: 'WORKDIR /app', reads: [] },
{ instruction: 'COPY package.json package-lock.json ./', reads: ['package.json', 'package-lock.json'] },
{ instruction: 'RUN npm ci', reads: [] },
{ instruction: 'COPY tsconfig.json ./', reads: ['tsconfig.json'] },
{ instruction: 'COPY src ./src', reads: ['src/'] },
{ instruction: 'RUN npm run build', reads: [] },
];
const naive = [
{ instruction: 'FROM node:22', reads: [] },
{ instruction: 'WORKDIR /app', reads: [] },
{ instruction: 'COPY . .', reads: ['.'] },
{ instruction: 'RUN npm install', reads: [] },
];
test('a source change breaks the cache at COPY src', () => {
expect(rebuiltSteps(good, ['src/orders/orders.service.ts'])).toBe(5);
});
test('a lock file change breaks it at the package COPY, the earliest step hit', () => {
expect(rebuiltSteps(good, ['src/main.ts', 'package-lock.json'])).toBe(2);
});
test('files that no step reads leave the whole build cached', () => {
expect(rebuiltSteps(good, ['README.md', 'docs/runbook.md', 'src-old/legacy.ts'])).toBe(-1);
});
test('COPY . . is invalidated by any file in the context', () => {
expect(rebuiltSteps(naive, ['README.md'])).toBe(2);
});
test('.dockerignore keeps ignored files from breaking the cache', () => {
expect(rebuiltSteps(naive, ['.git/index', 'node_modules/.package-lock.json', '.env'], ['.git/', 'node_modules/', '.env'])).toBe(-1);
expect(rebuiltSteps(naive, ['.git/index', 'src/app.module.ts'], ['.git/'])).toBe(2);
});
Show a solution
function rebuiltSteps(steps, changedFiles, ignored = []) {
// an exact path, a folder ending in "/", or "." for everything
const matches = (file, pattern) =>
pattern === '.' || file === pattern || (pattern.endsWith('/') && file.startsWith(pattern));
// ignored files never reach the builder
const inContext = changedFiles.filter((f) => !ignored.some((p) => p !== '.' && matches(f, p)));
for (let i = 0; i < steps.length; i++) {
const reads = steps[i].reads || [];
if (inContext.some((f) => reads.some((p) => matches(f, p)))) return i; // first miss
}
return -1; // all cached
}Note that src-old/legacy.ts does not match src/: the folder pattern includes the slash. In the naive Dockerfile any change hits step 2, which is why npm install reruns on every commit. In the good one, only dependency changes reach the install.
Review the old Dockerfile
A teammate opens a pull request for the reporting worker, a small NestJS job that builds the daily sales export and uses a private npm package. Review it. List each problem, why it matters at Mangrove, and the fix.
FROM node:latest
COPY . .
ARG NPM_TOKEN
RUN echo "//npm.pkg.github.com/:_authToken=${NPM_TOKEN}" > .npmrc
RUN npm install
RUN npm run build
RUN apt-get update
RUN apt-get install -y cron
CMD npm run start:prod && cron -f- You flag
node:latestas a moving, full-size base and propose a pinned slim or distroless base in a multi-stage build - You explain that
COPY . .before the install reruns it on every change, and that there is no.dockerignore - You say the token stays in the
.npmrclayer (and the build argument can show up in history), and propose a secret mount - You spot the separate
apt-get updateline (stale cache) and the missingUSER(runs as root) - You explain that the shell-form command with npm and cron gives two processes, no clean SIGTERM handling, and suggest separate containers or a scheduled job
Show a model answer
Base. node:latest changes under you and ships compilers and git. Use a multi-stage build: node:22-bookworm-slim pinned by digest to build, distroless or slim to run.
Cache and context. COPY . . first means every commit reinstalls everything. Copy package.json and package-lock.json, run npm ci (not npm install, so the lock file is respected), then copy the source. Add a .dockerignore for node_modules, .git and .env*.
Secret. The token is written into .npmrc in its own layer, so anyone who can pull the image can read it, and the build argument can appear in the image history. Use RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci.
apt and user. A lone RUN apt-get update is cached forever; combine it with the install, or better, drop cron from the image. There is no USER, so it runs as root; add a numeric non-root user.
Command. The shell form runs sh -c as PID 1, and cron -f only starts after the app exits, so cron never runs while the worker is up. Two jobs in one container also cannot be restarted or scaled apart. Run the worker as CMD ["node", "dist/main.js"] and schedule the export with the platform's scheduler (a Kubernetes CronJob, for example) instead of cron inside the container.
Check yourself
The order service Dockerfile already copies the package files before the source. On a laptop, a one-line change rebuilds in 40 seconds. In GitHub Actions the same change takes 6 minutes, and the log shows npm ci running every time. What is the most likely cause?
Layer reuse needs the old layers to be present. A laptop keeps them between builds, but a hosted runner starts empty, so without an external cache every step is a miss. The Dockerfile order is fine and Docker reads it top-down everywhere. A missing .dockerignore would hurt on the laptop too.
Staging ran ghcr.io/mangrove/order-service:1.4.0 on Monday and passed every check. Production pulled :1.4.0 on Thursday and fails at startup with a missing module. Nobody changed the code. What happened, and what prevents it?
A tag is a movable name, so "1.4.0" on Thursday can point at different bytes than on Monday. A digest names exact content, so deploying the digest staging tested guarantees the same image. Building per environment and using latest make the problem worse, and a corrupted layer would fail its hash check.
During a rolling deploy on Kubernetes, every old order service pod takes exactly 30 seconds to stop, ends with exit code 137, and never logs "shutting down". The image uses CMD ["npm", "run", "start:prod"]. What is the best fix?
Exactly the grace period plus 137 means SIGKILL ended a process that never handled SIGTERM: npm sits at PID 1 and the app never runs its shutdown. A longer grace period only makes each deploy slower before the same kill. Kubernetes ignores HEALTHCHECK, and the base image has nothing to do with signal delivery.
Flashcards
Key takeaways
- A container is a host process in its own namespaces with cgroup limits on a shared kernel: fast to start, thinner isolation than a VM, so run as a numeric non-root user.
- Images are read-only layers named by hash. Deleting a file later does not remove it, so keep secrets out with secret mounts and clean up inside the same RUN.
- The cache stops at the first changed step. Copy package files, run
npm ci, then copy source; use.dockerignoreand a CI layer cache. - Multi-stage builds ship only runtime files. Slim is the safe default, distroless is smallest to attack, and Alpine needs musl-compatible native modules.
- Exec-form
CMDand a SIGTERM handler make stops clean; deploy by digest, not by tag, and leave health checking on Kubernetes to probes.
Sources
- Dockerfile reference, Docker documentation
- Docker build cache, Docker documentation
- namespaces(7) and cgroups(7), Linux manual pages
- Working with the Container registry, GitHub Docs