Building effective Docker image for Go (w/ Multi-Stage build)

Wasin Watt
3 min readAug 2, 2018

เนื่องจากช่วงนี้กำลังเพิ่มสกิลเกี่ยวกับ DevOps ก็เลยถือโอกาสเขียนบทความขึ้นมา ด้วยเลย เผื่อจะมีคนสนใจเพิ่มสกิลด้านนี้เหมือนกัน และแน่นอนภาษาที่ใช้อ้างอิงก็ต้องเป็น Go นะจ๊ะ … (หาสาวกเพิ่ม 555)

เข้าเรื่อง

การสร้างและใช้งาน docker image/container เป็นเรื่องที่จำเป็นมากอย่างนึงใน business ที่เกี่ยวข้องกับ server และ application และบางครั้ง container ที่สร้างขึ้นมาก็มีขนาดค่อนข้างใหญ่ ทำให้เสีย resource แบบไม่จำเป็น

ซึ่งเราจะมาลองสร้าง image/container หลายๆวิธี ตั้งแต่วิธีทั่วไป ซึ่งทำให้ container มีขนาดใหญ่ ไปจนถึงวิธีที่ optimized จนมีขนาดเล็กกัน :D

0. Get your server ready

ก่อนจะเริ่ม เราต้องมี Server repo ที่อยากจะ build for production เพื่อความง่ายขอใช้ชื่อ simple-server ซึ่ง handle แค่ endpoint เดียวละกัน

ซึ่งใน folder นั้นมี main.go ที่เป็น file หลักในการ serve ทุกอย่าง แบบนี้

package main
import (
"fmt"
"log"
"net/http"
)
func main() { http.HandleFunc("/health", func(w http.ResponseWriter,
r *http.Request) {

w.WriteHeader(200)
fmt.Fprintln(w, "Server is healthy")

})
log.Println("Server is running...")
http.ListenAndServe(":8080", nil)
}

แล้วก็อย่าลืมสร้าง Dockerfile ลงในโฟลเดอร์ simple-server ด้วย

เมื่อทุกอย่างพร้อมแล้ว ก็ลองมา build image กัน :)

1. Straight-forward

วิธีที่พื้นฐานและตรงไปตรงมาที่สุด ก็คือการ pull base image ที่มี dependencies ทุกอย่างที่สามารถ build และ compile go ได้ จากนั้นจึงเพิ่ม layer ที่ต้องการจนสามารถสั่งรัน application ได้

FROM golang:1.8-alpineADD . /go/src/simple-serverWORKDIR /go/src/simple-serverRUN go build -o simple-server .ENTRYPOINT [ "./simple-server" ]

เมื่อเริ่ม build โดยพิมคำสั่ง

docker build -t simple-server:v1 .

Dockerfile นี้จะทำการใช้ golang:1.8-alpine เป็น base image จากนั้น build & compile go ให้เป็น binary (simple-server) แล้วจึงรัน binary นั้นตามลำดับ

ถ้าไม่มีอะไรผิดพลาด เมื่อลองเช็ค docker images เราก็จะได้ image ที่มีชื่อว่า simple-server และมี tag v1 ดังนี้

เย่ !! ได้ image มาแล้ว ง่ายมากถูกไหม … แต่ลองดูที่ size ของมัน ซึ่งใหญ่ถึง 263MB!!! …

มันมาจากไหน ?

ถ้าลองไปดู Base image จะรู้ว่า golang:1.8-alpine นั้นมีขนาดถึง 257MB แสดงว่า server เราจริงๆเพิ่มมาแค่ประมาณ 6MB เท่านั้น แต่ช่วยไม่ได้เพราะเราไป build และ run บน base image นั้นทำให้ไฟล์เราขนาดใหญ่ตามไปด้วย

ถ้าเทียบง่ายๆก็ server ของเราคือรถยนต์, base image คือโรงงานผลิตรถยนต์, และ Final image ของเราคือเมืองที่จะนำรถไปวิ่ง ซึ่งในวิธีแรกที่กล่าวมานี้ เราได้สร้างเมืองโดยนำโรงงานผลิตรถยนต์ติดมาด้วย ทำให้เมืองใช้พื้นที่มากขึ้น

ในขึ้นตอนต่อไปเราจะมาลองแก้ไขปัญหาตรงนี้ดู

2. Custom build script

ถ้าไม่อยากจะพ่วง base image หนักๆที่เต็มไปด้วย dependencies และเครื่องมือที่ไม่จำเป็นติดไปด้วย ก้ build go binary บนเครื่องตัวเองก่อน แล้วค่อยยัด binary นั้นใส่ base image ที่เล็กๆแทน

Dockerfile V2 ของเราก็จะได้ประมาณนี้

FROM scratchADD simple-server .ENTRYPOINT [ "./simple-server" ]

ใน Dockerfile V2 นี้ base image ที่เราใช้คือ scratch ซึ่งอาจจะเรียกว่า root image ของทุก image ก็ได้ (optimized สุดๆ)

ซึ่งวิธีนี้ก็ต้องพึ่ง script ช่วย เพราะเราไม่สามารถ build โดยใช้ Dockerfile ได้อีกแล้วเราจึงต้องทำการ build go binary เองก่อน จากนั้นจึงค่อยสั่ง docker build

# build go binary
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o simple-server .
# build image via Dockerfile V2
docker build -t simple-server:v2 .

Image ที่ได้ก็จะประมาณนี้

ขนาดเหลือแค่ 6.59MB เท่านั้นเอง !!!

แต่ปัญหาก็คือเราต้อง build binary นอก Dockerfile จะมีวิธีอื่นไหมที่สามารถแก้ตรงปัญหาตรงนี้ได้ ?

3. Multi-Stage build

จริงๆ วิธีนี้ไม่ได้ช่วยเรื่องขนาดให้เล็กลงจากขึ้นตอนที่ 2 เท่าไหร่ แต่ช่วยให้การ build นั้นจบลงที่ Dockerfile ไฟล์เดียวได้ ซึ่งมันสามารถลดความซับซ้อนยุ่งเหยิงในการทำงานของ Engineer ได้

Dockerfile V3 ของเราก็จะเป็นแบบนี้

FROM golang:1.8-alpine as builderADD . /go/src/simple-serverWORKDIR /go/src/simple-serverRUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /go/bin/simple-server .FROM scratchCOPY --from=builder /go/bin/simple-server .ENTRYPOINT [ "./simple-server" ]

ความสามารถตามชื่อ “Multi-Stage build” ตอนนี้ Dockerfile V3 ทำการ build image ขึ้นสองตัวใน Dockerfile เดียวกัน คือ

  • image จาก FROM golang:1.8 alpine และ
  • image จาก FROM scratch

ซึ่ง image ตัวแรกนั้นใช้สำหรับ build go binary เพื่อใช้ในการรันเท่านั้นเอง(ขอเรียก image ตัวนี้ว่า builder)

จากนั้นเมื่อสร้าง ‘builder’ image เสร็จ ก็สร้าง image ที่ใช้สำหรับ run server ของเรา โดยแค่ยัดสิ่งที่เราต้องการ (go binary) มาจาก builder ไปใส่ใน image เรา และสั่ง execute ก็เป็นอันจบ

Image สุดท้ายจาก Dockerfile V3 ของเราก็จะเป็นแบบนี้

เย่ :D

ทั้งนี้ทั้งนั้น ไม่ใช่ทุกภาษาจะ support การ optimized แบบนี้ได้ทั้งหมด เนื่องจาก go สามารถ compile เป็น binary ได้ ทำให้สามารถใช้ base image เป็น scratch ได้

แถม ->ลองเทียบกับ node.js ดูเล่นๆละกัน :D

// index.js
var http = require('http');
http.createServer(function (req, res) { res.write('Hello World!'); res.end();}).listen(8080);

ไฟล์ index.js สั้นๆง่ายๆ ไม่ต้องมี Framework … Build image แล้วจะเป็นยังไงนะ ?

และนี่ก็เป็นอีกเรื่องที่ Golang wins !

Welcome to Golang :)

*ผิดถูกยังไงบอกได้นะครับ*

--

--

Wasin Watt

Terraformer @ a crypto payment gateway, Ex-ThoughtWorker. Building on Terra & ETH.