Optimization & Security : การเขียน Dockerfile ของผมเอง by AP.xyz

สวัสดีครับ ผมได้กลับมาเขียน Blog อีกครั้งแล้วนะครับ หลังจากได้ไปท่องโลกสนธยา โลก DevOps มา ไม่ได้มีเวลากลับมาเขียน Blog เลย คิดถึงผู้อ่านทุกท่านนะครับ และผมหวังอย่างยิ่งว่า บทความนี้ อาจจะเป็นประโยชน์ต่อผู้อ่าน หรือ ผู้ที่ต้องการศึกษา Dockerfile ครับ
Dockerfile ที่ดี ต้องมีอะไรบ้าง หลายคนคงทราบอยู่แล้วนะครับว่า Dockerfile ที่ดี ต้องรู้จักการ Copy ของที่จำเป็นต่อการรันแอปพลิเคชั่น และ ไม่ขาดสิ่งที่จำเป็นไป โดยในการทำ Dockerfile นั้นสิ่งที่ต้องห้ามเลย คือ Copy Credentials / Environment หรือ Sensitive Object ต่างๆนะครับ เอาขึ้นไป ผมขอตีมือทีนึงนะ 5555555555
เอาจริงๆ ผมเองก็มาเริ่ม Nerd กับ Dockerfile ในช่วงหลังๆชีวิตการทำงานตลอด 4 ปีที่ผ่านมานะครับ สมัยก่อน มีอะไรก็ก๊อปๆใส่ไปก่อน เอาขึ้นให้ได้ ช่วงต่อมา ก็เริ่มรู้แล้วว่า อะไรที่ไม่ควร Copy ใส่ และ มาถึง Step Build Multi-stage และ ก็มาถึงอีกสเต็ป คือ การเอา Dockerfile ยัดเขา Distroless ยัน Scratch Dockerfile ครับ
ในบทความนี้ผมจะมาพาทำ Dockerfile แบบเบาสบาย ปลอดภัย และ คลีน ฉบับผมเองนะครับ โดยผมจะเริ่มจากการที่ผมนำโปรเจกต์ Website ส่วนตัวของผม มาเป็นต้บแบบในบทความนี้นะครับ
Stack ที่ผมนำไป Containerized มีดังนี้ครับ
Code base : Nuxt3 (Vue 3) + Bun.js
OS base : Alpine Linux
มาเริ่มกันเลยดีกว่าครับ ผมจะพาทัวร์ โดยเริ่มจากการดูโครงสร้าง Folder ก่อน

Structure ของ Nuxt3
ทุกคนสังเกตเห็น .dockerignore ไหมครับ อันนี้แหละครับที่เป็นตัวช่วยทุกท่านในการ ไม่เอา File ที่เราไม่ต้องการ รวมถึง Sensitive Object ขึ้นไป เวลา Build Dockerfile ครับ

.dockerignore
ในไฟล์นี้ผมจะไม่เอาสิ่งที่เป็น Output เวลาที่ผม Build บน Local ขึ้นไป หรือ ไฟล์ต่างๆที่ไม่จำเป็น หรือ เกินจำเป็น เวลา Build ครับ เช่น node_modules, .output, .nuxt, .git หรือว่าพวก Sensitive Object เช่น .env หรือสิ่งที่ Editor เป็นคน Generate ให้ เช่น .vscode, .idea หรือ อื่นๆครับ และ ถ้า เป็นพวก Script ต่างๆที่ใช้งานเกี่ยวกับ Deployment ผมก็ไม่เอาขึ้นไปเช่นกันครับ เช่น ที่เก็บ File Helm Chart , YAML ของ K8s
มาถึงส่วนที่สำคัญกันบ้างล่ะครับ คือ Dockerfile
FROM oven/bun:alpine as base #เลือก Distro ที่เราต้องการจะนำมาเป็น Base สำหรับการ Build
COPY package.json bun.lock ./app/ #คัดลอกไฟล์ package.json และ bun.lock ซึ่งเป็นไฟล์ระบุ Package ที่ใช้ภายในระบบ
WORKDIR /app #กำหนดพื้นที่หรือขอบเขตในการ Execute
RUN bun install --frozen-lockfile #ติดตั้งไฟล์ที่จำเป็นสำหรับการ Build Nuxt
FROM base AS prerelease #เปลี่ยน Step การทำงาน
ENV USER_NAME=isaac #กำหนด Username
ENV UID=1001 #กำหนด User ID
ENV GID=1001 #กำหนด Group ID
#เริ่มติดตั้ง Package ที่จำเป็นต่อการใช้งาน
RUN apk update && \
apk add --no-cache ca-certificates && \
addgroup -g ${GID} ${USER_NAME} && \ #เพิ่ม Group ที่ User ID จะอยู่
adduser -D -u ${UID} -G ${USER_NAME} ${USER_NAME} && \ # เพิ่ม User เข้า Group
chown -R ${USER_NAME}:${USER_NAME} /app #ให้สิทธิ์ในโฟลเดอร์ /app ทั้งหมดแก่ User ดังกล่าว
USER ${USER_NAME} #สลับ User
COPY --from=base /app/node_modules node_modules #Copy File node_modules จาก Step
COPY . . #Copy file ทั้งหมดที่จำเป็นสำหรับการ Build (ยกเว้น node_modules)
ENV NODE_ENV=production #กำหนด ENV เป็น NODE_ENV=production เพื่อ build แบบ Production
RUN bun run build #คำสั่ง Build
FROM scratch #เปลี่ยน Step มาเป็น Step สุดท้าย คือการ สลับเป็น Scratch
COPY --from=prerelease /etc/passwd /etc/passwd #คัดลอกไฟล์ที่จำเป็นจากสเต็บ Pre-release อันนี้เป็นไฟล์ /etc/passwd คือไฟล์ที่เกี่ยวข้องกับการจัดการ User
COPY --from=prerelease /etc/group /etc/group #คัดลอกไฟล์ที่จำเป็นจากสเต็บ Pre-release อันนี้เป็นไฟล์ /etc/group คือไฟล์ที่เกี่ยวข้องกับการจัดการ User เช่นเดียวกัน
ENV USER_NAME=isaac #กำหนด Username
USER ${USER_NAME} #สลับ User
WORKDIR /app #กำหนดพื้นที่หรือขอบเขตในการ Execute
COPY --from=base /usr/local/bin/bun /usr/local/bin/bun #คัดลอกไฟล์ที่จำเป็นจาก Step Base อันนี้เป็น Executable ที่จำเป็นต่อการรัน App ของเรา คือ bun
COPY --from=base "/usr/lib/libstdc++.so.6" "/usr/lib/libstdc++.so.6" #คัดลอก Library ที่จำเป็นสำหรับภาพรวม
COPY --from=base /usr/lib/libgcc_s.so.1 /usr/lib/libgcc_s.so.1 #คัดลอก Library ที่จำเป็นสำหรับภาพรวม
# คัดลอก Library ที่จำเป็นสำหรับภาพรวม
# (Library นี้ ขาดแล้ว รันไม่ได้ คือ Library ที่เกี่ยวกับ ld เป็น dynamic linker
# ถ้าก๊อปปี้ผิด ก็รันไม่ได้้เช่นกัน เช่น ถ้าเครื่องปลายทางใช้ AMD64 / Intel ควรเป็น ld-musl-x86_64.so.1
# หรือถ้าเป็น Apple Silicon / ARM64 ควรเป็น ld-musl-aarch64.so.1
COPY --from=base /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
COPY --from=prerelease --chown=${USER_NAME}:${USER_NAME} /app/.output /app #คัดลอกโฟลเดอร์ Output สำหรับ Application โดย ต้องกำหนด User สำหรับการ Copy ให้ตรงกับ User ของเราที่กำหนดไว้ในขั้น Pre-release
ENV NUXT_HOST=0.0.0.0 #กำหนด Environment
ENV NUXT_PORT=3000 #กำหนด Environment
EXPOSE 3000 #กำหนด Port ที่จะนำออก สำหรับ Container
CMD ["bun", "run","server/index.mjs"] #กำหนด คำสั่ง ในการรัน Application
ที่ท่านเห็นอยู่ข้างบนนี้คือ Dockerfile ของโปรเจกต์ ผมเองนะครับ
แต่ละอัน ผมจะคอมเมนต์ และ อธิบายไว้ในไฟล์นะครับ
สังเกตไหมครับว่า Dockerfile ดังกล่าว ผมใช้ scratch สาเหตุที่ผมใช้มีอยู่อย่างเดียวเลย …. ป้องกัน Shell Execution ครับ
ค่อนข้างจะละเอียดเลยครับ เป็นไฟล์ที่ผมใช้เวลาศึกษาพอสมควร กว่าจะได้ Dockerfile นี้มา อาจจะรู้สึกว่ามันยุ่บยั่บไปหน่อย ไหนจะเรื่อง Lib ต่างๆที่ Copy มาเอง ไหนจะเป็น ld ที่จะต้องมาเลือก Architecture ภายหลัง วุ่นวายดีครับ
แต่ผมจะบอกอย่างนึงนะครับ ที่ผมเลือกใช้ scratch แทน distroless เพราะ มันค่อนข้างเห็นชัดดีครับว่า อะไรที่เราจำเป็นต้อง Copy บ้าง และ distroless อาจจะไม่ตอบโจทย์ผมในเรื่องของการใช้งาน Alpine linux ครับ เพราะมันเป็น glibc แต่ Alpine Linux เป็น musl
ไม่ว่าอย่างไรก็ดีครับ การเขียน Dockerfile ในแบบฉบับของผมเอง อาจไม่ใช่วิธีทางที่ดีหรือดูน่าชื่นชมสำหรับพี่ๆน้องๆทุกท่านนะครับ แต่ผมอยากมาแชร์วิธีการเขียนในแนวทางของผม ที่ผมได้ศึกษามา และ ได้มองเห็นจุดอ่อน และ ได้แก้จุดอ่อนในแบบฉบับของผมเพียงเท่านั้นเองครับ ผมยินดีรับความคิดเห็นที่สร้างสรรค์จากพี่ๆ ในวงการ Developer / DevOps / Security ทุกท่านอยู่นะครับ หวังว่าพี่ๆจะใจดีกับผมนะครับ
ขอพระเจ้าอวยพรทุกท่านครับ สวัสดีครับ