A Dockerfile Is a Build Script, Not a Config File
One-line summary
Dockerfile instructions fall into two groups: those that create layers (RUN, COPY, ADD) and those that stamp values into the image configuration (ENV, CMD, ENTRYPOINT, USER, LABEL, EXPOSE). If you know which group an instruction belongs to, you can avoid about half of the size problems and security problems in advance.
Why this is needed
The accident that repeats most often is secrets. If you put a token in with ENV NPM_TOKEN=..., the token stays permanently in the image configuration.
docker inspect -f '{{json .Config.Env}}' bad:v1
# "NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ"
Receiving it with ARG and deleting it with rm ~/.npmrc does not help either. The file remains in the lower layer, and with the classic builder it remains in the history too. If you pushed it to a registry, you must treat that token as already leaked and revoke it. Deleting the image cannot undo it.
How it works
Several instructions behave differently from how they look.
ADD is not a superset of COPY. ADD automatically unpacks a local tar and also downloads URLs. Those extra behaviors are mostly the problem. If an archive contains a path with ../, files are written to unintended locations, and URL downloads have no checksum verification. The default is COPY, and the only legitimate use of ADD is when you deliberately unpack a local tar.
COPY --chown and RUN chown -R differ by a factor of two in size. If COPY . /app is 286 MB, the RUN chown -R app:app /app that follows rewrites all the files into a new layer, so it writes another 286 MB. If you add one option to COPY, it ends with a single layer.
Always write USER as a numeric UID. Kubernetes' runAsNonRoot cannot tell from a name whether the user is root, and rejects the Pod.
Error: container has runAsNonRoot and image has non-numeric user (app),
cannot verify user is non-root
There are two misunderstandings about HEALTHCHECK. Even on failure the container is not restarted but only marked unhealthy, and Kubernetes ignores the image's HEALTHCHECK entirely. You have to define probes separately.
What it looks like in the field
A review checklist needs only five lines.
- Is
FROMpinned? (latestremoves the target you would roll back to) - Is
USERa numeric UID? - Are
CMD/ENTRYPOINTJSON arrays (exec form)? - Do secrets avoid coming in through
ARG/ENV? - Are no build tools left in the final stage?
A common objection is "if you pin the tag, you can't get security patches", but updates can be handled by a bot opening a PR, which is merged after CI verifies it, instead of by a person. It is the difference between something flowing in automatically and something coming in after verification.
Confusing pairs of instructions
| Difference | |
|---|---|
COPY vs ADD |
ADD downloads URLs and unpacks tar automatically. That makes it hard to predict. The default is COPY |
CMD vs ENTRYPOINT |
ENTRYPOINT is what to run, CMD is the default arguments. Arguments to docker run override CMD |
ENV vs ARG |
ENV remains at run time too, while ARG exists only during the build |
RUN vs CMD |
RUN is at build time, CMD is at run time |
The combination of ENTRYPOINT + CMD is the most useful.
ENTRYPOINT ["python", "-m", "app"]
CMD ["--port", "8080"]
docker run myapp → python -m app --port 8080
docker run myapp --port 9000 → python -m app --port 9000
You must not pass secrets through ARG. They remain as they are in docker history. Use a build secret (RUN --mount=type=secret).
Shell form and exec form
CMD python app.py # 셸 형식 — /bin/sh -c 로 감싸진다
CMD ["python", "app.py"] # exec 형식 — 그대로 실행
Use the exec form. In the shell form, sh becomes PID 1 and does not forward SIGTERM to the application. When you stop the container, it waits 10 seconds and then is killed.
However, in the exec form environment variable substitution does not work. CMD ["echo", "$HOME"] prints $HOME as it is. If you need variables, put in an entrypoint script and use exec "$@" at the end.
Making the build reproducible
FROM python:3.12-slim@sha256:abc123… # 태그가 아니라 다이제스트로 고정
Tags move. python:3.12-slim may be different today than yesterday, and then "it worked yesterday" begins. Pin production images by digest.
Pin dependencies too.
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
--no-cache-dir keeps the pip cache out of the layer, so the image gets smaller. If you use BuildKit's mount cache, you can keep the build fast and the image small.
Things that are often missed
- Put
USERlast — theRUNinstructions after it run as that user. Put work that needs file permissions before it. WORKDIRcreates the directory if it does not exist — you do not needRUN mkdir.- Write
.dockerignorefirst — it reduces both the amount of context transferred and cache invalidation. - Order matters more than the number of layers — these days the number of layers itself is not a problem. Moving what changes less toward the top is far more effective.
What you will do in the next lab
You will add instructions one at a time and build with a different tag each time, check with inspect which field of the image each instruction is stamped into, and finally complete a Dockerfile that passes the checklist.