ข้ามไปยังเนื้อหา

พื้นฐาน Dockerfile

วัดบน Docker Desktop 4.81.0 / Engine 29.6.1, Buildx v0.35.0-desktop.2, BuildKit v0.31.1 (macOS, linux/arm64), 2026-09-30 transcript ของการ build ใช้ --progress=plain (หน้าตาของ log ใน CI); terminal แบบ interactive แสดงขั้นตอนเดียวกันเป็นตาราง [+] Building สด

Dockerfile คือลำดับของ instruction Docker อ่านจากบนลงล่างและรันทีละตัว นี่คือ instruction ที่คุณจะใช้ในเกือบทุก Dockerfile

ทุก Dockerfile ต้องเริ่มด้วย FROM (มีเพียง ARG และ comment ที่มาก่อนได้) มันกำหนดจุดเริ่มต้น — base image ที่ทุก instruction ถัดไปสร้างต่อยอด

FROM node:24-alpine

node:24-alpine คือ image ทางการของ Node.js ที่สร้างบน Alpine Linux เลือก base image ที่เฉพาะเจาะจงและเล็กที่สุดที่ตอบโจทย์เสมอ และ pin tag เวอร์ชัน (ไม่ใช่ latest)

WORKDIR กำหนดไดเรกทอรีทำงานปัจจุบันสำหรับ instruction ถัดไปทั้งหมด และสร้างให้ถ้ายังไม่มี ช่วยให้ไม่ต้องฮาร์ดโค้ด path ทุกที่

WORKDIR /app

พิสูจน์แล้ว: WORKDIR /a/b/c ตามด้วย RUN pwd พิมพ์ /a/b/c ทั้งที่ทั้งสามไดเรกทอรีไม่มีอยู่ใน alpine:3.24 instruction COPY, RUN และ CMD ที่ตามมาทำงานเทียบกับมัน

COPY คัดลอกไฟล์หรือไดเรกทอรีจาก build context (โฟลเดอร์โปรเจกต์บน host) เข้า filesystem ของ image

COPY package.json package-lock.json ./
COPY src/ ./src/

อาร์กิวเมนต์แรกคือต้นทาง (เทียบกับ build context) อาร์กิวเมนต์ที่สองคือปลายทางใน image (เทียบกับ WORKDIR)

RUN รันคำสั่งและ commit ผลลัพธ์เป็น layer ใหม่ ใช้ติดตั้ง dependency, compile โค้ด หรือทำ setup ครั้งเดียว

RUN npm ci --omit=dev

RUN มีสองรูปแบบ shell form (RUN echo "$HOME") รันผ่าน /bin/sh -c ตัวแปร && และ pipe จึงใช้ได้ exec form (RUN ["echo", "$HOME"]) รันโปรแกรมโดยตรงโดยไม่มี shell พิสูจน์ด้วยข้อความเดียวกันทั้งสองแบบ:

#5 0.111 exec form: $HOME
#6 0.109 shell form: /root

exec form ไม่ขยาย $HOME ให้ใช้ shell form สำหรับ RUN เว้นแต่ต้องการ executable เฉพาะโดยไม่ผ่าน shell

ENV ตั้ง environment variable ที่ถูกฝังใน image และใช้ได้ทั้งตอน build และตอนรัน

ENV NODE_ENV=production
ENV PORT=3000

ARG กำหนดตัวแปร ตอน build ตั้งด้วย --build-arg และไม่มีอยู่ตอนรัน:

Dockerfile.args
# @build
FROM alpine:3.24
ARG APP_VERSION=dev
ENV APP_ENV=production
RUN echo "building $APP_VERSION" > /version.txt
CMD ["sh", "-c", "echo APP_ENV=$APP_ENV APP_VERSION=${APP_VERSION:-unset}; cat /version.txt"]
$ docker build --build-arg APP_VERSION=1.4.2 -t args .
$ docker run --rm args
APP_ENV=production APP_VERSION=unset # ENV survives into the container, ARG does not
building 1.4.2 # but the ARG was visible during the build
$ docker run --rm -e APP_ENV=staging args
APP_ENV=staging APP_VERSION=unset # an ENV default can be overridden at run time

ทั้งสองไม่ใช่ที่สำหรับความลับ: docker history พิมพ์ ENV APP_ENV=production และแม้แต่ ARG APP_VERSION=1.4.2 (ดูบทเรียน security ในโมดูล optimization และ bug ด้านล่าง)

EXPOSE เป็นเอกสาร — บอกผู้อ่านและเครื่องมือว่าแอปฟังที่ port ไหน มัน ไม่ได้ publish port จริง คุณยังต้องใช้ -p ตอน docker run

EXPOSE 3000

พิสูจน์กับ image ที่ build ด้านล่าง: หลัง docker run -d myapp:1.0 docker port ไม่พิมพ์อะไร (exit 0), docker ps แสดง 3000/tcp โดยไม่มีลูกศร และ curl http://localhost:3000 จาก Mac ได้ 000 (connection refused) มีเพียง -p 127.0.0.1:18030:3000 ที่ทำให้ curl คืน Hello from Docker! NODE_ENV=production EXPOSE สำคัญเฉพาะกับ -P (publish ทุก port ที่ expose) และในฐานะเอกสาร

ADD ทำทุกอย่างที่ COPY ทำ บวกเพิ่มอีกสองอย่าง: ดึง URL ได้ และ แตกไฟล์ tar ในเครื่องอัตโนมัติ ความประหลาดใจนั้นคือเหตุผลที่เอกสารแนะนำ COPY เว้นแต่ต้องการส่วนเสริม พิสูจน์ด้วย pack.tgz ที่มี a.txt:

Dockerfile.add
FROM alpine:3.24
COPY pack.tgz /copy/pack.tgz
ADD pack.tgz /add/
RUN ls -R /copy /add
#8 0.102 /add:
#8 0.102 a.txt # ADD unpacked the archive
#8 0.102 /copy:
#8 0.102 pack.tgz # COPY left it alone

(fence นั้นต้องมี pack.tgz อยู่ข้าง ๆ การตรวจสอบจึงเป็น docker buildx build --check เท่านั้น ดูบทเรียน build & tag สำหรับ build context)

นี่คือโปรเจกต์ที่ครบในตัวสำหรับ HTTP server ของ Node.js แบบเล็กที่สุด ไฟล์เล็ก ๆ สามไฟล์:

package.json
{
"name": "myapp",
"version": "1.0.0",
"private": true,
"scripts": { "start": "node src/server.js" }
}
package-lock.json
{
"name": "myapp",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "myapp",
"version": "1.0.0"
}
}
}
src/server.js
const http = require('http');
const port = Number(process.env.PORT) || 3000;
http
.createServer((req, res) => res.end(`Hello from Docker! NODE_ENV=${process.env.NODE_ENV}\n`))
.listen(port, '0.0.0.0', () => console.log(`Listening on ${port}`));

(server ฟังที่ 0.0.0.0 เพื่อให้ port ที่ publish เข้าถึงได้ — ดูบทเรียน ports) และ Dockerfile:

# Dockerfile
# @build
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "src/server.js"]

Build:

Terminal window
docker build -t myapp:1.0 .

output ของการ build จริง (cache ว่าง --no-cache --progress=plain ตัดเหลือบรรทัดขั้นตอน):

#4 [1/5] FROM docker.io/library/node:24-alpine
#5 [2/5] WORKDIR /app
#7 [3/5] COPY package.json package-lock.json ./
#8 [4/5] RUN npm ci --omit=dev
#8 0.367 up to date, audited 1 package in 201ms
#8 0.367 found 0 vulnerabilities
#8 DONE 0.4s
#9 [5/5] COPY src/ ./src/
#10 exporting to image
#10 naming to docker.io/library/myapp:1.0 done

มี ห้า ขั้นตอนที่มีเลขกำกับ ไม่ใช่หก: ENV, EXPOSE และ CMD เปลี่ยนแค่ metadata ของ image และไม่ปรากฏเป็นขั้นตอน docker history แสดงมันเป็น entry ขนาดศูนย์แทรกอยู่ระหว่างตัวจริง:

$ docker history myapp:1.0
CREATED BY SIZE
CMD ["node" "src/server.js"] 0B
EXPOSE [3000/tcp] 0B
ENV PORT=3000 0B
ENV NODE_ENV=production 0B
COPY src/ ./src/ # buildkit 249B
RUN /bin/sh -c npm ci --omit=dev # buildkit 1.39MB
COPY package.json package-lock.json ./ # bui… 284B
WORKDIR /app 0B
CMD ["node"] ... # (from the node:24-alpine base)
...

ให้นับ filesystem layer จริงแทนการนับบรรทัด history: docker image inspect node:24-alpine myapp:1.0 --format '{{len .RootFS.Layers}}' ได้ 4 สำหรับ base และ 8 สำหรับ image ของแอป สี่ตัวที่เพิ่มคือ WORKDIR (สร้าง /app), COPY แรก, RUN npm ci และ COPY ที่สอง ENV, EXPOSE และ CMD ไม่เพิ่มเลย (WORKDIR แสดง 0B เพราะไดเรกทอรีว่างเล็กมาก — แต่ก็ยังเป็น layer)

รัน container:

Terminal window
docker run --rm -p 127.0.0.1:3000:3000 myapp:1.0

curl http://127.0.0.1:3000 พิมพ์ Hello from Docker! NODE_ENV=production เมื่อใช้ -e PORT=4000 image เดียวกันบันทึก Listening on 4000 เพราะแอปอ่าน PORT ตอน start และ ENV เป็นแค่ค่าเริ่มต้น

snippet ด้านล่างมี Dockerfile ที่ครบในตัวและ server.js เล็ก ๆ แบบ inline ที่เขียนด้วย echo ของ shell — ไม่ต้องใช้ไฟล์ภายนอก วางใน Play with Docker แล้วรัน

# syntax=docker/dockerfile:1
FROM node:24-alpine
WORKDIR /app
# Inline a minimal server so no host files are needed
RUN echo 'const h=require("http");h.createServer((_,r)=>{r.writeHead(200);r.end("Hello from Docker!\n");}).listen(3000,()=>console.log("Listening on 3000"));' > server.js
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "server.js"]
# --- build & run ---
# docker build -t myapp .
# docker run --rm -p 3000:3000 myapp
# curl http://localhost:3000

ตัวอย่างคลาสสิกคือ apt-get package index และ cache ถูกลบได้เฉพาะจาก layer ที่เขียนมัน ดังนั้น rm ต้องอยู่ใน RUN เดียวกัน Dockerfile สองแบบจาก debian:bookworm (base 139 MB) ที่ติดตั้ง curl และ ca-certificates:

Dockerfile.apt-two
# @build
FROM debian:bookworm
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates
RUN rm -rf /var/lib/apt/lists/*
Dockerfile.apt-one
# @build
FROM debian:bookworm
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
debian:bookworm 139MB
apt-one (one RUN) 156MB # the install layer is 16.6MB
apt-two (two RUNs) 175MB # the install layer is 36.2MB and the rm layer hides 19.6MB of lists

filesystem สุดท้ายเหมือนกัน แต่ต่างกัน 19 MB (ทั้งสอง build ต้องเข้าถึง Debian mirror ผ่านเครือข่าย และตัวเลขเป็นแพ็กเกจ arm64)

Dockerfile ที่ AI เขียนสำหรับแอปที่ “ต้องใช้ API key”:

🐛 หา bug ในโค้ดนี้

FROM alpine:3.24
# AI: "bake the key in so the container works out of the box"
ENV API_KEY=<your-api-key>
CMD ["sh", "-c", "echo calling api with $API_KEY"]
เฉลย

ENV ฝังค่าลงใน config ของ image ซึ่งทุกคนที่ใช้ image อ่านได้โดยไม่ต้องรันอะไร พิสูจน์แล้ว (placeholder เป็นค่าปลอมโดยตั้งใจ):

$ docker history --no-trunc secret-demo
CMD ["sh" "-c" "echo calling api with $API_KEY"]
ENV API_KEY=<your-api-key>
$ docker image inspect secret-demo --format '{{json .Config.Env}}'
["PATH=/usr/local/sbin:...:/bin","API_KEY=<your-api-key>"]

การตรวจแบบ static จับความผิดพลาดประเภทนี้ได้ด้วย: docker buildx build --check รายงาน WARNING: SecretsUsedInArgOrEnv - Do not use ARG or ENV instructions for sensitive data (ENV "API_KEY") ซึ่งคือสิ่งที่ harness ของคอร์สนับเป็นความล้มเหลว ใครก็ตามที่ pull image ได้ก็ได้ key และมันติดอยู่ในประวัติ layer แม้ภายหลังคุณจะ ENV API_KEY= ทิ้ง อย่าฝังค่าลงไป ให้บังคับส่งตอนรันและล้มดัง ๆ เมื่อไม่มี:

Dockerfile.secret-fixed
# @build
FROM alpine:3.24
CMD ["sh", "-c", "echo calling api with ${API_KEY:?API_KEY must be set}"]
$ docker run --rm secret-fixed
sh: API_KEY: API_KEY must be set # exit code 2
$ docker run --rm -e API_KEY='<your-api-key>' secret-fixed
calling api with <your-api-key>
$ docker image inspect secret-fixed --format '{{json .Config.Env}}'
["PATH=/usr/local/sbin:...:/bin"] # nothing baked in

ความลับตอน build ใช้ RUN --mount=type=secret ซึ่งอยู่ในโมดูล optimization ความลับตอนรันควรอยู่ใน secret store ไม่ใช่ -e บน host ที่ใช้ร่วมกัน (ดูบทเรียน ports & env)

อ่าน Dockerfile เป็น สองรายการที่สลับกัน: instruction ที่เปลี่ยน filesystem (RUN, COPY, ADD และบางครั้ง WORKDIR) กับ instruction ที่เปลี่ยน config ของ image (ENV, EXPOSE, CMD, ENTRYPOINT, ARG, LABEL) มีเฉพาะรายการแรกที่ทำให้เกิดขั้นตอน build ที่มีเลขและ layer รายการ config คือสิ่งที่ container เริ่มต้นด้วย: environment เริ่มต้น คำสั่งเริ่มต้น port ที่บันทึกไว้ ไม่มีอะไรในรายการ config ที่เปิด port หรือซ่อนความลับ

ทางเลือกข้อดีข้อเสีย
รวมคำสั่งด้วย && ใน RUN เดียวlayer น้อยลง; การล้างทำให้ image เล็กลงจริง (156 MB เทียบ 175 MB)บรรทัดยาว อ่านยากขึ้น ดูยากว่าส่วนไหนล้มเหลว
แยกเป็นหลายบรรทัด RUNอ่านง่าย แต่ละขั้นชัดเจนและ cache แยกกันlayer มากขึ้น image ใหญ่กว่าที่จำเป็นเมื่อการล้างอยู่ในขั้นถัดไป
COPYคาดเดาได้: คัดลอกเฉพาะที่คุณระบุแตก archive หรือดาวน์โหลดไม่ได้
ADDแตก tarball ในเครื่องอัตโนมัติ ดึง URL ได้พฤติกรรมชวนประหลาดใจ; การดึงจากระยะไกลไม่เป็นมิตรกับ cache หรือ checksum
ENV ค่าเริ่มต้น + override ด้วย -eค่าเริ่มต้นที่เหมาะสม ยังปรับได้ค่าปรากฏใน image; ไม่เหมาะกับความลับ
  • แยกการล้างเป็น RUN ต่างหากจากการติดตั้ง ทำให้ image เก็บไฟล์ที่ลบไปแล้ว (apt lists 19 MB ที่นี่)
  • ไม่ตั้ง WORKDIR แล้วฮาร์ดโค้ด path เต็มซ้ำ ๆ ในทุก instruction ทำให้การเปลี่ยน path ต้องแก้หลายที่
  • คิดว่า EXPOSE เปิด port จริง ทั้งที่เป็นแค่เอกสาร — คุณยังต้องใช้ -p ตอน docker run
  • ใส่ credential ใน ENV หรือ ARG ทั้งคู่อ่านได้ใน docker history และ docker inspect
  • ใช้ exec form ของ RUN แล้วคาดว่า $VARIABLE จะถูกขยาย

หน้า Dockerfile best practices ครอบคลุมการ pin base image, การเรียงเพื่อ cache, multi-stage build และ COPY แทน ADD; รูปแบบที่แนะนำคือสิ่งที่ Dockerfile ในบทเรียนนี้ทำตาม

  • “FROM, RUN, COPY และ ENV แต่ละตัวสร้าง layer” มีเพียง RUN/COPY/ADD (และ WORKDIR ที่สร้างไดเรกทอรี) การ build ข้างบนมี 5 ขั้นตอนและ 4 layer ใหม่จาก 9 instruction
  • “log ของ build แสดงหนึ่งขั้นตอนต่อหนึ่ง instruction” ENV, EXPOSE, CMD ไม่ใช่ขั้นตอนที่มีเลข; log บอก [5/5] สำหรับ COPY ตัวสุดท้าย
  • “EXPOSE 3000 หมายความว่า host เข้าถึง port 3000 ได้” docker port ไม่พิมพ์อะไรและ curl ถูกปฏิเสธจนกว่าจะใช้ -p
  • “ARG เป็นวิธีที่ปลอดภัยในการส่ง token” มันถูกเก็บในประวัติ image (ARG APP_VERSION=1.4.2 มองเห็นได้)
  • “ADD คือ COPY ที่ fancy กว่า” มันแตก pack.tgz; COPY ไม่ทำ
instruction ใดกำหนดไดเรกทอรีทำงานสำหรับ instruction ถัดไปทั้งหมดใน Dockerfile?
EXPOSE ทำอะไรจริง ๆ?
ทำไมควรรวมคำสั่ง shell หลายตัวใน RUN เดียวด้วย &&?
instruction ใดคัดลอกไฟล์จาก build context บน host เข้า image?
Dockerfile ที่มี WORKDIR, COPY, RUN, COPY, ENV, ENV, EXPOSE, CMD บน base 4 layer build ได้กี่ขั้นตอนที่มีเลข และ image มีกี่ layer?
Dockerfile มี ENV API_KEY=<your-api-key> ใครอ่านค่านี้ได้?