- Published on
การสร้าง Docker Image สำหรับ Go ให้เหมาะกับ Production
- Authors
- Name
- Somprasong Damyos
- @somprasongd
การสร้าง Docker Image สำหรับ Go ให้เหมาะกับ Production
แนวทางสร้าง Go container image ที่ เล็ก ปลอดภัย deploy ง่าย และ maintain ได้ระยะยาว
เมื่อพัฒนาแอปพลิเคชันด้วยภาษา Go หนึ่งในจุดเด่นที่สำคัญคือการสร้างไฟล์ไบนารีแบบ static ทำให้เหมาะอย่างยิ่งสำหรับนำไปบรรจุใน Docker container ที่มีขนาดเล็กและปลอดภัย ซึ่ง Alpine Linux เป็นฐานที่คนนิยมใช้เพราะขนาดเล็กและมีแพ็กเกจพื้นฐานเพียงพอสำหรับงานส่วนใหญ่
แต่การจะทำให้ container ขนาดเล็กและพร้อมใช้จริงใน production ต้องเข้าใจประเด็นสำคัญทั้งในแง่ของ build, dependency, และการจัดการ image ให้มีความเสถียรและปลอดภัย
ปิด cgo (CGO_ENABLED=0) เพื่อสร้าง static binary
โดยปกติ Go จะเปิดใช้ cgo เป็นค่าเริ่มต้นหากโค้ดมีการเรียกไลบรารี C หรือใช้ฟังก์ชันมาตรฐานบางส่วนที่อ้างอิง libc ซึ่งอาจทำให้ไฟล์ไบนารีต้องพึ่งพา shared library ภายนอก เช่น glibc หรือ musl
เมื่อใช้ Alpine ซึ่งใช้ musl libc แทน glibc ปัญหาที่พบบ่อยคือไฟล์ไบนารีที่พึ่ง glibc จะรันไม่สำเร็จ หากต้องการความแน่นอนว่ารันได้ทุกที่บน Linux base image ควรตั้ง CGO_ENABLED=0
เพื่อให้ Go สร้างไฟล์ไบนารีที่ลิงก์แบบ static ทั้งหมด ลดปัญหา dependency ภายนอก
ตัวอย่างที่นิยมคือ
ENV CGO_ENABLED=0
ลดขนาดไฟล์ด้วย ldflags "-s -w"
ตัวเลือก -ldflags="-s -w"
เป็นอีกเทคนิคที่ใช้กันทั่วไปเพื่อลดขนาดไฟล์ที่ได้จาก go build
s
จะตัด symbol table ซึ่งไม่จำเป็นต่อการรันจริงw
จะตัดข้อมูลสำหรับการ debug (DWARF)
ผลคือขนาดไฟล์จะลดลงได้หลาย MB แต่ข้อควรระวังคือ หากต้องใช้เครื่องมือ debug เช่น Delve ข้อมูลเหล่านี้จะหายไป ทำให้ debug ได้ลำบากขึ้น เทคนิคนี้จึงเหมาะสำหรับ build ไบนารีที่ใช้จริงใน production เท่านั้น
ทำไมไม่ควรใช้ alpine:latest
หลายคนมักเขียน Dockerfile ว่า FROM alpine:latest
เพราะง่าย แต่ในทางปฏิบัติ นี่เป็นสิ่งที่ควรหลีกเลี่ยง เนื่องจาก tag latest
ไม่ได้ผูกกับ version ใด ๆ แบบตายตัว ภายใน repository อาจอัปเดตเมื่อใดก็ได้โดยไม่ประกาศล่วงหน้า ซึ่งทำให้ build ครั้งถัดไปอาจได้ base image ที่ไม่เหมือนเดิมและอาจเกิดปัญหาใหม่โดยไม่รู้ตัว
แนวทางที่ควรทำคือระบุเวอร์ชันให้ชัดเจน เช่น FROM alpine:3.18
เพื่อให้แน่ใจว่าผลลัพธ์ reproducible และ rollback ได้ง่ายหากเกิดปัญหา
ใช้ Multi-Stage Build เพื่อลดขนาดและจัดการได้ง่าย
Dockerfile สำหรับ Go ที่ดีควรแยกขั้นตอน build ออกจากขั้นตอน runtime โดยใช้ multi-stage build ขั้นแรกใช้ image golang:<version>-alpine
สำหรับ compile โค้ด ขั้นถัดไปใช้ alpine:<version>
หรือแม้แต่ scratch
เพื่อลดขนาด image
ตัวอย่างโครงสร้าง
# Build Stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
ARG VERSION=latest
RUN go build -ldflags="-s -w -X 'main.Version=${VERSION}'" -o /app/app ./cmd/api/main.go
# Final Stage
FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S appgroup \
&& adduser -S appuser -G appgroup
WORKDIR /app
COPY /app/app .
USER appuser
EXPOSE 8080
ENTRYPOINT ["./app"]
ใช้ Non-Root User
อีกหนึ่งจุดที่หลายคนมองข้ามคือ user ที่ container ใช้รันโปรเซส เริ่มต้น container จะรันด้วย root ซึ่งถ้าเกิดช่องโหว่ ผู้โจมตีอาจใช้สิทธิ root ภายใน container เพื่อโจมตีต่อได้ง่าย
วิธีแก้คือสร้าง user สิทธิจำกัด แล้วสั่งให้ container รันด้วย user นี้แทน การเพิ่มบรรทัด adduser
และ USER
ใน Dockerfile เป็นวิธีปฏิบัติมาตรฐานที่ช่วยปิดความเสี่ยงนี้ได้ดี
ENTRYPOINT vs CMD
หลายคนสับสนระหว่าง ENTRYPOINT
และ CMD
ว่าควรใช้แบบไหน ต่างกันอย่างไร
ENTRYPOINT
ใช้กำหนด คำสั่งหลัก ที่ container ต้องรันเสมอ ไม่ว่าผู้ใช้จะสั่งdocker run
พร้อม argument อะไร คำสั่งนี้จะถูกเรียกเสมอ โดย argument ที่ตามมาจะถูกต่อท้ายCMD
ใช้กำหนด default arguments ถ้าdocker run
ไม่ได้ระบุ argument ใหม่ ระบบจะใช้ค่าในCMD
แทน แต่ถ้าผู้ใช้ระบุ argument ใหม่ทั้งหมดCMD
จะถูกแทนที่ทันที
ตัวอย่างเช่น
ENTRYPOINT ["./app"]
CMD ["--port=8080"]
กรณีนี้ หากรัน docker run myapp
จะได้ ./app --port=8080
ถ้ารัน docker run myapp --help
จะได้ ./app --help
การใช้ ENTRYPOINT
แบบ exec form (["./app"]
) ยังช่วยให้โปรเซสของเราทำงานเป็น PID 1 โดยตรง ทำให้จัดการ signal ได้ถูกต้อง โดยเฉพาะ SIGTERM ซึ่งสำคัญต่อการทำ graceful shutdown ใน production
สรุปแนวทาง Production Docker Image สำหรับ Go
- ปิด cgo (
CGO_ENABLED=0
) เพื่อสร้างไฟล์ static - ใช้
ldflags="-s -w"
เพื่อลดขนาดไฟล์ - ระบุ base image version ชัดเจน เช่น
alpine:3.18
อย่าใช้latest
- แยกขั้น build ออกจากขั้น runtime ด้วย multi-stage build
- สร้าง non-root user เพื่อลดความเสี่ยง
- ใช้
ENTRYPOINT
เพื่อกำหนด command หลัก และCMD
เพื่อกำหนด default arguments
แนวทางทั้งหมดนี้จะช่วยให้ได้ Docker Image ที่ขนาดเล็ก เสถียร ปลอดภัย และจัดการได้ง่ายจริงในงาน production