ยุคนี้ผมคิดว่าทุกคนคงใช้ Docker ในการทำงานกันอยู่แล้ว วันนี้อยากเขียนสั้นๆ เบื้องหลังการทำงานเวลาที่เรารัน build docker image จาก Dockerfile ว่ามันมีอะไรเกิดอะไรขึ้นบ้าง สามารถทำยังไงให้มันเร็วขึ้นได้ และวิธีการเรียงลำดับ instruction ใน Dockerfile
Dockerfile
ในการสร้าง Docker image เราจะต้องกำหนดสิ่งที่เราต้องการ (instruction) ใน image ของเรา เช่นจะลง OS อะไร หรือลง dependencies ตัวไหน version อะไร ซึ่งเราจะเขียนทั้งหมดที่เราต้องการไว้ใน Dockerfile ไฟล์นี้เป็นเหมือนแม่พิมพ์สำหรับ image ของเรา
ถ้าเปรียบเทียบกับโลก OOP ตัว Docker image เป็นเหมือน Class ส่วนตัว container เป็นเหมือน instance ของ class นั้นๆ ที่เราได้สร้างขึ้น เราสามารถสร้าง container มาเท่าไรก็ได้จาก image ที่เราได้สร้างไว้
FROM ubuntu:18.04
COPY . /app
RUN make /app
CMD python /app/app.py
ตัวอย่าง Dockerfile ที่จะสร้าง Docker image ที่ใช้ ubuntu เวอร์ชั่น 18.04
โดย flow หลักๆ ที่นิยมทำกัน Dockerfile จะประกอบด้วย
- Download base image (เช่น ubuntu, node, python)
- อัพเดต software และลง dependencies ต่างๆที่ต้องใช้ (apt-get update && apt-get install build-essential)
- copy source code จาก host หรือ local computer ไปยัง container
- ทำการ build project
- ได้ executable ที่นำไปใช้งานต่อได้
Docker Layer
แต่ละคำสั่งหรือแต่ละบรรทัด ใน Dockerfile นั้นหมายถึงแต่ละ Docker layer ที่จะต้องถูก build ออกมาครับ พูดง่ายๆก็คือ ในหนึ่ง Docker image จะประกอบไปด้วยหลายๆ Docker layer ซึ่ง layer แต่ละชั้นก็จะถูกสร้างจาก layer ก่อนหน้าแล้วรันคำสั่งที่เพิ่มขึ้นมาครับ โดยแต่ละ layer จะถูก build เป็นลำดับขั้นเริ่มตั้งแต่บรรทัดแรกสุดไปจดถึงบรรทัดสุดท้ายของ Dockerfile
เปรียบเทียบให้เห็นภาพ สมมติผมมี Dockerfile ของ nodejs appliation แบบนี้
FROM node:16
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]
เวลาเราสร้าง Docker image เราจะใช้คำส่ง docker build .
ซึ่งสิ่งที่เกิดขึ้นก็คือ Docker จะสร้าง layer ชั้นล่างสุด (บรรทัดแรกของ Dockerfile) ก็คือดาวน์โหลด node version 16 หลังจากนั้นจะสร้าง layer ถัดมา (บรรทัดที่สอง รันคำสั่ง WORKDIR /app
) ซึ่งก็คือทำการสร้าง working directory ชื่อ app โดย Docker จะไม่ได้ทำการสร้าง layer ใหม่จากเริ่มต้น แต่จะนำ layer ก่อนหน้ามาใช้ ซึ่งหลังจากรันคำสั่งเสร็จ Docker จะบันทึกไว้เป็นหนึ่ง Docker layer ใหม่อีกอัน (layer ชั้นที่สอง) ซึ่งจะทำแบบนี้ไปจนถึงบรรทัดสุดท้ายของ Dockerfile ซึ่งจาก instruction ที่เรามี Docker image ของเราจะมีทั้งหมด 5 layer ตามรูป
ถ้าเราอยากรู้ว่า Docker image ที่สร้างนั้นมีกี่ layer ให้รันคำสั่ง docker history $IMAGE_ID
ครับ จากตัวอย่างถ้าผมรันดูว่า Image ที่ผมได้มี layer อะไรบ้างก็จะได้ประมาณนี้
❯ docker history f87f73980b63
IMAGE CREATED CREATED BY SIZE COMMENT
f87f73980b63 18 minutes ago CMD ["npm" "start"] 0B buildkit.dockerfile.v0
<missing> 18 minutes ago RUN /bin/sh -c npm install # buildkit 111MB buildkit.dockerfile.v0
<missing> 18 minutes ago COPY . . # buildkit 279kB buildkit.dockerfile.v0
<missing> 7 hours ago WORKDIR /app 0B buildkit.dockerfile.v0
<missing> 5 days ago /bin/sh -c #(nop) CMD ["node"] 0B
<missing> 5 days ago /bin/sh -c #(nop) ENTRYPOINT ["docker-entry… 0B
<missing> 5 days ago /bin/sh -c #(nop) COPY file:4d192565a7220e13… 388B
<missing> 5 days ago /bin/sh -c set -ex && for key in 6A010… 7.59MB
<missing> 5 days ago /bin/sh -c #(nop) ENV YARN_VERSION=1.22.19 0B
<missing> 5 days ago /bin/sh -c ARCH= && dpkgArch="$(dpkg --print… 98.7MB
<missing> 5 days ago /bin/sh -c #(nop) ENV NODE_VERSION=16.18.1 0B
<missing> 5 days ago /bin/sh -c groupadd --gid 1000 node && use… 338kB
<missing> 5 days ago /bin/sh -c set -ex; apt-get update; apt-ge… 465MB
<missing> 5 days ago /bin/sh -c apt-get update && apt-get install… 144MB
<missing> 5 days ago /bin/sh -c set -ex; if ! command -v gpg > /… 17.4MB
<missing> 5 days ago /bin/sh -c set -eux; apt-get update; apt-g… 15.9MB
<missing> 5 days ago /bin/sh -c #(nop) CMD ["bash"] 0B
<missing> 5 days ago /bin/sh -c #(nop) ADD file:2deba7c04e28d0199… 108MB
วิธีการตีความก็คือ layer จะซ้อนกันเรียงจากล่างขึ้นบนครับ พวก layer ที่เป็น /bin/sh
ทั้งหลายมาจาก base image ของ node:16 ครับ จะเห็นว่า layer ที่เราสร้างเพิ่มขึ้นมาจาก Dockerfile ของเราจะเริ่มจาก WORKDIR /app
ไล่ขึ้นมา
Caching
ทีนี้เคยสงสัยไหมครับว่าเวลาที่เราทำการสร้าง Docker image จาก Dockerfile เดิม ทำไมมันเร็วขึ้นกว่าครั้งแรกเยอะมาก เช่นถ้าผมทำการ build Docker iamge เดิมอีกครั้งจะเจอว่าทุก layer ของ image ถูกดึงมาจาก cache หมดเลย (CACHED
)
❯ docker build -t demo/simple-node-app .
[+] Building 1.3s (9/9) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 36B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 35B 0.0s
=> [internal] load metadata for docker.io/library/node:16 1.2s
=> [1/4] FROM docker.io/library/node:16@sha256:7f404d09ceb780c51f4fac7592c46b8f21211474aacce25389eb0df06aaa7472 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 124B 0.0s
=> CACHED [2/4] WORKDIR /app 0.0s
=> CACHED [3/4] COPY . . 0.0s
=> CACHED [4/4] RUN npm install 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:f87f73980b63a7a94a2ec976cf1f527839d459cd50e05310f8c30560727cdc5a 0.0s
=> => naming to docker.io/demo/simple-node-app
ตรงนี้หลักการก็คือ Docker จะทำการ cache แต่ละ layer ที่เราเคยสร้างเอาไว้ครับ โดยถ้าแต่ละ Layer ไม่มีอะไรเปลี่ยนแปลงเลยก็จะทำการใช้ Layer ที่ทำการแคชไว้ทั้งหมดครับ ทำให้ไม่ต้องเข้าสู่กระบวนการ build ใหม่อีกครั้ง แต่ถ้า มี layer ใดสักอันหนึ่งเกิดการเปลี่ยนแปลงขึ้น layer นั้นและ layer อื่นที่อยู่ถัดจากชั้นนั้นขึ้นมา จะถูก build ใหม่หมดครับ ไม่สามารถใช้ cache ได้
ตัวอย่างเช่นถ้า nodejs application ของผมมีการแก้ไข code แม้เพียงเล็กน้อย (อาจจะแค่เพิ่ม console.log ไป 1 บรรทัด) แต่ code ชุดนั้นอยู่ในชุดคำสั่งที่ถูกเพิ่มไปใน layer ที่ 3 ในกรณีนี้ docker จะทำการดึง cache ได้เฉพาะ layer 1 - 2 ครับ ส่วน layer ที่ 3 ลงมาจะต้องทำการ build ใหม่
Best practice
จากที่เรามาจะเห็นได้ว่า ลำดับของ instruction ใน Dockerfile มีผลมาก เพราะสามารถบอก level ของ Docker layer และส่งผลไปถึงการ cache ด้วย จากกรณีข้างบนจะเห็นว่าแม้เราจะแก้ code ของเราเพียงเล็กน้อย แต่เราต้อง build image ใหม่ ถึง 3 layer ซึ่งเป็นสิ่งที่ควรเลี่ยง ท่าที่คนส่วนมากนิยมส่วนมากก็คือเราจะมัดรวมพวก update or install dependencies ไว้รวมกันใน แล้วเอา application วางไว้ใน layer ในชั้นหลัง ๆ ครับ เช่น
FROM node:16
WORKDIR /app
RUN npm install
COPY . .
CMD ["npm", "start"]
จะเห็นได้ว่าถ้าเราเอา instruction ที่ copy code ของ application ของเรามาวางไว้ในลำดับท้ายๆ เราสามารถ cache ทุก layer ที่อยู่ก่อนหน้าได้ ทำให้ไม่ต้องมาเสียเวลา build ใหม่ ไม่ต้องทำการ npm install ใหม่ทุกครั้ง
Reference: