วิธีลดเวลา Build ใน GitLab CI ด้วย Docker Layer Caching
เคยเป็นไหม? กด push โค้ดไปนิดเดียว แต่ pipeline ดันต้อง build Docker image ใหม่ทั้งดุ้น โหลด layer เดิม ๆ ซ้ำทุกรอบ นั่งรอ 5-10 นาทีทั้งที่จริง ๆ เปลี่ยนแค่บรรทัดเดียว... วันนี้เลยอยากมาเล่าเรื่อง Docker Layer Caching ใน GitLab CI ให้ฟัง เผื่อใครกำลังปวดหัวกับเรื่องนี้อยู่เหมือนกัน
ทำไม build ถึงช้า
ถ้าใครใช้ Docker-in-Docker (DinD) ใน GitLab CI จะรู้ว่าทุกครั้งที่ job รัน มันคือ container ใหม่เสมอ ไม่มี layer เก่าเหลืออยู่เลย พอสั่ง docker build มันเลยต้องโหลดทุก layer ของ image ใหม่หมด ทั้ง ๆ ที่ปกติ Docker เก่งเรื่อง cache layer อยู่แล้ว (แต่ก่อน 1.13) แค่ตอนนี้เราต้อง "บอก" มันหน่อยว่าจะเอา image ไหนมาใช้เป็น cache
หลักการง่าย ๆ คือ ทุกคำสั่งใน Dockerfile จะสร้าง 1 layer แล้ว Docker จะเก็บ layer พวกนี้ไว้ใช้ซ้ำถ้าไม่มีอะไรเปลี่ยน แต่ถ้า layer ไหนเปลี่ยน layer ที่ตามมาหลังจากนั้นจะโดน build ใหม่หมดเลย ดังนั้นกุญแจสำคัญคือการมี "cache source" ให้ Docker อ้างอิงถึง ผ่าน flag --cache-from
เช็คก่อน Docker 27.0.1 ขึ้นไปต้องเปิด containerd
อันนี้สำคัญ ถ้าใช้ Docker เวอร์ชัน 27.0.1 ขึ้นไป build driver ปกติ (docker) จะรองรับ cache backend ก็ต่อเมื่อเปิดใช้ containerd image store เท่านั้น ทางเลือกมีสองแบบ
- เปิด containerd image store ใน Docker daemon config
- หรือเปลี่ยนไปใช้ build driver ตัวอื่นแทน
ไม่งั้น cache อาจจะไม่ทำงานตามที่คาดหวัง เช็คให้ชัวร์ก่อนเซ็ตอัพนะ
วิธีที่ 1: Inline Caching (ง่ายสุด เริ่มก่อนได้เลย)
ถ้าอยากลองอะไรง่าย ๆ ก่อน ตัวนี้แหละเหมาะสุด inline cache จะฝัง metadata ของ cache ไว้ในตัว image เลย ไม่ต้องมี cache image แยกต่างหาก เหมาะกับ build ที่ไม่ซับซ้อนมาก แต่ถ้า build เป็น multi-stage หรือซับซ้อนหน่อย แนะนำข้ามไปใช้ registry caching (อธิบายด้านล่าง) จะคุ้มกว่า
หัวใจสำคัญคือ flag --build-arg BUILDKIT_INLINE_CACHE=1 ต้องใส่ไม่งั้น cache จะไม่ทำงานเงียบ ๆ โดยไม่มี error ให้เห็นเลย เจ็บมาก่อนแล้วเลยอยากเตือนไว้
ตัวอย่าง .gitlab-ci.yml
default:
image: docker:27.4.1-cli
services:
- docker:27.4.1-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
variables:
# ใช้ TLS ตามที่ GitLab แนะนำ
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
build:
stage: build
script:
- docker pull $CI_REGISTRY_IMAGE:latest || true
- docker build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from $CI_REGISTRY_IMAGE:latest
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA --tag $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker push $CI_REGISTRY_IMAGE:latestมาดูทีละบรรทัดในส่วน script กัน
docker pull ... || true— พยายามดึง image ล่าสุดจาก registry มาก่อน เผื่อเอาไว้ใช้เป็น cache (ใส่|| trueไว้เพราะถ้าเป็น build แรกสุดที่ยังไม่มี image เลย จะได้ไม่ fail) สำคัญ: image ที่จะเอามาใช้กับ--cache-fromต้อง pull มาก่อนเสมอ ใช้ตรง ๆ ไม่ได้docker build ...— build image โดยใช้ image ที่ pull มาเป็น cache source ผ่าน--cache-fromแล้ว flagBUILDKIT_INLINE_CACHE=1จะฝัง cache metadata เข้าไปในตัว image ที่ build ออกมาด้วย- สอง
docker push— เอา image ที่ tag ไว้ทั้งสองตัวขึ้น registry เพื่อให้รอบถัดไปเอามาใช้เป็น cache ต่อได้
ง่าย ๆ แค่นี้เลย ลองเอาไปแปะใน pipeline แล้วดูเวลา build เทียบก่อน-หลังได้เลย ต่างกันชัดเจนมาก
วิธีที่ 2: Registry Caching (สำหรับ build ที่ซับซ้อนขึ้น)
ถ้า project ของเราเป็น multi-stage build หรือ build flow เริ่มซับซ้อน inline caching อาจจะเริ่มไม่พอ ทีนี้ก็ถึงเวลาใช้ registry cache backend ผ่าน docker buildx build ซึ่งจะเก็บ cache แยกเป็น image ต่างหาก ไม่ปนกับ image จริงที่เอาไปใช้งาน ทำให้ scale ได้ดีกว่า
default:
image: docker:27.4.1-cli
services:
- docker:27.4.1-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
build:
stage: build
script:
- docker context create my-builder
- docker buildx create my-builder --driver docker-container --use
- docker buildx build --push -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE/cache-image,mode=max
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE/cache-image .อธิบายทีละส่วน:
- สองบรรทัดแรก (
docker context createและdocker buildx create) คือการสร้างและตั้งค่า BuildKit driver แบบdocker-containerซึ่งเป็นตัวที่รองรับ registry cache backend (driver ปกติทำไม่ได้) - บรรทัดสุดท้าย build แล้ว push image ไปพร้อมกันเลย โดยดึง cache มาจาก image แยกต่างหาก (
--cache-from) แล้วอัปเดต cache image นั้นกลับไปด้วย (--cache-to) ส่วนmode=maxคือให้ cache ทุก intermediate layer เก็บไว้หมด ไม่ใช่แค่ layer สุดท้าย ทำให้ hit cache ได้มากขึ้นในรอบถัดไป
สรุปเลยละกัน
ถ้าจะให้แนะนำสั้น ๆ
- เริ่มต้นง่าย ๆ ก่อนด้วย inline caching ถ้า build ไม่ซับซ้อน แค่ใส่
BUILDKIT_INLINE_CACHE=1กับ--cache-fromก็พอ - ถ้า build เริ่มซับซ้อน มี multi-stage เยอะ ๆ ค่อยขยับไปใช้ registry caching ผ่าน buildx จะจัดการ cache ได้ดีกว่าและแยก concern ชัดเจนกว่า
- อย่าลืมเช็ค Docker version ของตัวเองก่อน ถ้า 27.0.1 ขึ้นไปต้องเปิด containerd image store ไม่งั้น cache backend อาจไม่ทำงานตามที่หวัง
ลองปรับใช้ดูนะ แค่ปรับ pipeline นิดเดียว เวลา build หายไปเยอะเลย โดยเฉพาะโปรเจกต์ที่ image ใหญ่ ๆ หรือมี dependency เยอะ ๆ จะรู้สึกถึงความต่างชัดมาก
อ้างอิงจาก เอกสารทางการของ GitLab ใครอยากอ่านฉบับเต็มแบบละเอียดกว่านี้ไปตามลิงก์ได้เลย