- สารบัญ
- 1. Multi-stage Build คืออะไร
- 2. ความแตกต่างระหว่าง Single-stage และ Multi-stage
- 3. โครงสร้างการทำงาน
- 4. Workshop: สร้าง Go HTTP API
- 5. เขียน Dockerfile แบบ Multi-stage
- 6. Build และ Run
- 7. เปรียบเทียบขนาด Image
- 8. ใช้งานกับ Docker Compose
- 9. แนวทางที่แนะนำสำหรับ Production
- 10. ตัวอย่าง Multi-stage สำหรับ Node.js และ Java
- 11. แก้ปัญหาที่พบบ่อย
- 12. แบบฝึกหัดและสรุป
- แหล่งอ้างอิง
เป้าหมาย: ใช้ Dockerfile เพียงไฟล์เดียวในการทดสอบและ Compile แอปพลิเคชัน จากนั้นย้ายเฉพาะไฟล์โปรแกรมที่จำเป็นไปยัง Docker Image สำหรับ Production
ตัวอย่างหลัก: Go HTTP API โดยใช้
golang:1.26-alpineสำหรับ Build และscratchสำหรับ Runtimeข้อกำหนด: Docker Engine หรือ Docker Desktop ที่มี BuildKit รองรับ Dockerfile syntax รุ่นปัจจุบัน, Command Line และโปรแกรม
curl(Go SDK ไม่จำเป็นบนเครื่องผู้เรียน หาก Build ด้วย Docker)

#สารบัญ
- Multi-stage Build คืออะไร
- ความแตกต่างระหว่าง Single-stage และ Multi-stage
- โครงสร้างการทำงาน
- Workshop: สร้าง Go HTTP API
- เขียน Dockerfile แบบ Multi-stage
- Build และ Run
- เปรียบเทียบขนาด Image
- ใช้งานกับ Docker Compose
- แนวทางที่แนะนำสำหรับ Production
- ตัวอย่าง Multi-stage สำหรับ Node.js และ Java
- แก้ปัญหาที่พบบ่อย
- แบบฝึกหัดและสรุป
#1. Multi-stage Build คืออะไร
Multi-stage Build เป็นเทคนิคที่ใช้ FROM หลายครั้งใน Dockerfile เดียว เพื่อแยกขั้นตอนสร้างแอปพลิเคชันออกจากขั้นตอนรันแอปพลิเคชัน แต่ละ FROM จะเริ่ม Stage ใหม่ และสามารถตั้งชื่อ Stage ด้วย AS <name> ได้
FROM builder-image AS builder
# ติดตั้ง dependency, ทดสอบ และ build
FROM runtime-image AS runtime
COPY --from=builder /path/to/artifact /app/artifact
# สั่งรันแอปพลิเคชัน
ประโยชน์หลัก:
- Image ขนาดเล็กลง: Production ไม่จำเป็นต้องมี Compiler และ Build Tools
- ลด Attack Surface: ลดจำนวนเครื่องมือ/แพ็กเกจที่ไม่จำเป็นใน Image สุดท้าย (ไม่ได้หมายความว่าไม่มีช่องโหว่)
- แยกหน้าที่ชัดเจน: จัดการ Build, Test, Runtime ได้ใน Dockerfile เดียว
- ใช้ Build Cache ได้มีประสิทธิภาพ: จัดลำดับคำสั่งและเลือก Cache Mount ตามลักษณะโปรเจกต์
- รองรับ CI/CD: Build Stage ที่ต้องการด้วย
--target
Docker จะเลือก Stage สุดท้าย เป็นผลลัพธ์เริ่มต้นเมื่อไม่ระบุ
--targetและ Stage ก่อนหน้า ไม่ได้ถูกนำทั้งหมด ไปรวมใน Final Image โดยอัตโนมัติ แม้เครื่อง Build อาจยังเก็บ Cache หรือ Intermediate Image ไว้ในเครื่องก็ตาม [1]
#2. ความแตกต่างระหว่าง Single-stage และ Multi-stage
| หัวข้อ | Single-stage | Multi-stage |
|---|---|---|
| Dockerfile | ใช้ FROM หนึ่ง Stage เป็นหลัก |
ใช้ FROM หลาย Stage |
| Compiler/Build Tools ใน Final Image | มักมี ถ้าติดตั้งเพื่อ Build ใน Stage เดียว | สามารถตัดออกได้ |
| Source Code ใน Final Image | มักติดไปด้วย | คัดลอกเฉพาะ Artifact ได้ |
| ขนาด Image | มักใหญ่กว่าเมื่อมี Build Tools | มักเล็กกว่าหากเลือก Runtime Image เหมาะสม |
| ความปลอดภัย | มีแพ็กเกจเกินความจำเป็นได้ | ลดแพ็กเกจที่ไม่จำเป็นได้ แต่ยังต้องอัปเดตและสแกน |
| การ Debug | มี Shell/เครื่องมือช่วยมากกว่า | Runtime แบบ scratch ไม่มี Shell |
#ตัวอย่าง Single-stage Dockerfile
ไฟล์ Dockerfile.single สำหรับเปรียบเทียบ:
# Single-stage example for comparison only
FROM golang:1.26-alpine
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN go test ./...
RUN CGO_ENABLED=0 go build -o /src/server .
USER 65532:65532
EXPOSE 8080
CMD ["/src/server"]
ข้อสังเกต: Dockerfile แบบนี้ Build และ Run ได้ แต่ Runtime Image ยังคงมี Go SDK และไฟล์ Source Code จากขั้นตอน Build อยู่
#3. โครงสร้างการทำงาน
flowchart LR
A["Go source code"] --> B["Stage 1: builder (golang:1.26-alpine)"]
B --> C["Run unit tests"]
C --> D["Static binary: /out/server"]
D -->|"COPY --from=builder"| E["Stage 2: runtime (scratch)"]
E --> F["Final Production Image: /server"]
| คำสั่ง | ความหมาย |
|---|---|
FROM image AS builder |
เริ่ม Stage และตั้งชื่อให้เรียกอ้างอิงได้ |
WORKDIR /src |
กำหนดไดเรกทอรีทำงานใน Stage ปัจจุบัน |
COPY |
คัดลอกไฟล์เข้า Image ตาม Build Context |
RUN |
เรียกคำสั่งระหว่าง Build Image |
COPY --from=builder |
คัดลอกไฟล์จากอีก Stage โดยตรง |
USER |
กำหนดผู้ใช้สำหรับ Runtime และคำสั่งถัดไป |
EXPOSE |
ประกาศ Port ที่แอปใช้ ไม่ได้ Publish Port ออกสู่ Host |
ENTRYPOINT |
ระบุโปรแกรมหลักของ Container |
#4. Workshop: สร้าง Go HTTP API
โครงสร้างโปรเจกต์:
docker-multistage-workshop/
├── go.mod
├── main.go
├── main_test.go
├── Dockerfile
├── Dockerfile.single
├── .dockerignore
└── compose.yaml
#ขั้นตอน 4.1: กำหนด Go module (go.mod)
module example.com/docker-multistage-workshop
go 1.23
ใน Workshop นี้ใช้ไลบรารีมาตรฐานของ Go จึงไม่จำเป็นต้องมีไฟล์ go.sum เมื่อยังไม่มี External Module Dependencies
#ขั้นตอน 4.2: เขียน API (main.go)
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
fmt.Fprintln(w, `{"message":"Hello from Docker multi-stage build"}`)
})
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, "ok")
})
return mux
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, routes()))
}
API ที่ได้ (ค่าเริ่มต้นฟังที่ Port 8080 และปรับได้ด้วยตัวแปร PORT):
GET /คืนข้อความ JSONGET /healthzคืนข้อความokสำหรับตรวจสถานะ
#ขั้นตอน 4.3: เพิ่ม Unit Test (main_test.go)
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHealth(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200", rec.Code)
}
if got := strings.TrimSpace(rec.Body.String()); got != "ok" {
t.Fatalf("body=%q, want ok", got)
}
}
func TestRoot(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200", rec.Code)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
t.Fatalf("content-type=%q, want application/json", got)
}
}
ถ้าเครื่องผู้เรียนติดตั้ง Go อยู่แล้ว สามารถตรวจสอบโดยรัน:
go test ./...
go run .
จากนั้นเปิด Terminal อีกหน้าต่างหนึ่งแล้วใช้ curl http://localhost:8080/healthz
#5. เขียน Dockerfile แบบ Multi-stage
สร้างไฟล์ชื่อ Dockerfile:
# syntax=docker/dockerfile:1
# Stage 1: compile and test
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN --mount=type=cache,target=/root/.cache/go-build go test ./...
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server .
# Stage 2: minimal production runtime
FROM scratch AS runtime
WORKDIR /
COPY --from=builder /out/server /server
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]
#อธิบาย Stage แรก: builder
FROM golang:1.26-alpine AS builderใช้ Image ที่มี Go Toolchain สำหรับ BuildWORKDIR /srcตั้งตำแหน่งทำงานภายใน ContainerCOPY go.mod ./และCOPY *.go ./คัดลอกไฟล์ที่จำเป็นสำหรับตัวอย่างซึ่งมี Go files อยู่ที่รากโปรเจกต์ (โปรเจกต์ที่มีไดเรกทอรีย่อยควรปรับCOPYให้ครอบคลุม)go test ./...รัน Unit Test ระหว่าง Build หาก Test ไม่ผ่าน การ Build จะล้มเหลวCGO_ENABLED=0ช่วยให้ตัวอย่างนี้ Build เป็น Static Binary โดยไม่ต้องพึ่ง C runtime;-trimpathและ-ldflags="-s -w"ลดข้อมูลส่วนเกินใน BinaryRUN --mount=type=cacheใช้ BuildKit cache สำหรับไฟล์แคช Compile ของ Go เพื่อช่วยการ Build รอบถัดไป โดย Cache Mount นี้ไม่ถูกคัดลอกเข้า Final Image
#อธิบาย Stage สุดท้าย: runtime
FROM scratch AS runtimeเริ่มจาก Empty Base Image ที่ไม่มี Shell หรือ Package ManagerCOPY --from=builder /out/server /serverนำเฉพาะ Binary มาใส่ใน Runtime ImageUSER 65532:65532สั่งให้โปรเซสรันด้วย UID/GID ที่ไม่ใช่ Root แม้ Image แบบscratchจะไม่มี/etc/passwdก็ใช้ ID แบบตัวเลขได้EXPOSE 8080เป็นข้อมูลเกี่ยวกับ Port เท่านั้น ต้อง-pเพื่อ Publish Port ให้เรียกจาก Host ได้ENTRYPOINT ["/server"]เริ่มโปรแกรมเมื่อสร้าง Container
ข้อจำกัดของ scratch: เหมาะกับ Static Binary ที่ไม่ต้องใช้ Shell, Dynamic Libraries หรือ Runtime Resources เพิ่มเติม หากแอปเรียก HTTPS ภายนอก ต้องตรวจสอบการมี CA Certificates; หากต้องใช้ Timezone/ไฟล์ระบบอื่น หรือ Native Dependencies อาจต้องเพิ่มไฟล์ที่จำเป็นหรือเลือก Minimal Runtime เช่น Distroless อย่างเหมาะสม
#เพิ่มไฟล์ .dockerignore
.git
.github
.env
.env.*
*.log
.DS_Store
node_modules
bin
dist
tmp
การลด Build Context ช่วยให้ Build มีประสิทธิภาพขึ้น และลดโอกาสนำไฟล์ที่ไม่ควรใช้งาน (เช่น .env) เข้ากระบวนการ Build โดยยังต้องตรวจสอบ Context และ Secret อย่างรอบคอบ
#6. Build และ Run
#6.1 Build Final Image
เปิด Terminal ในโฟลเดอร์โปรเจกต์แล้วรัน:
docker build -t docker-multistage-go:multi .
หรือกำหนด Stage ปลายทางให้ชัดเจน:
docker build --target runtime -t docker-multistage-go:multi .
#6.2 เริ่ม Container
docker run -d --name multi-api -p 8080:8080 docker-multistage-go:multi
#6.3 ทดลอง API
curl http://localhost:8080/
curl http://localhost:8080/healthz
ผลลัพธ์ที่คาดหวัง:
{"message":"Hello from Docker multi-stage build"}
และคำสั่ง /healthz ตอบ ok
#6.4 ดู Log และหยุด Container
docker logs multi-api
docker stop multi-api
docker rm multi-api
#7. เปรียบเทียบขนาด Image
สร้าง Image 2 รูปแบบจาก Source Code ชุดเดียวกัน:
# Single-stage
docker build -f Dockerfile.single -t docker-multistage-go:single .
# Multi-stage
docker build -f Dockerfile -t docker-multistage-go:multi .
# ดูขนาดที่ Docker แสดง
docker image ls docker-multistage-go
# ดูขนาดเป็น Byte
docker image inspect docker-multistage-go:single --format '{{.Size}}'
docker image inspect docker-multistage-go:multi --format '{{.Size}}'
การตีความ: คาดว่าแบบ Multi-stage ในตัวอย่างจะมีขนาดน้อยกว่า เพราะไม่ได้บรรจุ Go SDK เข้า Image สุดท้าย แต่ขนาดจริงขึ้นกับ Base Image, Architecture, Docker Version, Build Options และ Artifact หลีกเลี่ยงการรายงานตัวเลขประหยัดพื้นที่เป็นเปอร์เซ็นต์หากยังไม่ได้วัดบนระบบจริง
นอกจากนี้ยังดูแต่ละ Layer ได้ด้วย:
docker history docker-multistage-go:single
docker history docker-multistage-go:multi
หากต้องการ Build เฉพาะ Builder Stage เพื่อตรวจสอบ:
docker build --target builder -t docker-multistage-go:builder .
#8. ใช้งานกับ Docker Compose
สร้าง compose.yaml:
services:
api:
build:
context: .
target: runtime
image: docker-multistage-go:local
ports:
- "8080:8080"
read_only: true
security_opt:
- no-new-privileges:true
คำสั่ง:
docker compose up -d --build
docker compose ps
docker compose logs -f api
curl http://localhost:8080/healthz
docker compose down
read_only: true กำหนดให้ Filesystem หลักของ Container เป็น Read-only เหมาะกับ API ตัวอย่างนี้ที่ไม่เขียนไฟล์ภายใน Container หากแอปของจริงต้องเขียนข้อมูล ควรจัด Volume หรือ Writable Mount ให้เหมาะสม
ข้อควรระวัง: ก่อนใช้ Compose ให้แน่ใจว่า Container ชื่อ
multi-apiจากขั้นตอนก่อนหน้าหยุดและถูกลบแล้ว เพื่อไม่ให้ Port 8080 ชนกัน
#9. แนวทางที่แนะนำสำหรับ Production
- แยก Build กับ Runtime: นำ Artifact เท่าที่จำเป็นเข้า Final Image
- ใช้ Base Image ที่เชื่อถือได้: เลือกเวอร์ชันที่ยังได้รับการดูแล ตรวจสอบ Security Advisories และปรับรุ่นสม่ำเสมอ
- Pin เวอร์ชันหรือ Digest: สำหรับการผลิตซ้ำอย่างแน่นอนควร Pin ไปยัง Digest; Tag เช่น
1.26-alpineอาจถูกอัปเดตได้ - รันเป็น Non-root: ใช้
USERหรือ Runtime Image แบบ Non-root เมื่อแอปรองรับ - ใช้
.dockerignore: ไม่คัดลอก.git, Dependency Caches, Logs หรือ Secrets ไปโดยไม่จำเป็น - Build แบบทดสอบได้: รัน Unit Test ใน Builder Stage หรือทำ Test เป็น Job ก่อน Build/Push Production Image
- Cache อย่างมีเหตุผล: คัดลอก Dependency Manifest ก่อน Source Code สำหรับแอปที่มี Dependency มาก; ใช้ BuildKit Cache Mount เมื่อเหมาะสม
- ไม่ฝัง Secrets: หลีกเลี่ยงการใช้
ARG/ENVใส่รหัสผ่านหรือ Token ระหว่าง Build ควรใช้ BuildKit Secret Mount ในกรณีต้องใช้ Secret ระหว่าง Build - สแกนและอัปเดต Image: Image ขนาดเล็กไม่ได้ปลอดภัยโดยอัตโนมัติ; ควรสแกน Vulnerabilities, ทำ SBOM และใช้ Patch/Upgrade ตามรอบ
- ตรวจสอบ Runtime Dependencies:
scratchอาจขาด CA Certificates, Timezone Files และ Shared Libraries ที่แอปต้องการ
#ตัวอย่างรองรับ Multi-platform (ขั้นสูง)
ตัวอย่างส่วนของ Builder สำหรับการ Cross-compile Go Binary ที่ไม่พึ่ง CGO:
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN go test ./...
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w" -o /out/server .
ใช้คู่กับ Runtime Stage เดิมได้ และสั่ง Build สำหรับสองสถาปัตยกรรมด้วย Buildx เมื่อ Builder รองรับ:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t your-registry/your-image:1.0 \
--push .
คำสั่งนี้จะ Push ไปยัง Registry จริง จึงต้องเปลี่ยนชื่อ Repository ให้ถูกต้องและ Login ก่อนใช้งาน
#10. ตัวอย่าง Multi-stage สำหรับ Node.js และ Java
#10.1 Node.js + Vite: Build Frontend แล้ว Serve ด้วย Nginx
สมมติว่าโปรเจกต์มี package.json, package-lock.json และ npm run build สร้างไฟล์ใน dist/
# syntax=docker/dockerfile:1
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
FROM nginx:stable-alpine AS runtime
COPY --from=builder /app/dist/ /usr/share/nginx/html/
EXPOSE 80
สำหรับ Single-page Application ที่ใช้ Client-side Routing อาจต้องเพิ่ม Nginx Config เพื่อให้ Fallback ไป index.html และควร Pin Runtime Image เวอร์ชัน/Digest สำหรับ Production
#10.2 Node.js Backend ที่ Compile TypeScript
สมมติว่า npm run build สร้าง dist/server.js และมี package-lock.json
# syntax=docker/dockerfile:1
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder --chown=node:node /app/dist/ ./dist/
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
ตัวอย่างนี้ติดตั้งเฉพาะ Production Dependencies ใน Runtime Stage แทนการ Copy node_modules ทั้งหมดจาก Builder Stage; หากมี Native Dependencies ต้องตรวจสอบความเข้ากันได้ของแพลตฟอร์มและ Alpine/musl
#10.3 Java + Maven: Build JAR และรันบน JRE
สมมติว่า Maven Project สร้าง JAR ไฟล์เดียวใน target/
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml ./
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
COPY --from=builder /app/target/*.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
หมายเหตุ: หาก target/ มีหลาย JAR ต้องกำหนดชื่อ Artifact ให้แน่นอนก่อน COPY; สามารถเลือก JRE Base Image ที่เล็กลงและตั้ง Non-root User ได้ตามข้อกำหนดของแอป
#11. แก้ปัญหาที่พบบ่อย
| อาการ | สาเหตุที่ควรตรวจสอบ | วิธีแก้ |
|---|---|---|
COPY --from=builder หาไฟล์ไม่พบ |
Build ไม่ได้สร้าง Artifact ใน Path ตามที่กำหนด | ตรวจสอบ -o /out/server และ Path ใน COPY |
exec format error |
Binary ไม่ตรงกับ CPU Architecture ของ Runtime | ตรวจสอบ GOARCH, TARGETARCH และ --platform |
no such file or directory ตอนรัน Binary |
Binary อาจต้องพึ่ง Dynamic Loader หรือไฟล์ที่ไม่มีใน scratch |
ใช้ CGO_ENABLED=0 เมื่อเหมาะสมหรือเปลี่ยน Runtime Base |
| Container รันได้แต่เรียกไม่ได้ | ไม่ได้ Publish Port หรือแอปไม่ฟังที่ Interface ถูกต้อง | ใช้ -p 8080:8080 และให้แอป Listen ที่ :8080 |
| Docker Build ช้า | Cache ถูก Invalidate บ่อย, Context ใหญ่ | เพิ่ม .dockerignore, จัดลำดับ COPY, ใช้ Cache Mount |
ออก HTTPS ไม่ได้จาก scratch |
ขาด CA Root Certificates | เพิ่ม Certificates ที่จำเป็นหรือเลือก Runtime Image ที่มีอยู่แล้ว |
sh: not found ตอน docker exec |
scratch ไม่มี Shell |
ใช้ docker logs, Ephemeral Debug Container หรือเครื่องมือภายนอก |
| Port 8080 ถูกใช้อยู่ | มี Container/Process อื่นครอบครอง Port | หยุดตัวเดิม หรือ Map Host Port อื่น เช่น -p 8081:8080 |
#12. แบบฝึกหัดและสรุป
แบบฝึกหัด
- ปรับ API เพิ่ม
GET /versionแล้วเขียน Unit Test - สร้าง Single-stage และ Multi-stage Image เพื่อวัดขนาดจริง แล้วบันทึกผลเป็นตาราง
- สร้าง Build Pipeline ที่รัน Test และ Build ด้วย Docker
- ทดลอง Build ผ่าน
docker buildxสำหรับlinux/amd64และlinux/arm64 - เปรียบเทียบการใช้
scratch, Distroless และ Alpine ในแง่ขนาด Security และ Debuggability
สรุป: Multi-stage Build ช่วยให้นักพัฒนากำหนด Workflow การ Build และ Runtime ใน Dockerfile เดียว โดยควบคุมได้ว่า Artifact ใดจะไปอยู่ใน Final Image แนวทางนี้เหมาะกับ Go, Java, Node.js, Python และภาษาอื่น ๆ แต่ผลด้านขนาด ความเร็วและความปลอดภัยจะขึ้นอยู่กับการออกแบบ Stage, Dependencies และ Base Image ที่เลือก
#แหล่งอ้างอิง
- Docker Docs — Multi-stage builds
- Docker Docs — Building best practices
- Docker Docs — Dockerfile reference
- Docker Docs — Build cache optimization