Frontend Image
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”taskflow/infra/frontend.Dockerfile — Dockerfile แบบ multi-stage สำหรับ Astro frontend สะท้อนรูปแบบเดียวกับที่ backend-image เพิ่งสร้างไป: builder stage ที่ติดตั้ง dependency แล้วรัน npm run build และ runtime stage ที่บางเบาซึ่งรันแค่ output ที่ build แล้ว จุดที่ต่างออกไปจริงๆ ที่นี่ไม่เกี่ยวกับ Docker เองเลย — คือ PUBLIC_API_URL ตัวแปรที่ board-page และส่วนที่เหลือของ frontend ใช้เพื่อรู้ว่า API อยู่ที่ไหน จะ ฝังลงใน JavaScript ที่ build แล้ว ทันทีที่ npm run build รัน ไม่ใช่ถูกอ่านใหม่ทุกครั้งที่ container เริ่มทำงาน ข้อเท็จจริงเดียวนี้กำหนดโครงสร้างเกือบทั้งหมดของ Dockerfile นี้
taskflow/├── frontend/│ ├── astro.config.mjs # from board-page: @astrojs/node, mode: 'standalone'│ └── package.json└── infra/ └── frontend.Dockerfileเมื่อจบบทเรียนนี้ docker build --build-arg PUBLIC_API_URL=http://localhost:8080 -f infra/frontend.Dockerfile -t taskflow-frontend . จะได้ image ที่รันคำสั่ง node ./dist/server/entry.mjs เดียวกับที่ board-page ตรวจสอบบนเครื่อง local ด้วย npm run preview — แค่อยู่ใน container บน 0.0.0.0:4321
board-page ทำให้ frontend เป็น Node server จริงไปแล้ว ไม่ใช่แค่โฟลเดอร์ static file: export const prerender = false บน boards/[id].astro หมายความว่า Astro ไม่สามารถ build route นั้นเป็นไฟล์ HTML คงที่ได้ ดังนั้น @astrojs/node แบบ mode: 'standalone' จึงส่ง Node HTTP server เล็กๆ ที่เริ่มทำงานเองมาเป็นส่วนหนึ่งของ build output route อื่นทุกตัวใน frontend นี้ยังคงถูก prerender เป็น HTML ธรรมดา — dist/client/ เก็บไฟล์เหล่านั้นไว้ และ Node server ของ Astro ก็เสิร์ฟไฟล์เหล่านั้นเองควบคู่ไปกับ route แบบ on-demand ตัวเดียว ไม่ต้องมี static file server แยกเลย
ทำไม PUBLIC_API_URL ต้องเป็น build ARG ไม่ใช่ค่า environment: ตอน runtime Astro (ผ่าน Vite) ถือว่าตัวแปร environment ที่ขึ้นต้นด้วย PUBLIC_ ปลอดภัยที่จะเปิดเผยให้โค้ดฝั่ง browser เห็น โดยทำด้วยการแทนที่ import.meta.env.PUBLIC_API_URL ด้วยค่าจริงของตัวแปร ตอน bundle — เหมือนกับที่ sed replace จะทำ ไม่ใช่ด้วยการอ่าน process.env ตอนที่ page ถูก request ในภายหลัง เมื่อ npm run build จบ ทุกการอ้างอิงถึง PUBLIC_API_URL ใน client bundle จะเป็น literal string http://localhost:8080 (หรือค่าอะไรก็ตามที่ตั้งไว้ตอน build) ไปแล้ว ไม่มีโค้ดเหลืออยู่ตอน runtime ที่จะไปหาตัวแปร environment ชื่อนั้นอีก การตั้งค่า PUBLIC_API_URL ใน environment: ของ container ตอน runtime หลังจาก image build เสร็จแล้ว จะไม่มีผลอะไรเลย — ค่าที่ browser ได้จริงถูกตัดสินใจแบบถาวรไปแล้วตอนที่ npm run build รัน อยู่ใน builder stage
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”build-time ARG ที่ pipe เข้าไปในตัวแปร PUBLIC_ ระหว่าง npm run build (แบบที่เราใช้) เทียบกับการอ่าน PUBLIC_API_URL จาก <script> tag ที่ inject ตอน runtime หรือไฟล์ /config.js ที่ browser fetch ตอนโหลด page
- ข้อดี: นี่คือวิธีที่ convention
PUBLIC_ของ Astro/Vite ออกแบบมาให้ใช้จริงๆ — ไม่มีกลไกเพิ่มเติม ไม่มี network request เพิ่มทุกครั้งที่โหลด page แค่เพื่อรู้ว่า API อยู่ที่ไหน และค่าที่ได้ก็รับประกันว่าสอดคล้องกับทุกอย่างที่ compile เข้าไปใน build เดียวกันนั้น (ไม่มีความเสี่ยงที่ runtime config จะ drift ออกจาก bundle ฝั่งที่อ่านค่านั้น) - ข้อเสีย: URL ของ API ถูกแช่แข็งไว้ใน image เองแล้ว — การ deploy frontend image เดียวกัน กับ backend URL คนละตัว (staging environment, region ที่สอง) หมายถึงต้อง rebuild image ด้วย
--build-argคนละค่า ไม่ใช่แค่เปลี่ยน flagdocker run -eทีมที่ deploy ไปหลาย environment จาก image เดียวมักจะเลือกทางเลือกแบบ inject config ตอน runtime เพื่อหลีกเลี่ยง rebuild ต่อ environment โดยเฉพาะ TaskFlow มีแค่ environment เดียวในคอร์สนี้ ความยืดหยุ่นนั้นจึงไม่คุ้มกับความซับซ้อนที่เพิ่มเข้ามาตรงนี้
copy dist/ แล้วทำ npm ci --omit=dev ใหม่ใน runtime stage (แบบที่เราใช้) เทียบกับ copy node_modules ตรงๆ จาก builder stage
- ข้อดี: runtime image ไม่มี
devDependenciesอยู่เลย — ไม่มีอะไรที่ต้องใช้แค่ตอนastro buildเองหลงเหลืออยู่ใน image ที่เสิร์ฟ traffic จริง และการติดตั้งใหม่ใน runtime stage รับประกันว่า native binding เฉพาะแพลตฟอร์มใดๆ ตรงกับ runtime base image เป๊ะๆ แทนที่จะเชื่อว่าอะไรก็ตามที่npm ciresolve ไว้ใน builder stage จะถูกต้องใน base image ที่ต่างออกไป (แม้จะใกล้เคียงกัน) ด้วย - ข้อเสีย:
npm ciรันสองครั้งข้ามสองสเตจแทนที่จะรันครั้งเดียว ดังนั้นเวลา build รวมนานกว่าการCOPY --from=builder node_modulesตรงๆ เล็กน้อย สำหรับ frontend ที่ไม่มี native/binary dependency การ copynode_modulesครั้งเดียวก็ใช้ได้และ build เร็วกว่า — การติดตั้งเพิ่มตรงนี้เป็นการแลกเปลี่ยนเล็กๆ ที่ตั้งใจทำ เพื่อไม่ต้องมานั่งคิดว่า package ไหนในnode_modulesที่ server ที่กำลังรันต้องการจริงๆ เทียบกับที่ขั้นตอน build ของ Astro ต้องการเท่านั้น
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”infra/frontend.Dockerfile
หัวข้อที่มีชื่อว่า “infra/frontend.Dockerfile”# syntax=docker/dockerfile:1
# ---- Builder ----FROM node:20 AS builderWORKDIR /app/frontend
# PUBLIC_ vars are inlined into the client bundle at build time by# Astro/Vite, so this has to be a build ARG, not a runtime environment# variable — see the Why section above.ARG PUBLIC_API_URLENV PUBLIC_API_URL=${PUBLIC_API_URL}
COPY frontend/package.json frontend/package-lock.json ./RUN npm ci
COPY frontend/ ./RUN npm run build
# ---- Runtime ----FROM node:20-slim AS runtimeWORKDIR /app/frontend
ENV HOST=0.0.0.0ENV PORT=4321ENV NODE_ENV=production
COPY frontend/package.json frontend/package-lock.json ./RUN npm ci --omit=dev
COPY --from=builder /app/frontend/dist ./dist
EXPOSE 4321CMD ["node", "./dist/server/entry.mjs"]เดินผ่านส่วนที่ควรหยุดพิจารณา:
ARG PUBLIC_API_URLแล้วENV PUBLIC_API_URL=${PUBLIC_API_URL}—ARGเดี่ยวๆ มีอยู่แค่ระหว่าง build เท่านั้นและไม่ visible กับขั้นตอนRUN npm run buildในฐานะตัวแปร environment ของ process การ re-export ด้วยENVจึงเปลี่ยนให้เป็นตัวแปร environment จริงที่กลไกimport.meta.envของ Vite อ่านได้ตอน bundle ถ้าไม่มีบรรทัดENVnpm run buildจะรันโดยที่PUBLIC_API_URLไม่ถูกตั้งค่าCOPY frontend/package.json frontend/package-lock.json ./ก่อนCOPY frontend/ ./— หลักการ layer-caching เดียวกับที่ backend-image พูดถึง: การ copy lockfile แล้วรันnpm ciใน layer ของตัวเอง ก่อน source ที่เหลือ หมายความว่าการแก้ไฟล์.astroจะไม่บังคับให้npm ciเต็มรูปแบบรันใหม่ใน build ครั้งถัดไป — มีแค่การเปลี่ยนpackage.json/package-lock.jsonจริงๆ เท่านั้นที่ทำได้RUN npm run build— นี่คือคำสั่งเดียวกับที่ board-page รันบนเครื่อง local ด้วยnpm run previewตามมาทันที คำสั่งนี้สร้างdist/client/(ทุก route ที่ prerender แล้ว เสิร์ฟเป็น static file) และdist/server/entry.mjs(server แบบ standalone ของ@astrojs/nodeที่จัดการboards/[id].astroแบบ on-demand และเสิร์ฟทุกอย่างในdist/client/ควบคู่ไปด้วย)ENV HOST=0.0.0.0/ENV PORT=4321— server แบบ standalone ของ@astrojs/nodeอ่านตัวแปร environment สองตัวนี้เพื่อตัดสินใจว่าจะ bind กับอะไร0.0.0.0ไม่ใช่127.0.0.1ด้วยเหตุผลเดียวกับที่ app-skeleton bind server ของ Rust กับ0.0.0.0:127.0.0.1ยอมรับ connection จากภายใน container เดียวกันเท่านั้น ซึ่งไม่มีใครเลยเมื่อสิ่งนี้รันอยู่หลัง network ของ DockerCMD ["node", "./dist/server/entry.mjs"]— ไม่มีnpmเกี่ยวข้องตอน runtime เลย ไม่มีastro preview— แค่nodeธรรมดารัน entry point ของ server ที่ bundle แล้วโดยตรง เป็นวิธีเริ่ม server ที่เล็กที่สุดเท่าที่ทำได้
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”จาก taskflow/:
docker build --build-arg PUBLIC_API_URL=http://localhost:8080 \ -f infra/frontend.Dockerfile -t taskflow-frontend .ที่คาดหวัง: npm ci และ npm run build รันใน builder stage (npm ci ครั้งแรก — รวม dev dependency — จะช้ากว่าในสองครั้ง) จากนั้น npm ci --omit=dev ครั้งที่สองที่เร็วกว่าใน runtime stage และ build จบด้วยการ tag taskflow-frontend
ยืนยันว่า bundle ฝัง URL ที่คุณส่งเข้าไปจริงๆ ไม่ใช่ placeholder:
docker run --rm taskflow-frontend grep -r "localhost:8080" dist/client/_astro/ | head -1ที่คาดหวัง: match อย่างน้อยหนึ่งรายการ — chunk _astro/*.js บางตัวที่มี literal string http://localhost:8080 อยู่ เป็นหลักฐานว่า PUBLIC_API_URL ถูกฝังไว้ตอน build time แทนที่จะเหลือเป็น runtime lookup ถ้าตรงนี้ว่างเปล่า ให้เช็กก่อนว่า --build-arg ถูกส่งเข้าไปใน docker build command จริงๆ — ค่า ARG ที่หายไปจะ build แบบเงียบๆ โดยที่ PUBLIC_API_URL ไม่ถูกกำหนด แทนที่จะ fail
taskflow/infra/frontend.Dockerfile build Node server ตัวเดียวกับที่ board-page รันบนเครื่อง local ด้วย npm run preview ตอนนี้อยู่ใน container: builder stage แบบ node:20 รัน npm ci และ npm run build โดยส่ง PUBLIC_API_URL เข้าไปเป็น build ARG (re-export เป็น ENV เพื่อให้ bundler ของ Vite มองเห็นได้จริง) และ runtime stage แบบ node:20-slim ทำ npm ci --omit=dev ของตัวเองก่อน copy เอาแค่ dist/ เข้ามาแล้วรัน node ./dist/server/entry.mjs บน HOST=0.0.0.0, PORT=4321 แนวคิดหนึ่งที่ควรจำไว้: ตัวแปร PUBLIC_ เป็นเรื่องของ build time สำหรับ Astro ถูกฝังลง bundle ทันทีที่ npm run build จบ ไม่เคยเป็น runtime lookup เลย — ได้ URL ผิดตอน build แล้ว ไม่มี docker run -e ไหนแก้ได้ในภายหลัง ต่อไป compose-full เชื่อม image ทั้งสอง — backend และ frontend — เข้ากับ docker-compose.yml เดียวกันที่มี db และ redis อยู่แล้ว และเดินผ่านทั้ง stack ตั้งแต่ต้นจนจบ: register, login, สร้าง board และดูการลาก-วางย้ายแบบ live sync กันระหว่าง browser tab สองแท็บ