創建優化的Go鏡像文件以及踩過的坑

原文鏈接:https://segmentfault.com/a/1190000020784107

在 Docker 上創建 Go 鏡像文件並不困難,但建立的文件很大,接近 1G,使用起來不太方便。Docker 鏡像的一個主要難題就是如何優化,創建小的鏡像。我們可以用多級構建的方法來創建 Docker 鏡像文件,它也不復雜。但由於使用這種方法時,需要用簡版的 Linux(Alpine),它帶來了一系列的問題。本文講述如何解決這些問題併成功創建優化的 Go 鏡像文件,優化之後只有 14M。

單級構建

我們用一個 Go 程序作為例子來展示如何創建 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

[1]

這樣創建的鏡像用的是全版的 Linux 系統,因此比較大,大概接近 1G。如果要想優化,就要用多級構建。

Multi-stage builds(多級構建)

單級構建只有一個“From”語句,而在多級構建中,有多個“From”,每個“From”構成一級。例如,下面的文件有兩個“From”,是一個二級構建。每一級都可以根據需要選擇適合自己的基礎(base)鏡像來構造本級鏡像。每級鏡像完成之後,下一級鏡像可選擇只保留上一級構建中對自己有用的最終文件,而刪除所有的中間產物,這樣就大大節省了空間。詳情請參見Use multi-stage builds[2]

下面就是多級構建的 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[3]

關於這個錯誤的討論請參見Installed Go binary not found in path on Alpine Linux Docker[4]

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?[5]和My kubernetes pods keep crashing with “CrashLoopBackOff” but I can't find any log[6]

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 部署成功,程序運行正常。

源碼:

完整源碼的 github 鏈接[7]

文中鏈接

[1]Dockerfile reference: https://docs.docker.com/engine/reference/commandline/build/

[2]Use multi-stage builds: https://docs.docker.com/develop/develop-images/multistage-build/

[3]Statically compiled Go programs, always, even with cgo, using musl: http://dominik.honnef.co/posts/2015/06/statically_compiled_go_programs__always__even_with_cgo__using_musl/

[4]Installed Go binary not found in path on Alpine Linux Docker: https://stackoverflow.com/questions/34729748/installed-go-binary-not-found-in-path-on-alpine-linux-docker

[5]How can I keep a container running on Kubernetes?: https://stackoverflow.com/questions/31870222/how-can-i-keep-a-container-running-on-kubernetes

[6]My kubernetes pods keep crashing with “CrashLoopBackOff” but I can't find any log: https://stackoverflow.com/questions/41604499/my-kubernetes-pods-keep-crashing-with-crashloopbackoff-but-i-cant-find-any-lo

[7]完整源碼的github鏈接: https://github.com/jfeng45/k8sdemo


分享到:


相關文章: