เคยเป็นไหม? กด 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 กัน

  1. docker pull ... || true — พยายามดึง image ล่าสุดจาก registry มาก่อน เผื่อเอาไว้ใช้เป็น cache (ใส่ || true ไว้เพราะถ้าเป็น build แรกสุดที่ยังไม่มี image เลย จะได้ไม่ fail) สำคัญ: image ที่จะเอามาใช้กับ --cache-from ต้อง pull มาก่อนเสมอ ใช้ตรง ๆ ไม่ได้
  2. docker build ... — build image โดยใช้ image ที่ pull มาเป็น cache source ผ่าน --cache-from แล้ว flag BUILDKIT_INLINE_CACHE=1 จะฝัง cache metadata เข้าไปในตัว image ที่ build ออกมาด้วย
  3. สอง 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 ใครอยากอ่านฉบับเต็มแบบละเอียดกว่านี้ไปตามลิงก์ได้เลย