ยุคนี้ผมคิดว่าทุกคนคงใช้ Docker ในการทำงานกันอยู่แล้ว วันนี้อยากเขียนสั้นๆ เบื้องหลังการทำงานเวลาที่เรารัน build docker image จาก Dockerfile ว่ามันมีอะไรเกิดอะไรขึ้นบ้าง สามารถทำยังไงให้มันเร็วขึ้นได้ และวิธีการเรียงลำดับ instruction ใน Dockerfile

Image alt

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 จะประกอบด้วย

  1. Download base image (เช่น ubuntu, node, python)
  2. อัพเดต software และลง dependencies ต่างๆที่ต้องใช้ (apt-get update && apt-get install build-essential)
  3. copy source code จาก host หรือ local computer ไปยัง container
  4. ทำการ build project
  5. ได้ 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 ตามรูป

Image alt
Docker layer ทั้งหมดที่ถูกสร้างขึ้นจาก Dockerfile ด้านบน

ถ้าเราอยากรู้ว่า 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 ได้

Image alt
Docker layer 3 ลงมาจะถูก build ใหม่

ตัวอย่างเช่นถ้า 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: