Dependencies Cache ด้วย Docker

Sorrasak Phonklad
FLOWACCOUNT TECH BLOG
4 min readJun 9, 2022

Dependencies คือ?

Dependencies นั้นคือ module, api, package หรือส่วนต่อเติมส่วนขยายที่มีคนพัฒนาไว้ก่อนแล้ว และเราได้นำมาใช้ใน application ของเราเพื่อเข้าถึงบริการต่างๆ ยกตัวอย่างเรามี app แต่ต้องต้องการเข้าถึงไฟล์ที่เก็บอยู่บน s3 ของ aws เราเพียงแค่ติดตั้ง aws-sdk ลงใน app ของเรา ก็จะสามารถเข้าถึง s3 ของ aws ได้อย่างง่ายดายตามข้อกำหนดการเรียกใช้ของ aws-sdk โดยไม่ต้องหาวิธีหรือพัฒนาการเข้าถึง s3 ที่ซับซ้อนขึ้นมาใหม่บน app ของเราเลย

Dependencies Cache คืออะไร ทำไมต้องมี?

ใน 1 app เราจะมีการติดตั้ง dependencies มากกว่าหนึ่งตัวอยู่แล้ว เนื่องจากการพัฒนา app นั้นจำเป็นต้องติดต่อกับ 3rd party ที่หลากหลาย หรือ module ของ app อื่น ดังนั้น ยิ่ง app มีความซับซ้อนก็มีโอกาสที่ dependencies ที่เราต้องใช้ก็จะมีมากขึ้นตามไปด้วย

หากในองค์กรเรามีการทำ CI/CD จะยิ่งเห็นได้ชัดว่า เมื่อไรที่เราต้องรอ pipeline ของ CI/CD ซึ่งใช้เวลากับการติดตั้ง dependencies มากกว่าปกติ (ระยะเวลาในการรอของแต่ละ app ไม่เท่ากัน ขึ้นอยู่กับความซับซ้อนของ app แต่โดยทั่วไปสำหรับผมมากกว่า 30 วินาทีคือผิดปกติ) ควรจะต้องพิจารณาการทำ cache โดยด่วน

cache คือการสำรองส่วนที่เคยทำไปแล้วเก็บไว้ และนำมาใช้เมื่อสิ่งนั้นไม่มีการเปลี่ยนแปลง ดังนั้น dependencies cache จึงหมายถึงการเก็บ dependencies ที่เคยติดตั้งไปแล้วเอาไว้ใน disk, memory, repository และดึงมันมาใช้เมื่อไม่มีการเปลี่ยนแปลงนั่นเอง

โดยปกติ dependencies มักจะไม่มีการเปลี่ยนบ่อยๆ เพราะงานส่วนใหญ่เราจะพัฒนา logic ใน app มากกว่า ดังนั้น cache ควรเป็นสิ่งที่ต้องทำอย่างยิ่ง ไม่ใช่แค่ใน CI/CD pipeline แต่ควรทำในเครื่องของตัวเองด้วย เพื่อลดเวลาในการติดตั้ง dependencies ใหม่โดยไม่จำเป็น

แล้วทำไมถึงต้อง Cache ด้วย Docker?

การทำ cache dependencies นั้น package manager อย่าง yarn, maven และอื่นๆ โดยส่วนใหญ่จะมีการทำ cache ใน local ของตัวเองอยู่แล้ว (ซึ่งผมจะไม่ขอกล่าวในที่นี้) แต่จากที่กล่าวข้างต้น หาก app มีการพัฒนาที่หลากหลาย framework ใช้ package manager ในการทำ cache dependencies ที่หลากหลาย การจัดการในแบบที่เป็นมาตรฐาน และการทำงานข้าม project นั้นจะเป็นไปอย่างลำบาก เช่น

ผมรับผิดชอบ app A และคุ้นเคยกับการทำ cache package บน app A เป็นอย่างดี สามารถทำ pipeline บน CI/CD เพื่อ cache package ได้อย่างไม่มีปัญหา แต่เมื่อมีความจำเป็นต้องแก้ไข app B ซึ่งใช้ framework คนละตัว และไม่คุ้นเคยกับการทำ cache บน app B มาก่อน จะทำให้ผมสูญเสียเวลาในการ switch context รวมถึงการต้องหาวิธีในการทำ cache บน app B ไป

หนึ่งในต้นทุนที่สำคัญที่เรามักมองข้ามในการพัฒนา app ไปนั้นคือการ switch context ระหว่าง technology

Docker จึงมามีส่วนสำคัญในการลดปัญหาความซับซ้อนการจัดการข้าม project ได้เพราะ Docker นั้นสามารถทำ cache image รวมถึง Dockerfile ก็เปรียบเสมือน Blueprint ในตัว ให้คนที่มาใหม่ใน project สามารถลด learning curve ได้เป็นอย่างดี

เรามาลองเริ่มต้นทำ Docker cache dependencies อย่างง่ายๆกัน

การทำ nodejs dependencies cache บน Docker

เริ่มต้นให้ checkout project “Hello World” จาก https://github.com/gogotaro/docker-hello-world

ใน repositories นี้เป็น app ตัวอย่างแค่พิมพ์คำว่า “Hello World” เท่านั้นครับซึ่งเป็น nodejs app คราวนี้ลองมาดูที่ Dockerfile ของ app กันครับ

จะเห็นว่า Dockerfile ไม่มีอะไรมาก เป็น Dockerfile สำหรับรัน app nodejs ทั่วไป คือ

  • บรรทัดที่ 1 FROM node:carbon คือการระบุว่าใช้ docker image ที่มี node version carbon (v12) เป็น base image
  • บรรทัดที่ 3 WORKDIR /app คือการสร้าง path ใน container /app
  • บรรทัดที่ 5 COPY . . คือการ copy file ใน project ทั้งหมดเข้าไปใน container ที่ path /app
  • บรรทัดที่ 7 RUN npm install คือการติดตั้ง dependencies ของ nodejs
  • บรรทัดที่ 9 CMD ["node", "server.js"] คือการสั่งรัน app node ที่ file server.js
  • บรรทัดที่ 11 EXPOSE 3000 คือการระบุ port app ที่ใช้ใน container

คราวนี้ให้ลอง build docker image จาก Dockerfile นี้ ด้วยคำสั่งด้านล่างบน terminal ครับ

docker build . -t helloworld

จะได้ผลลัพธ์บน terminal ที่รัน ดังนี้

=> [1/4] FROM docker.io/library/node:carbon@sha256:xxxxx
=> [2/4] WORKDIR /app
=> [3/4] COPY . .
=> [4/4] RUN npm install
=> exporting to image

จะเห็นว่าการ build docker ครั้งนี้ไม่มีการ cache ใดใดทั้งสิ้นเพราะเป็นการ build ครั้งแรก

คราวนี้ลอง build docker image ด้วยคำสั่งเดิมอีกครั้ง จะได้ผลลัพธ์ที่ต่างออกไป ดังนี้

=> [1/4] FROM docker.io/library/node:carbon@sha256:xxxxx
=> CACHED [2/4] WORKDIR /app
=> CACHED [3/4] COPY . .
=> CACHED [4/4] RUN npm install
=> exporting to image

จะสังเกตว่าขั้นตอนที่ 2,3,4 มีการ cache ทั้งหมด และไม่มีการทำใหม่ นั่นเพราะ ไม่มีการเปลี่ยนแปลงใดๆภายใน app นั่นเอง docker จึงเลือกที่จะใช้ cache จาก image ที่เคย build ครั้งแรกมาใช้ทำให้ลดระยะเวลาการ build ใหม่ทั้งหมดได้

คราวนี้ลองแก้ไข code ดูครับ ในไฟล์ server.js เปลี่ยนคำว่า “Hello World!” ใน code ไปเป็น “Hello!” แล้วบันทึก จากนั้น build docker image ด้วยคำสั่งเดิมอีกรอบ จะได้ผลลัพธ์ดังนี้

=> [1/4] FROM docker.io/library/node:carbon@sha256:xxxxx
=> CACHED [2/4] WORKDIR /app
=> [3/4] COPY . .
=> [4/4] RUN npm install
=> exporting to image

คำว่า CACHED ในขั้นตอนที่ 3,4 หายไปแล้ว เนื่องจากมีการเปลี่ยนแปลงใน file และคำสั่ง COPY จำเป็นต้องดำเนินการใหม่ รวมถึงคำสั่งหลังจาก COPY ต้องทำใหม่หมดด้วยทำให้คำสั่ง npm install ไม่ถูก cache

ดังนั้นการทำ Dockerfile แล้วไม่ได้หมายความว่าจะ cache dependencies ได้ถูกต้องเสมอไป เพราะกรณีนี้เราต้องการให้ cache dependencies ทุกครั้งแม้ว่า code จะถูกแก้ไขก็ตาม เพราะกรณีนี้เราไม่ได้มีการแก้ไข เพิ่ม หรือลบ package นั่นเอง

ให้เราแก้ไข Dockerfile เป็นดังนี้

จะเห็นว่าเรามีการสลับส่วนของ RUN npm install มาทำก่อน COPY . . และก่อน npm install จะมีการ copy file 2 file ก่อนนั่นก็คือ package.json, package-lock.json ซึ่งเป็น file ที่ระบุการเปลี่ยนแปลงของ package ใน project ดังนั้น หากมีการแก้ไข code แล้วไม่ได้แก้ package ขั้นตอนการติดตั้ง dependencies ก็ควรจะ cache ไว้

ให้เราลอง build docker image ด้วยคำสั่งเดิม อีกรอบ

=> [1/4] FROM docker.io/library/node:carbon@sha256:xxxxx
=> CACHED [2/6] WORKDIR /app
=> [3/6] COPY package.json package.json
=> [4/6] COPY package-lock.json package-lock.json
=> [5/6] RUN npm install
=> [6/6] COPY . .
=> exporting to image

จะเห็นว่ายังไม่มีการ cache ในขั้นตอนการติดตั้ง dependencies เพราะเป็นครั้งแรกที่ build หลังจากแก้ไข Dockerfile นั่นเอง คราวนี้ให้ลองแก้ code ในไฟล์ server.js เปลี่ยนคำว่า “Hello!” ใน code ไปเป็น “Hello World!” แล้วบันทึก จากนั้น build docker image ด้วยคำสั่งเดิมอีกรอบ จะได้ผลลัพธ์ดังนี้

=> [1/4] FROM docker.io/library/node:carbon@sha256:xxxxx
=> CACHED [2/6] WORKDIR /app
=> CACHED [3/6] COPY package.json package.json
=> CACHED [4/6] COPY package-lock.json package-lock.json
=> CACHED [5/6] RUN npm install
=> [6/6] COPY . .
=> exporting to image

จะเห็นได้ว่า ทุกขั้นตอน cache หมด มีแค่ขั้นตอน COPY เท่านั้นที่ไม่ cache เนื่องจาก code ถูกแก้ไข ซึ่งหากเราแก้ code ใน app หลังจากนี้ไม่ว่ากี่รอบ แล้ว build docker ใหม่ ก็จะได้ผลลัพธ์ที่ dependencies นั้น cache ตลอดนั่นเอง

ไอเดียการนำ dependencies cache ด้วย Docker ไปใช้บน CI/CD

การนำไปใช้บน CI/CD นั้น จะไม่มีปัญหากรณีที่เรามี 1 team 1 site นำไปใช้ สามารถ build docker แบบปกติตามหัวข้อก่อนหน้าได้ทันที

แต่ถ้าหากว่าองค์กรเรามีมากกว่า 1 team และแต่ละทีมมี site ในการใช้งานเป็นของตัวเอง เช่น

  • ทีม A ใช้งาน project hello-world และ pipeline มีการ build ขึ้นไปทดสอบที่ site hello-world-a.test.private
  • ทีม B ใช้งาน project hello-world และ pipeline มีการ build ขึ้นไปทดสอบที่ site hello-world-b.test.private

จะสังเกตุว่ามีการ build project เดียวกันแต่ deploy ไปคนละ site ดังนั้น ความเป็นไปได้ที่จะเกิดกับ 2 ทีม (ในเชิงการทำ dependencies) นี้ คือ

  1. ทั้ง 2 ทีมใช้ package ชุดเดียวกัน และทุกการเปลี่ยนแปลง package ที่ code main branch จะต้องใช้ package ของ main branch เป็นหลัก
  2. ทีมใดทีมหนึ่งอาจ poc การเปลี่ยน version หรือมีการเพิ่ม package ใหม่และยังไม่นำเข้า main branch

ทั้ง 2 กรณีนี้ยังต้องการความสามารถในการ cache อยู่

ไอเดียคือทั้งสองทีมจะต้องมี docker image ซึ่ง cache แยกทีมกันและ base ต้องมาจาก main branch ซึ่งแนวทางมีดังนี้

  • สร้าง pipeline การ build docker image จาก main branch โดย หลังจาก build แล้วให้ tag image แยกทีมกัน ดังนี้
docker build . -t helloworld
docker tag helloworld helloworld-a
docker tag helloworld helloworld-b
  • ทุกการ build docker ใน pipeline ของแต่ละทีมให้อ้างอิง cache จาก image ของแต่ละทีมเอง ดังนี้
# team a
docker build . -t helloworld-a --cache-from helloworld-a
# team b
docker build . -t helloworld-b --cache-from helloworld-b

จากไอเดียข้างต้น ทุกครั้งที่มีการ merge code เข้า main branch นั้นจำเป็นต้องมีการ run pipeline ที่ build image จาก main branch แล้ว tag image แยกทีมทุกครั้ง เพื่อให้ทุกทีมนั้นมีโอกาสได้ใช้ cache dependencies ที่เทียบเท่ากับ main เสมอ

ส่วนที่สองเป็นการ build และ cache แยกทีม ซึ่งถ้าทีมไหนมีการแก้ไข package ที่ไม่ตรงกับ main branch หรืออีกทีม ก็จะไม่กระทบซึ่งกันและกัน ทำให้ทีมตัวเองก็ยัง cache และไม่ทำให้ทีมอื่นต้องสูญเสีย cache จากการแก้ไข package ของทีมตัวเองด้วยนั่นเอง

For more articles from FlowAccount Tech Blog, please visit https://medium.com/flowaccount-tech.

Open Positions jobs available on FlowAccount > https://flowaccount.com/en/jobs

--

--