如何创建一个小的容器镜像

如何创建一个小的容器镜像

对 golang 程序上线到了容器,在使用 scratch 的时候遇到了一个小小的问题,顺手对编译做了一些整理. 以下:

编写一个简单的Go应用程序,例如 gin 的例子 **main.go**:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"net/http"

"github.com/gin-gonic/gin"
)

func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

例子1: Dockerfile 编译

使用容器来编译运行程序

1
2
3
4
5
6
7
# 使用官方的 Golang 镜像作为基础镜像
FROM golang:1.20
WORKDIR /app
COPY . .
RUN go build -o main
EXPOSE 8080
CMD ["./main"]

通过构建的到了容器大小为 1.16GB

1
docker-golang-build-example1              latest               cd34e318087d   40 seconds ago   1.16GB

这个时候会容器的内容又一些大了. 可以看看 https://hub.docker.com/_/golang

golang:1.20 是基于 Debian 来作为基础镜像,本身就很大.

例子2: 使用 apline

我们可以看到提供了 alpine 作为基础镜像的包 golang:<version>-alpine ,只需要将 FROM 后面的镜像加上 alpine 即可

1
2
3
4
5
6
7
# 使用官方的 Golang alpine 镜像作为基础镜像
FROM golang:1.20-alpine
WORKDIR /app
COPY . .
RUN go build -o main
EXPOSE 8080
CMD ["./main"]

Untitled.png

使用 alpine 作为基础镜像会存在一些问题, 它使用了 musl libc 来代替 glibc ,小的镜像和稳定性的讨论会有一些不同的意见: https://news.ycombinator.com/item?id=10782897

镜像的大小从 1.16G 变成了 570M

例子3: 分段编译,使用 busybox

在编译过程中, go build 会拉取依赖,使用分段将编译和运行的镜像分开. golang 在运行时不需要编译环境,这里我直接使用 busybox 来作为基础镜像,同时例子2中使用 alpine 会存在一些问题,所以我们使用 busybox 的 glibc 的包.

1
2
3
4
5
6
7
8
9
10
11
FROM golang:1.20 AS build
WORKDIR /app
COPY . .
RUN go build -o main

# 最终阶段
FROM busybox:glibc
WORKDIR /app
COPY --from=build /app/main /app
EXPOSE 8080
CMD ["./main"]

Untitled.png

编译后得到了 15.5M 的镜像.

例子4: 静态编译,使用 scratch

在容器镜像中有一个 0M 的基础镜像 scratch ,它是所以镜像的基础.

容器本质上是基于Linux内核的Namespace、Cgroups和Union FS等技术对进程进行封装隔离的操作系统层面的虚拟化技术。

我们可以通过静态编译的方式,基于 scratch 来运行. 构建Go应用程序,并确保它是静态链接的。可以使用以下命令来构建:

1
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app main.go

在编译中添加了 -a -installsuffix cgo 两个条件,并指定了CGO_ENABLED=0:

-a : 强制重新构建
-installsuffix cgo: 指示编译器将生成的目标文件和包安装到一个特定的目录中,以便将与C语言绑定的包(使用**cgo**工具)与其他Go包区分开。
CGO_ENABLED=0 : cgo 工具标识为禁用

对 例子三 中的 dockerfile 做编译做调整,这个Dockerfile分为两个阶段:构建阶段和最终阶段。在构建阶段,它使用了官方的**golang镜像来构建Go应用程序,然后在最终阶段使用了scratch**基础镜像来创建最终的容器。

1
2
3
4
5
6
7
8
9
10
# 构建阶段
FROM golang:1.20 AS build
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app

# 最终阶段
FROM scratch
COPY --from=build /app/app /app
CMD ["/app"]

Untitled.png

编译后 docker-golang-build-example4 的大小从 15.5M 西江到了 11.2M, 这一点点的差值几乎就是 busybox 的存储占用.

要注意的问题

使用 scratch 镜像是一个空镜像,所以需要特别注意一些依赖和系统调用.

涉及到依赖时,需要解依赖,比如证书依赖:

COPY --from=builder /etc/ssl/certs/ /etc/ssl/certs/

涉及到系统调用,无法使用 scratch ,就需要考虑 apline 的方案.

例子5 加快编译速度

当把运行镜像的大小降下来后,还存在另一个问题,运行 go build 的时候都需要重新拉一遍golang 的环境. 可以通过挂在缓存的位置来减少应用程序编译时的时间.比如 golang 的 pkg目录 $GOPATH/pkg .在编译前提前做好 pkg 缓存,将缓存复制到目录中.

假设已经准备好了缓存目录 pkg. 在 例子4 中添加 COPY pkg/* /go/pkg/ 来加快编译速度.

1
2
3
4
5
6
7
8
9
10
11
# 构建阶段
FROM golang:1.20 AS build
WORKDIR /app
COPY pkg/* /go/pkg/
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app

# 最终阶段
FROM scratch
COPY --from=build /app/app /app
CMD ["/app"]

这个例子可能对 golang 的意义不是特别大, 对一些依赖比较多的 node 程序的 node_modele 目录缓存就很有用.

总结

通过四种方式,不断的降低了容器镜像的大小. 但是四种方式并没有特别的优劣之分,在不同的场景下,可以通过不同的策略来做镜像编译的方式.

  • 使用更小的镜像 apline 、busybox、scratch 来降低镜像大小
  • 使用分阶段构建来减少
  • 使用缓存来加快编译

在没有多阶段编译的时期,还有将多条命令写成一条的方式来降低容器的层数,或者借助一些工具来合并层.

以上例子放在了 github ,可以使用 docker-compose 来验证

docker-golang-build-example

作者

张巍

发布于

2023-10-19

更新于

2023-10-19

许可协议

评论