创建优化的Go镜像文件以及踩过的坑

25次阅读

共计 6220 个字符,预计需要花费 16 分钟才能阅读完成。

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

源码:

完整源码的 github 链接

索引

  1. Dockerfile reference
  2. Use multi-stage builds
  3. Statically compiled Go programs, always, even with cgo, using musl
  4. Installed Go binary not found in path on Alpine Linux Docker
  5. How can I keep a container running on Kubernetes?
  6. My kubernetes pods keep crashing with“CrashLoopBackOff”but I can’t find any log
  7. Building Docker Containers for Go Applications

本文由博客一文多发平台 OpenWrite 发布!

正文完
 0