Gendi Notes

Docker

Docker packages an application together with everything it needs to run, so the same package behaves identically on your laptop and in production.

Level 1 guide dockercontainersimages

What is it?#

Docker is a tool for building and running containers. A container is your application plus its libraries, runtime and configuration, bundled into one unit that runs the same way anywhere.

You use it in three steps that never really change:

Dockerfile Image Container ────────── ───── ───────── a recipe build a frozen run a running you write ────→ snapshot ────→ process (read-only) (has state)

Why it exists#

The problem was "it works on my machine". An app needs a specific Python version, three system libraries and two environment variables — and the server has different ones.

The old fixes were painful. Install everything on the server by hand and hope nobody changes it. Or ship a whole virtual machine: a full guest operating system, gigabytes in size, a minute to boot.

Docker's answer: package only the application and its dependencies, and share the host's operating system kernel.

Virtual machineContainer
ContainsA whole guest OSJust the app and its libraries
SizeGigabytesTens of megabytes
Start time~30–60 secondsUnder a second
IsolationVery strong (own kernel)Good (shared kernel)
VIRTUAL MACHINES CONTAINERS ──────────────── ────────── ┌──────┐┌──────┐┌──────┐ ┌──────┐┌──────┐┌──────┐ │ app ││ app ││ app │ │ app ││ app ││ app │ ├──────┤├──────┤├──────┤ └──────┘└──────┘└──────┘ │guest ││guest ││guest │ ← the ┌────────────────────┐ │ OS ││ OS ││ OS │ part │ container engine │ └──────┘└──────┘└──────┘ that └────────────────────┘ ┌────────────────────┐ got ┌────────────────────┐ │ hypervisor │ deleted │ HOST KERNEL │ ├────────────────────┤ │ (shared by all) │ │ host kernel │ ├────────────────────┤ ├────────────────────┤ │ hardware │ │ hardware │ └────────────────────┘ └────────────────────┘ 3 kernels booting 1 kernel, already booted

What it is made of#

Five pieces. Knowing which one you are dealing with removes most Docker confusion.

PieceWhat it isThink of it as
DockerfileA text file of build instructionsThe recipe
ImageThe built, read-only resultA frozen snapshot
ContainerA running instance of an imageThe live process
RegistryA server storing images (Docker Hub, ECR, ACR)The app store
VolumeStorage that outlives the containerThe external hard drive

Behind the scenes there is also the Docker daemon (dockerd), the background service that does the actual work, and the CLI (docker), which just sends it instructions. When you see "Cannot connect to the Docker daemon", the service is not running — your command was fine.

YOUR MACHINE SOMEWHERE ELSE ───────────────────────────────────────── ────────────────── ┌─────────────┐ ┌──────────────────────────┐ │ docker │ the command │ dockerd │ │ (the CLI) │ ──────────────→ │ the daemon │ │ │ as JSON │ │ │ build run │ over a unix │ · builds images │ │ ps logs │ socket │ · starts containers │ └─────────────┘ │ · keeps volumes │ types nothing │ · wires up networks │ but requests └──────────────────────────┘ │ ↑ docker │ │ docker push │ │ pull ↓ │ ┌──────────────────────────┐ │ Registry │ │ Docker Hub · ECR · ACR │ └──────────────────────────┘

How to use it#

1. Run something that already exists#

You do not need a Dockerfile to start. Pull a published image and run it:

Run nginx and reach it on localhost:8080
# -d          run in the background (detached)
# -p 8080:80  map port 8080 on your machine to port 80 in the container
# --name      give it a name, so you don't have to use the random ID
docker run -d -p 8080:80 --name web nginx

# Confirm it is running, then open http://localhost:8080
docker ps

The -p flag is the one people get backwards. It is always host:container — outside first, inside second.

2. Package your own app#

Create a file called Dockerfile next to your code:

Dockerfile — a small Node app
# Start from an existing image instead of building from nothing.
# "alpine" variants are much smaller; "22" pins the major version.
FROM node:22-alpine

# Everything after this runs inside /app in the image.
WORKDIR /app

# Copy the dependency manifest FIRST, on its own. This is the single most
# valuable line in the file — see the caching note below.
COPY package*.json ./
RUN npm ci --omit=dev

# Now copy the rest of the source.
COPY . .

# Documents which port the app listens on. Does not publish it by itself.
EXPOSE 3000

# The command that runs when a container starts. Use this array form, not a
# plain string, so your app receives shutdown signals properly.
CMD ["node", "server.js"]

Then build and run it:

Build, then run
# -t gives the image a name and tag. Without it you get an untagged image
# that is awkward to refer to later.
docker build -t my-app:1.0 .

docker run -d -p 3000:3000 --name my-app my-app:1.0

3. The commands you will actually use daily#

docker ps                  # running containers
docker ps -a               # including stopped ones — where exited containers hide
docker images              # images on this machine
docker logs -f my-app      # follow the output. First stop for "why did it break?"
docker exec -it my-app sh  # get a shell INSIDE a running container
docker stop my-app         # graceful stop
docker rm my-app           # delete the (stopped) container
docker rmi my-app:1.0      # delete the image
docker system prune -a     # reclaim disk. Deletes unused images — read the prompt

docker logs and docker exec are where you will spend most of your debugging time. docker logs shows what the app printed; docker exec puts you inside the container to look around.

4. Several containers at once: Compose#

An app usually needs a database too. Writing three docker run commands with the right flags in the right order gets old immediately. Compose puts it in one file:

compose.yaml — app plus database
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/mydb
    depends_on:
      - db

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: mydb
    # Without this volume, your data is deleted with the container.
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
docker compose up -d      # start everything
docker compose logs -f    # follow all services' logs together
docker compose down       # stop and remove (volumes survive)

Note postgres://user:pass@db:5432 — the hostname is db, the service name. Compose puts the services on one network where each is reachable by its name. You never need to know their IP addresses.

How it works underneath#

A container is not a small virtual machine. It is an ordinary Linux process with two kernel features applied to it:

┌──────────────────────────────────────────┐ │ your app (just a process on the host) │ └──────────────────────────────────────────┘ │ │ ┌────────↓─────────┐ ┌─────────↓──────────┐ │ NAMESPACES │ │ CGROUPS │ │ what it can SEE │ │ what it can USE │ ├──────────────────┤ ├────────────────────┤ │ own process list │ │ CPU limit │ │ own network/IP │ │ memory limit │ │ own filesystem │ │ I/O limit │ │ own hostname │ │ │ └──────────────────┘ └────────────────────┘ │ ┌───────────↓────────────┐ │ SHARED HOST KERNEL │ ← the whole trade-off └────────────────────────┘

Run ps aux on the host and you will see your containerised process listed like any other. That is the entire trick: the process is real, but its view of the system is restricted.

This also explains the limits. Containers cannot run a different kernel, which is why Linux containers on Windows or macOS run inside a hidden Linux VM. And because the kernel is shared, containers are a good isolation boundary but a weaker security boundary than a VM.

What goes wrong#

SymptomUsual causeFix
Cannot connect to the Docker daemonDocker service not runningStart Docker Desktop / sudo systemctl start docker
Container exits immediatelyThe main process finisheddocker logs. A container lives only as long as its main process
port is already allocatedSomething else has that host portChange the left side of -p, or stop the other container
Changes to code not appearingYou rebuilt nothing, or ran the old imagedocker build again; check the tag you ran
Data disappeared after restartWritten to the container filesystem, not a volumeMount a volume
Image is enormous (1GB+)Build tools left in the final imageUse a multi-stage build and an alpine or slim base
Disk fullOld images and build cache accumulatedocker system prune -a

Two habits worth forming early#

Multi-stage builds. Compile with a big toolchain, then copy only the result into a small final image:

Build tools stay out of the shipped image
# Stage 1 — has the full toolchain
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2 — only the built output and runtime dependencies
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

Nothing from stage 1 exists in the final image. It is routinely a 10× size reduction, and it removes compilers from your production image.

A .dockerignore file. Without it, COPY . . sends your entire directory — including node_modules, .git and any local secrets — into the build:

.dockerignore
node_modules
.git
.env
*.log
dist

Interview corner · الأسئلة اللي بتتسأل#

The questions below are the ones that actually separate candidates. Each answer is collapsed — read the question, answer it in your head, then open it.

Is a container just a lightweight VM? · الكونتينر مجرد VM صغير؟

No, and the reason matters more than the answer.

A VM virtualises hardware and boots its own kernel. A container is an ordinary host process with namespaces restricting what it can see and cgroups restricting what it can consume. There is no second kernel.

Three consequences you should volunteer without being asked:

  • You cannot run a Windows container on a Linux host, or vice versa. Docker Desktop runs a hidden Linux VM to make this look seamless.
  • Isolation is good, but it is a weaker security boundary than a VM, because a kernel vulnerability is shared by every container on the host.
  • Startup is sub-second precisely because nothing boots.
CMD versus ENTRYPOINT — and what does docker run my-app echo hi do to each?

ENTRYPOINT is the command that always runs. CMD is the default arguments, and anything you type after the image name replaces it.

Dockerfiledocker run imgdocker run img echo hi
CMD ["node","a.js"]node a.jsecho hi
ENTRYPOINT ["node","a.js"]node a.jsnode a.js echo hi
ENTRYPOINT ["node"] + CMD ["a.js"]node a.jsnode echo hi

The third row is the pattern you want in production: ENTRYPOINT fixes the binary, CMD supplies overridable defaults.

Your container has a zombie process problem and docker stop takes 10 seconds every time. Why?

Your application is PID 1, and PID 1 has responsibilities it was not written for.

In Linux, PID 1 is the init process. It has two special duties: reaping orphaned child processes, and it does not get default signal handlers. A normal app made PID 1 therefore:

  • never reaps children, so dead subprocesses pile up as zombies;
  • ignores SIGTERM unless it explicitly installs a handler, so docker stop falls through to the 10-second timeout and SIGKILL.
docker stop │ │ SIGTERM → PID 1 (your app, no handler) → ignored │ │ ... waits --time=10 (the default) ... ↓ SIGKILL → killed dead, no clean-up, connections dropped

Fixes, in order of preference: handle SIGTERM in the app; or run docker run --init / add tini as the entrypoint so a real init sits at PID 1; in Kubernetes, shareProcessNamespace and a proper preStop hook.

An image is 1.2 GB. Walk me through getting it to under 100 MB.

Answer as an ordered investigation, not a list of tricks:

  1. Look first. docker history --no-trunc my-app:1.0 shows the size of every layer. Fix the biggest one, not the easiest one.
  2. Multi-stage build. Compile in a fat stage, COPY --from=build only the artefact. This is usually the single biggest win.
  3. Smaller base. node:22 is ~1.1 GB; node:22-alpine is ~130 MB. Check your dependencies tolerate musl libc first — native modules sometimes do not.
  4. .dockerignore. COPY . . with no ignore file ships node_modules, .git and local .env files into the image.
  5. Combine RUN layers that create then delete. apt-get install followed by rm -rf /var/lib/apt/lists/* must be in the same RUN, or the deletion just adds a layer and the files stay in the earlier one.
We mount /var/run/docker.sock into our CI container so it can build images. What did we just do?

You gave that container root on the host.

Anything that can write to the Docker socket can ask the daemon to start a new container with --privileged, mounting / from the host. That is a complete, trivial escape. The same is true of adding a user to the docker group — it is equivalent to granting passwordless sudo.

Alternatives: a rootless builder such as BuildKit in rootless mode, Kaniko, or Buildah; or a dedicated build service the CI job calls rather than a socket it controls.

Key takeaways#

  • Dockerfile → image → container. Recipe, snapshot, running process.
  • -p host:container — outside first. This is the most-reversed flag.
  • Copy dependency manifests before source code. It keeps the slow install step cached and is the biggest single build-speed win.
  • No volume means no data. Container filesystems are temporary by design.
  • Pin versions. latest is a moving label, not a version.
  • A container is a process with a restricted view, not a small VM — which is why it starts instantly and why it shares your kernel.
  • Use the array form of CMD/ENTRYPOINT. The shell form makes sh PID 1, which swallows SIGTERM and turns every stop into a 10-second kill.

Last updated 2026-09-08