พื้นฐาน 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 สด
Instruction หลักของ Dockerfile
หัวข้อที่มีชื่อว่า “Instruction หลักของ Dockerfile”Dockerfile คือลำดับของ instruction Docker อ่านจากบนลงล่างและรันทีละตัว นี่คือ instruction ที่คุณจะใช้ในเกือบทุก Dockerfile
FROM — เลือก base
หัวข้อที่มีชื่อว่า “FROM — เลือก base”ทุก Dockerfile ต้องเริ่มด้วย FROM (มีเพียง ARG และ comment ที่มาก่อนได้) มันกำหนดจุดเริ่มต้น — base image ที่ทุก instruction ถัดไปสร้างต่อยอด
FROM node:24-alpinenode:24-alpine คือ image ทางการของ Node.js ที่สร้างบน Alpine Linux เลือก base image ที่เฉพาะเจาะจงและเล็กที่สุดที่ตอบโจทย์เสมอ และ pin tag เวอร์ชัน (ไม่ใช่ latest)
WORKDIR — กำหนดไดเรกทอรีทำงาน
หัวข้อที่มีชื่อว่า “WORKDIR — กำหนดไดเรกทอรีทำงาน”WORKDIR กำหนดไดเรกทอรีทำงานปัจจุบันสำหรับ instruction ถัดไปทั้งหมด และสร้างให้ถ้ายังไม่มี ช่วยให้ไม่ต้องฮาร์ดโค้ด path ทุกที่
WORKDIR /appพิสูจน์แล้ว: WORKDIR /a/b/c ตามด้วย RUN pwd พิมพ์ /a/b/c ทั้งที่ทั้งสามไดเรกทอรีไม่มีอยู่ใน alpine:3.24 instruction COPY, RUN และ CMD ที่ตามมาทำงานเทียบกับมัน
COPY — นำไฟล์เข้า image
หัวข้อที่มีชื่อว่า “COPY — นำไฟล์เข้า image”COPY คัดลอกไฟล์หรือไดเรกทอรีจาก build context (โฟลเดอร์โปรเจกต์บน host) เข้า filesystem ของ image
COPY package.json package-lock.json ./COPY src/ ./src/อาร์กิวเมนต์แรกคือต้นทาง (เทียบกับ build context) อาร์กิวเมนต์ที่สองคือปลายทางใน image (เทียบกับ WORKDIR)
RUN — รันคำสั่งระหว่าง build
หัวข้อที่มีชื่อว่า “RUN — รันคำสั่งระหว่าง build”RUN รันคำสั่งและ commit ผลลัพธ์เป็น layer ใหม่ ใช้ติดตั้ง dependency, compile โค้ด หรือทำ setup ครั้งเดียว
RUN npm ci --omit=devRUN มีสองรูปแบบ 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: /rootexec form ไม่ขยาย $HOME ให้ใช้ shell form สำหรับ RUN เว้นแต่ต้องการ executable เฉพาะโดยไม่ผ่าน shell
ENV และ ARG — configuration
หัวข้อที่มีชื่อว่า “ENV และ ARG — configuration”ENV ตั้ง environment variable ที่ถูกฝังใน image และใช้ได้ทั้งตอน build และตอนรัน
ENV NODE_ENV=productionENV PORT=3000ARG กำหนดตัวแปร ตอน build ตั้งด้วย --build-arg และไม่มีอยู่ตอนรัน:
# @buildFROM alpine:3.24ARG APP_VERSION=devENV APP_ENV=productionRUN echo "building $APP_VERSION" > /version.txtCMD ["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 argsAPP_ENV=production APP_VERSION=unset # ENV survives into the container, ARG does notbuilding 1.4.2 # but the ARG was visible during the build$ docker run --rm -e APP_ENV=staging argsAPP_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
หัวข้อที่มีชื่อว่า “EXPOSE — บันทึก port”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) และในฐานะเอกสาร
COPY หรือ ADD?
หัวข้อที่มีชื่อว่า “COPY หรือ ADD?”ADD ทำทุกอย่างที่ COPY ทำ บวกเพิ่มอีกสองอย่าง: ดึง URL ได้ และ แตกไฟล์ tar ในเครื่องอัตโนมัติ ความประหลาดใจนั้นคือเหตุผลที่เอกสารแนะนำ COPY เว้นแต่ต้องการส่วนเสริม พิสูจน์ด้วย pack.tgz ที่มี a.txt:
FROM alpine:3.24COPY pack.tgz /copy/pack.tgzADD 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)
Build image ของแอปจริง
หัวข้อที่มีชื่อว่า “Build image ของแอปจริง”นี่คือโปรเจกต์ที่ครบในตัวสำหรับ HTTP server ของ Node.js แบบเล็กที่สุด ไฟล์เล็ก ๆ สามไฟล์:
{ "name": "myapp", "version": "1.0.0", "private": true, "scripts": { "start": "node src/server.js" }}{ "name": "myapp", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "myapp", "version": "1.0.0" } }}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# @buildFROM node:24-alpineWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=devCOPY src/ ./src/ENV NODE_ENV=productionENV PORT=3000EXPOSE 3000CMD ["node", "src/server.js"]Build:
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.0CREATED BY SIZECMD ["node" "src/server.js"] 0BEXPOSE [3000/tcp] 0BENV PORT=3000 0BENV NODE_ENV=production 0BCOPY src/ ./src/ # buildkit 249BRUN /bin/sh -c npm ci --omit=dev # buildkit 1.39MBCOPY package.json package-lock.json ./ # bui… 284BWORKDIR /app 0BCMD ["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:
docker run --rm -p 127.0.0.1:3000:3000 myapp:1.0curl 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ล้างใน layer เดียวกัน: วัดผลจริง
หัวข้อที่มีชื่อว่า “ล้างใน layer เดียวกัน: วัดผลจริง”ตัวอย่างคลาสสิกคือ apt-get package index และ cache ถูกลบได้เฉพาะจาก layer ที่เขียนมัน ดังนั้น rm ต้องอยู่ใน RUN เดียวกัน Dockerfile สองแบบจาก debian:bookworm (base 139 MB) ที่ติดตั้ง curl และ ca-certificates:
# @buildFROM debian:bookwormRUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificatesRUN rm -rf /var/lib/apt/lists/*# @buildFROM debian:bookwormRUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/*debian:bookworm 139MBapt-one (one RUN) 156MB # the install layer is 16.6MBapt-two (two RUNs) 175MB # the install layer is 36.2MB and the rm layer hides 19.6MB of listsfilesystem สุดท้ายเหมือนกัน แต่ต่างกัน 19 MB (ทั้งสอง build ต้องเข้าถึง Debian mirror ผ่านเครือข่าย และตัวเลขเป็นแพ็กเกจ arm64)
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”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-demoCMD ["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= ทิ้ง อย่าฝังค่าลงไป ให้บังคับส่งตอนรันและล้มดัง ๆ เมื่อไม่มี:
# @buildFROM alpine:3.24CMD ["sh", "-c", "echo calling api with ${API_KEY:?API_KEY must be set}"]$ docker run --rm secret-fixedsh: API_KEY: API_KEY must be set # exit code 2$ docker run --rm -e API_KEY='<your-api-key>' secret-fixedcalling 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)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”อ่าน Dockerfile เป็น สองรายการที่สลับกัน: instruction ที่เปลี่ยน filesystem (RUN, COPY, ADD และบางครั้ง WORKDIR) กับ instruction ที่เปลี่ยน config ของ image (ENV, EXPOSE, CMD, ENTRYPOINT, ARG, LABEL) มีเฉพาะรายการแรกที่ทำให้เกิดขั้นตอน build ที่มีเลขและ layer รายการ config คือสิ่งที่ container เริ่มต้นด้วย: environment เริ่มต้น คำสั่งเริ่มต้น port ที่บันทึกไว้ ไม่มีอะไรในรายการ config ที่เปิด port หรือซ่อนความลับ
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
รวมคำสั่งด้วย && ใน RUN เดียว | layer น้อยลง; การล้างทำให้ image เล็กลงจริง (156 MB เทียบ 175 MB) | บรรทัดยาว อ่านยากขึ้น ดูยากว่าส่วนไหนล้มเหลว |
แยกเป็นหลายบรรทัด RUN | อ่านง่าย แต่ละขั้นชัดเจนและ cache แยกกัน | layer มากขึ้น image ใหญ่กว่าที่จำเป็นเมื่อการล้างอยู่ในขั้นถัดไป |
COPY | คาดเดาได้: คัดลอกเฉพาะที่คุณระบุ | แตก archive หรือดาวน์โหลดไม่ได้ |
ADD | แตก tarball ในเครื่องอัตโนมัติ ดึง URL ได้ | พฤติกรรมชวนประหลาดใจ; การดึงจากระยะไกลไม่เป็นมิตรกับ cache หรือ checksum |
ENV ค่าเริ่มต้น + override ด้วย -e | ค่าเริ่มต้นที่เหมาะสม ยังปรับได้ | ค่าปรากฏใน image; ไม่เหมาะกับความลับ |
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- แยกการล้างเป็น
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 ในบทเรียนนี้ทำตาม
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
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ไม่ทำ