創建優化的Go鏡像文件以及踩過的坑
- 2019 年 10 月 24 日
- 筆記
在Docker上創建Go鏡像文件並不困難,但建立的文件很大,接近1G,使用起來不太方便。Docker鏡像的一個主要難題就是如何優化,創建小的鏡像。我們可以用多級構建的方法來創建Docker鏡像文件,它也不複雜。但由於使用這種方法時,需要用簡版的Linux(Alpine),它帶來了一系列的問題。本文講述如何解決這些問題並成功創建優化的Go鏡像文件,優化之後只有14M。
單級構建:
我們用一個Go程式作為例子來展示如何創建Go鏡像。下面就是這個程式的目錄結構。
Go程式的具體內容並不重要,只要能運行就行了。我們重點關注「docker」子目錄(「kubernetes」子目錄里的文件有別的用途,會在另外的文章中講解)。它裡面有三個文件。「docker-backend.sh」是創建鏡像的命令文件,「Dockerfile-k8sdemo-backend」是多級構建文件,「Dockerfile-k8sdemo-backend-full」是單級構建文件,
FROM golang:latest # 從Docker庫中獲取標準golang鏡像 WORKDIR /app # 設置鏡像內的當前工作目錄 COPY go.mod go.sum ./ # 拷貝Go的包管理文件 RUN go mod download # 下載依賴包中的依賴庫 COPY . . #從宿主機拷貝文件到鏡像 WORKDIR /app/cmd # 設置新的鏡像內的當前工作目錄 RUN GOOS=linux go build -o main.exe #編譯Go程式,並在生成可執行文件 CMD exec /bin/bash -c "trap : TERM INT; sleep infinity & wait" # 保持鏡像一直運行,容器不被停掉
上面就是「Dockerfile-k8sdemo-backend-full」鏡像文件。請閱讀文件中的注釋以獲得解釋。
生成鏡像容器
cd /home/vagrant/jfeng45/k8sdemo/ docker build -f ./script/kubernetes/backend/docker/Dockerfile-k8sdemo-backend-full -t k8sdemo-backend-full .
運行鏡像容器,「–name k8sdemo-backend-full」是給這個容器一個名字(k8sdemo-backend-full),最後的「k8sdemo-backend-full」是鏡像的名字
docker run -td --name k8sdemo-backend-full k8sdemo-backend-full
登錄鏡像容器, 其中「a95c」是容器ID的前四位。
docker exec -it a95c /bin/bash
文件里有一條語句需要特別解釋一下「COPY . .」,它把文件從宿主機拷貝到鏡像里,在鏡像里已經用「WORKDIR」設置了當前工作目錄,那麼宿主機的「.」(當前目錄)是哪個目錄呢?它不是Dockerfile文件所在的目錄,而是你運行「Docker build」命令時所在的目錄。
我們要把整個程式都拷貝到鏡像里,那麼在運行docker命令時一定是在程式的根目錄,也就是「k8sdemo」目錄。但是與容器有關的文件都在「script」目錄的子目錄下,那麼當你運行「Docker build」命令時,它是怎麼找到Docekrfile的呢?這裡有一個重要的概念就是「build cotext」(構建上下文),由它來決定Dockerfile的預設目錄。當你運行「docker build -t k8sdemo-backend .」創建鏡像時,它會從「build cotext」的根目錄去找Dockerfile文件,預設值是你運行docker命令的目錄。但由於我們的Dockerfile在另外的目錄里,因此需要在命令里加一個「-f」選項來指定Dockerfile的位置,命令如下。 其中「-t k8sdemo-backend-full」 是指明鏡像名,格式是「name:tag」, 我們這裡沒有tag,就只有鏡像名。
docker build -f ./script/kubernetes/backend/docker/Dockerfile-k8sdemo-backend-full -t k8sdemo-backend-full .
詳情請參見Dockerfile reference
這樣創建的鏡像用的是全版的Linux系統,因此比較大,大概接近1G。如果要想優化,就要用多級構建。
Multi-stage builds(多級構建):
單級構建只有一個「From」語句,而在多級構建中,有多個「From」,每個「From」構成一級。例如,下面的文件有兩個「From」,是一個二級構建。每一級都可以根據需要選擇適合自己的基礎(base)鏡像來構造本級鏡像。每級鏡像完成之後,下一級鏡像可選擇只保留上一級構建中對自己有用的最終文件,而刪除所有的中間產物,這樣就大大節省了空間。詳情請參見Use multi-stage builds
下面就是多級構建的dockerfile(「Dockerfile-k8sdemo-backend」).
FROM golang:latest as builder # 本級鏡像用「builder」標識 # Set the Current Working Directory inside the container WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . WORKDIR /app/cmd # Build the Go app #RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main.exe RUN go build -o main.exe ######## Start a new stage from scratch ####### FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ RUN mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 # Copy the Pre-built binary file from the previous stage COPY --from=builder /app/cmd/main.exe . #把「/app/cmd/main.exe」文件從「builder」中拷貝到本級的當前目錄 # Command to run the executable CMD exec /bin/sh -c "trap : TERM INT; (while true; do sleep 1000; done) & wait"
創建鏡像:
cd /home/vagrant/jfeng45/k8sdemo/ docker build -f ./script/kubernetes/backend/docker/Dockerfile-k8sdemo-backend -t k8sdemo-backend .
登錄鏡像:
docker run -it --name k8sdemo-backend k8sdemo-backend /bin/sh
上面的文件把構造過程分成兩部分,第一部分編譯並生成Go可執行文件,用的是是全版Linux. 第二部分是拷貝可執行文件到合適的目錄並保持容器運行,用的是簡化版Linux。第一部分的命令與單級構建指令基本相同,第二部分的命令會在後面解釋。
使用這種方法大大減少了空間佔用,創建的Docker鏡像只有14M,但由於它使用的簡化版的Linux(Alpine),導致我踩了很多坑,下面看看這些坑是如何被填上的。
踩過的坑:
1. 找不到文件
創建鏡像成功後,登錄鏡像:
docker run -it --name k8sdemo-backend k8sdemo-backend /bin/sh
運行編譯後的Go可執行文件「main.exe」,錯誤資訊如下:
~ # ./main.exe ./main.exe not found
Go是一個靜態編譯的語言,也就是說在編譯時就把需要的庫存放在編譯好的程式里了,這樣在執行時就不需要再動態鏈接其它庫,使得運行起來非常方便。但並不是所有情況下都是這樣,例如但當你使用了cgo(讓Go程式可以調用C程式)時,通常需要動態鏈接libc庫(在Linux里是glibc)。Go里的net和os/user庫都用了cgo。但由於Apline的Linux版本沒有libc庫,這樣在運行時就找不到動態鏈接,因此報錯。它有兩種辦法來解決:
- CGO_ENABLED=0:當你在編譯Go時加了這個參數,編譯時就不會使用cgo,當然也就意味著使用cgo的庫都不能用了。這是最簡單的辦法,但它對你的程式有所限制。
- 使用musl:musl是一個輕量級的libc庫。Apline的Linux版本里自帶musl庫,你只要加入如下命令就行了。
RUN mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2
關於musl的詳情請參見Statically compiled Go programs, always, even with cgo, using musl
關於這個錯誤的討論請參見Installed Go binary not found in path on Alpine Linux Docker
2. Zap報錯
Zap是一個很流行的Go日誌庫,我在程式里用它來輸出日誌。當加上上面的語句後,原來的錯誤消失了,但又有一個新的錯。它是由Zap產生的。
~ # ./main.exe panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x6a37ab] goroutine 1 [running]: github.com/jfeng45/k8sdemo/config.initLog(0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, ...) /app/config/zap.go:94 +0x1fb github.com/jfeng45/k8sdemo/config.RegisterLog(0x0, 0x0) /app/config/zap.go:42 +0x42 github.com/jfeng45/k8sdemo/config.BuildRegistrationInterface(0x751137, 0x5, 0x43ab77, 0x984940, 0xc00002c750, 0xc000074f50) /app/config/appConfig.go:23 +0x26 main.testRegistration() /app/cmd/main.go:18 +0x3a main.main() /app/cmd/main.go:11 +0x20
我現在也不十分清楚出錯的原因,應該是跟Musl庫有關。估計是Zap用到的某個庫與Musl不兼容。我把日誌換成另一個庫Logrus問題就不存在了。這確實有點小遺憾,Zap是迄今為止我發現的最好的Go日誌庫。如果你堅持用Zap的話就只能用全版Linux,忍受大的鏡像文件;或者改用Logrus日誌庫,這樣就可以享受小的鏡像文件。
3. k8s部署不成功
換成Logrus之後,就沒再報錯,Docker里的程式運行正常。但如果你用這個鏡像創建k8s部署時又出了問題。
下面是k8s創建部署的命令:
vagrant@ubuntu-xenial:~/jfeng45/k8sdemo/script/kubernetes/backend$ kubectl get pod k8sdemo-backend-deployment-6b99dc6b8c-2fwnm NAME READY STATUS RESTARTS AGE k8sdemo-backend-deployment-6b99dc6b8c-2fwnm 0/1 CrashLoopBackOff 42 3h10m
錯誤資訊是「CrashLoopBackOff」。它產生的原因是容器要求裡面的程式一直運行,一旦運行結束,容器就會停掉。k8s發現容器停掉之後會重新部署容器,然後又被停掉,這樣就陷入了死循環。
解決的辦法是在鏡像文件里加入如下命令:
CMD exec /bin/bash -c "trap : TERM INT; sleep infinity & wait"
詳情請參見How can I keep a container running on Kubernetes?和My kubernetes pods keep crashing with 「CrashLoopBackOff」 but I can’t find any log
4. Pod出錯
加入命令,重新生成鏡像之後,果然解決了死循環的問題,k8s部署沒有報錯,但Pod又有了新的錯誤如下,「k8sdemo-backend-deployment-6b99dc6b8c-n6bnt」的「STATUS」是「Error」。
vagrant@ubuntu-xenial:~/jfeng45/k8sdemo/script/kubernetes/backend$ kubectl get pod NAME READY STATUS RESTARTS AGE envar-demo 1/1 Running 8 16d k8sdemo-backend-deployment-6b99dc6b8c-n6bnt 0/1 Error 1 6s k8sdemo-database-deployment-578fc88c88-mm6x8 1/1 Running 2 4d21h nginx-deployment-77fff558d7-84z9z 1/1 Running 3 10d nginx-deployment-77fff558d7-dh2ms 1/1 Running 3 10d
原因是在Docker文件里運行了如下命令:
CMD exec /bin/bash -c "trap : TERM INT; sleep infinity & wait"
但Alpine里沒有「/bin/bash」.需要改成「/bin/sh」,需要修改成如下命令:
CMD exec /bin/sh -c "trap : TERM INT; (while true; do sleep 1000; done) & wait"
修改之後,k8s部署成功,程式運行正常。
源碼:
索引
- Dockerfile reference
- Use multi-stage builds
- Statically compiled Go programs, always, even with cgo, using musl
- Installed Go binary not found in path on Alpine Linux Docker
- How can I keep a container running on Kubernetes?
- My kubernetes pods keep crashing with 「CrashLoopBackOff」 but I can’t find any log
- Building Docker Containers for Go Applications
本文由部落格一文多發平台 OpenWrite 發布!