前言 TL;DR
怎样才能给一个GO语言开发的项目打包成一个容器镜像, 并且做到又快又小(嗯?).
妥善安排Dockerfile里面的layer
尽量选择体积小的基础镜像
容器内容尽量精简
demo介绍 我把这个demo里面涉及到的代码, 依赖, 以及好几个Dockerfile都放到了github上, 大家可以自便:
https://github.com/RondoChen/docker-go-demo.git
顺便剧透一下, 最后构建出来的镜像, 在dockerhub: https://hub.docker.com/repository/docker/rondochen/go-demo-app
1 2 3 4 5 # 镜像下载以及试运行 docker pull rondochen/go-demo-app:multistage docker run -d -p 3000:3000 rondochen/go-demo-app:multistage
代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 package mainimport ( "net/http" "os" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) func main () { e := echo.New() e.Use(middleware.Logger()) e.GET("/" , func (c echo.Context) error { return c.HTML(http.StatusOK, "Hello, Docker! <3" ) }) e.GET("/ping" , func (c echo.Context) error { return c.JSON(http.StatusOK, struct { Status string }{Status: "OK" }) }) httpPort := os.Getenv("HTTP_PORT" ) if httpPort == "" { httpPort = "3000" } e.Logger.Fatal(e.Start(":" + httpPort)) }
smoke test 在运行代码之前, 你需要先有一个Golang的运行环境: https://go.dev/doc/tutorial/getting-started
笔者使用的是Go的1.18版本.
1 2 # go version go version go1.18.4 linux/amd64
安装好go的运行环境之后, 我们可以直接试运行一下demo代码:
1 2 3 4 5 6 git clone https://github.com/RondoChen/docker-go-demo.git cd docker-go-demo # 使用国内代理, 可以大大加快依赖下载速度 export GOPROXY=https://goproxy.cn go mod download && go mod verify go run main.go
demo会使用3000端口运行一个简单的http服务, 如果一切顺利(小心端口冲突), 运行起来之后是这样子的:
1 2 3 4 5 6 7 8 9 10 11 root@docker:/tmp/docker-go-demo# go run main.go ____ __ / __/___/ / ___ / _// __/ _ \/ _ \ /___/\__/_//_/\___/ v4.7.2 High performance, minimalist Go web framework https://echo.labstack.com ____________________________________O/_______ O\ ⇨ http server started on [::]:3000
使用curl
简单测试一下:
1 2 3 4 5 6 root@docker:~# curl 127.0.0.1:3000 Hello, Docker! <3root@docker:~# root@docker:~# root@docker:~# curl 127.0.0.1:3000/ping {"Status":"OK"} root@docker:~#
后面我们就都围绕着这个简单demo去做下面的测试.
构建docker镜像 我假设你已经调试好docker的运行环境了: https://docs.docker.com/get-started/
另外, 基于我们特殊的网络环境, 我有两个小建议:
找一个好使的网络代理
设置好国内的docker源
以上两点二选一, 但我个人倾向第一点, 大家可以自行斟酌.
Dockerfile.beginner 基于能跑就行的原则, 我们只需要找一个golang的镜像, 再把代码以及项目文件放进去就可以运行了.
这里, 编写一个Dockerfile.beginner
, 内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 FROM golang:1.18 ARG GOPROXY=https://goproxy.cn,directWORKDIR /app COPY main.go . COPY go.sum . COPY go.mod . RUN go mod download RUN go build -o /docker-go-demo EXPOSE 3000 CMD [ "/docker-go-demo" ]
制作镜像 编写好Dockerfile之后, 我们就可以使用docker build
去构建镜像了
1 2 3 4 5 6 cd docker-go-demo docker build --build-arg GOPROXY=https://mirrors.aliyun.com/goproxy/ -f ./Dockerfile.beginner -t rondochen/go-demo-app:beginner . docker image ls rondochen/go-demo-app:beginner REPOSITORY TAG IMAGE ID CREATED SIZE rondochen/go-demo-app beginner 7e2412bdac47 17 hours ago 1.06GB
使用容器运行 1 2 3 4 5 6 7 8 9 10 11 12 13 docker run -d -p 3000:3000 --name beginner rondochen/go-demo-app:beginner docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 7de5413c2c91 rondochen/go-demo-app:beginner "/docker-go-demo" 3 seconds ago Up 2 seconds 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp beginner # 简单测试一下 root@docker:/tmp/docker-go-demo# curl 127.0.0.1:3000 Hello, Docker! <3 root@docker:/tmp/docker-go-demo# curl 127.0.0.1:3000/ping {"Status":"OK"}
好了, 现在已经能跑了.
但如果在生产环境里面用这种镜像, 可能你的老板都想让你跑.
后面我们来改进一下.
改进过程 上面的镜像, 很明显存在几个问题:
镜像体积过于巨大, 1GB有余.
layer的安排不合理
太臃肿
初始镜像的选择 在dockerhub上面搜索镜像时, 同一个版本下往往还会有不同的区分, 常见的会有下面的后缀:
buster
alpine
slim
windowsservercore
等等
在上面的Dockerfile.beginner
中直接使用golang:1.18. 一般情况下, 这是最稳妥的镜像, 不会出问题, 但是副作用就是过分巨大, 类似这种大而全的镜像更适合用在开发环境的调试阶段.
另外, 像buster
, bullseye
这些后缀, 对应的是不同的Linux发行版的版本代号.
在大部分时候, 为了减少镜像体积, 我们会使用基于Alpine Linux的镜像. 所以, 我们后面可以尝试使用golang:1.18-alpine
.
layer的概念 为了节省空间, 容器镜像都是建立在layer(层)上的, 多个不同的镜像很有可能会重复利用相同的层. 也因此, Docker在管理镜像的时候, 其实是在记录每一个镜像都由哪些layer组成. 当你运行一个docker build
命令的时候, Dockerfile中的RUN
, COPY
, ADD
都会创建新的layer.
而为了最大限度地让docker减少创建新的layer, 在不影响程序运行的前提下, 我们在编写Dockerfile的时候应当尽量先把不经常变更的操作排在前面, 以使docker build
过程中可以尽可能多地复用已有的layer.
在我们的这个例子中, 像go.mod
和go.sum
这些记录代码依赖的文件, 相对来说不会频繁变更, 就应该先传入镜像并完成go mod download
操作. 最后再把其余会频繁变更的代码文件*.go
传入镜像, 最后再执行go build
.
在我们构建一个容器镜像的时候, 一条长期被强调的原则, 就是要尽可能地复用已有的layer以及最大限度地减少layer的数量.
Dockerfile.better 经过上面的两点优化, 我们就有了这个改进版的Dockerfile.better
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 FROM golang:1.18 -alpineARG GOPROXY=https://goproxy.cn,directWORKDIR /app COPY go.sum . COPY go.mod . RUN go mod download COPY *.go . RUN go build -o /docker-go-demo EXPOSE 3000 CMD [ "/docker-go-demo" ]
我们选择了一个alpine后缀的基础镜像, 再把COPY *.GO .
移到了RUN go mod download
后面.
最后构建完成之后, 镜像的体积减少到了422MB.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 # docker build -f ./Dockerfile.better -t rondochen/go-demo-app:better . Sending build context to Docker daemon 25.8MB Step 1/10 : FROM golang:1.18-alpine ---> 759ab1463be2 Step 2/10 : ARG GOPROXY=https://goproxy.cn,direct ---> Using cache ---> 204adfd69217 Step 3/10 : WORKDIR /app ---> Using cache ---> 89a7735a4ff3 Step 4/10 : COPY go.sum . ---> Using cache ---> a38f9e5c252b Step 5/10 : COPY go.mod . ---> Using cache ---> 6ba7a33d6f78 Step 6/10 : RUN go mod download ---> Running in 9a833e235590 Removing intermediate container 9a833e235590 ---> db120a440ec2 Step 7/10 : COPY *.go . ---> 843be98721cf Step 8/10 : RUN go build -o /docker-go-demo ---> Running in b09c13e7b039 Removing intermediate container b09c13e7b039 ---> 7c66ec913475 Step 9/10 : EXPOSE 3000 ---> Running in 339a6d8978f4 Removing intermediate container 339a6d8978f4 ---> 5e367446610e Step 10/10 : CMD [ "/docker-go-demo" ] ---> Running in 6fe046776e1a Removing intermediate container 6fe046776e1a ---> cb4971c4d949 Successfully built cb4971c4d949 Successfully tagged rondochen/go-demo-app:better # docker image ls rondochen/go-demo-app:better REPOSITORY TAG IMAGE ID CREATED SIZE rondochen/go-demo-app better cb4971c4d949 36 seconds ago 422MB
另外, 如果我们重复执行同一个docker build
的命令, 往往会很快完成, 屏幕的输出会有Using cache
的提示, 这也在侧面印证了前面说到的layer的概念.
Multi-stage builds 镜像体积减少到422MB, 还是太大了, 就引出了灵魂拷问:
程序都编译完了, 为什么还需要在容器里面保留一个编译环境?
那既然这样, 我能不能直接把编译好的程序放到一个小容器里运行就完了?
这就是Multi-stage builds
.
Dockerfile.multistage 你或许会以为我们需要两个Dockerfile, 一个完成代码的编译并把文件复制到容器外, 再创建一个镜像把编译好的文件重新传回去镜像里面, 但这两个步骤可以合并在一个Dockerfile中完成.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 FROM golang:1.18 -buster AS buildARG GOPROXY=https://goproxy.cn,directWORKDIR /app COPY go.mod . COPY go.sum . RUN go mod download COPY *.go ./ RUN go build -o /docker-go-demo FROM gcr.io/distroless/base-debian10WORKDIR / COPY --from=build /docker-go-demo /docker-go-demo EXPOSE 3000 USER nonroot:nonrootCMD ["/docker-go-demo" ]
docker build
过程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 # docker build --build-arg GOPROXY=https://mirrors.aliyun.com/goproxy/,direct -f ./Dockerfile.multistage -t rondochen/go-demo-app:multistage . Sending build context to Docker daemon 25.8MB Step 1/14 : FROM golang:1.18-buster AS build ---> 0e87973a8632 Step 2/14 : ARG GOPROXY=https://goproxy.cn,direct ---> Running in 80fd51d9706b Removing intermediate container 80fd51d9706b ---> 17a8aadfb51e Step 3/14 : WORKDIR /app ---> Running in 8035a66663e0 Removing intermediate container 8035a66663e0 ---> 582d8078abd6 Step 4/14 : COPY go.mod . ---> 80e5770bc3c5 Step 5/14 : COPY go.sum . ---> cde03c8a05a5 Step 6/14 : RUN go mod download ---> Running in f728ea778625 Removing intermediate container f728ea778625 ---> f430f98f5fb8 Step 7/14 : COPY *.go ./ ---> e059f4113c90 Step 8/14 : RUN go build -o /docker-go-demo ---> Running in 14676183967c Removing intermediate container 14676183967c ---> d941227ea866 Step 9/14 : FROM gcr.io/distroless/base-debian10 ---> 87cb41f09abc Step 10/14 : WORKDIR / ---> Using cache ---> 6264ead9986c Step 11/14 : COPY --from=build /docker-go-demo /docker-go-demo ---> 9382789816bf Step 12/14 : EXPOSE 3000 ---> Running in 18384c0619aa Removing intermediate container 18384c0619aa ---> 059b50232036 Step 13/14 : USER nonroot:nonroot ---> Running in 39f500eb3775 Removing intermediate container 39f500eb3775 ---> 7711ca40b235 Step 14/14 : CMD ["/docker-go-demo"] ---> Running in 9b04d0fe4448 Removing intermediate container 9b04d0fe4448 ---> 5b83d87470c8 Successfully built 5b83d87470c8 Successfully tagged rondochen/go-demo-app:multistage
最后我们获得了一个26.7MB的镜像:
1 2 3 # docker image ls rondochen/go-demo-app:multistage REPOSITORY TAG IMAGE ID CREATED SIZE rondochen/go-demo-app multistage 5b83d87470c8 7 minutes ago 26.7MB
Dockerfile.multistage 测试 再跑一个测试, 我们可以发现, 这个26.7MB的镜像, 运行起来和那个1GB的完全一样.
1 2 3 4 5 6 7 8 9 10 root@docker:~# docker run -d -p 3000:3000 --name multistage rondochen/go-demo-app:multistage 10bb8ca346957c196ab011021ee4e82fae6aca9ea6ab9445d501cc78e88632f0 root@docker:~# curl http://127.0.0.1:3000 Hello, Docker! root@docker:~# curl http://127.0.0.1:3000/ping {"Status":"OK"} root@docker:~# docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 10bb8ca34695 rondochen/go-demo-app:multistage "/docker-go-demo" 24 seconds ago Up 23 seconds 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp multistage root@docker:~#
这个镜像已经上传到dockerhub, 大家可以自便:
1 docker pull rondochen/go-demo-app:multistage
拾遗 本来弄出这个demo是为了在介绍CI/CD工具的时候用的, 但是在捣鼓这个demo的过程中也发现有很多值得讨论的话题, 就有了这篇文章. 诚然, 在现实生产环境中不会用到这么简单的demo, 但大致就是这样的一个打磨过程. 囿于篇幅, 很多话题也没能一一展开详细说明, 这里先留一个楔子, 后面有空再回头想想.
关于alpine和buster的选择 你或许会发现, 我在Dockerfile.multistage
中使用了golang:1.18-buster
镜像而不是golang:1.18-alpine
去编译, 一方面是因为golang:<version>-alpine
还处于验证阶段, 没得到Go项目的官方认可, 另一方面是因为编译出来的二进制文件没能成功地在gcr.io/distroless/base-debian10
运行, 所以最后选择了buster
镜像去编译.
关于golang的容器镜像部分后缀的说明, 以及alpine的一些说明, 在dockerhub的golang页面有大概描述过: https://hub.docker.com/_/golang
Dockerfile.tiny 笔者在选择buster之前, 还真用过alpine去编译代码, 再用alpine去运行, 最后构建出来的镜像只有13MB, 也可以正常运行.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 FROM golang:1.18 -alpine AS buildARG GOPROXY=https://goproxy.cn,directWORKDIR /app COPY go.mod . COPY go.sum . RUN go mod download COPY *.go ./ RUN go build -o /docker-go-demo FROM alpine:3.15 RUN adduser -D nonroot WORKDIR / COPY --from=build /docker-go-demo /docker-go-demo EXPOSE 3000 USER nonroot:nonrootCMD ["/docker-go-demo" ]
但基于上面提到的原因, 所以最后不打算使用这个基于alpine的镜像作为最终成果.
是的, 设计容器构建方案就是这样一个反复斟酌和权衡的过程.
在开发过程灵活使用容器 虽说现在在很多场景下都流行使用容器, 但是在开发过程中, 我们未必要总是不断重复这个docker build
和docker run
的过程. 相反, 在开发阶段, 容器更适合作为一个灵活的运行或者编译环境使用.
就比如说, 我想要快速看看这个demo在golang:1.18-buster
和golang:1.18-alpine
中编译出来有什么不一样, 我就可以这样简单测试:
1 2 3 4 5 6 7 8 cd docker-go-demo && go mod verify && go mod vendor cd .. docker run --rm -v $(pwd)/docker-go-demo:/docker-go-demo golang:1.18-alpine sh -c 'cd /docker-go-demo && go build -o app-build-from-alpine' docker run --rm -v $(pwd)/docker-go-demo:/docker-go-demo golang:1.18-buster sh -c 'cd /docker-go-demo && go build -o app-build-from-buster'
这样我就分别在两个不同的golang镜像中给代码编译了一次, 继而可以查看两个不同的镜像编译出来的程序有什么不一样.
我们可以在下面的输出看到, 用alpine编译出来的程序, 引用了libc.musl-x86_64.so.1
, 看起来不是很主流, 起码在我使用的Ubuntu 20.04.3 LTS
和镜像gcr.io/distroless/base-debian10
中缺失了.
1 2 3 4 5 6 7 8 9 10 cd docker-go-demo ldd app-build-from-alpine app-build-from-buster app-build-from-alpine: linux-vdso.so.1 (0x00007ffe5a1df000) libc.musl-x86_64.so.1 => not found app-build-from-buster: linux-vdso.so.1 (0x00007ffdc7572000) libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f12ac1ac000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f12abfba000) /lib64/ld-linux-x86-64.so.2 (0x00007f12ac1d8000)
而上面的两个直接挂载项目路径再用用docker run
运行镜像去编译代码的场景, 就非常适合用在日常的开发调试阶段.
是否要使用vendor功能 你或许又发现了, 我在项目路径里面运行了go mod vendor
命令, 这样, 在临时的golang的镜像中编译的时候, 就省去了反复go mod download
的时间了. 在我们设计CI/CD的过程, 这个也是其中一个考虑因素, 就是是否要把vendor中的内容也作为源代码的一部分.
得益于我们前面讨论过的使用了镜像layer可以复用相同的内容, 只要go.mod
和go.sum
文件没有产生变化, 每次在docker build
的时候即使有go mod download
操作, 也可以直接调用缓存, 不至于反复下载, 所以也不算是一个痛点.
但当然, 如果你每次docker build
的时候都把vendor文件夹里面的内容放到容器里面, 也可以.
笔者认为, 这只是一个偏好问题, 可以灵活处理.
注意权限和安全设置 留坑.
扩展阅读 Dockerfile新手可以重点了解一下一些常用命令的区别:
COPY
VS ADD
https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#add-or-copy
https://docs.docker.com/engine/reference/builder/#add
https://docs.docker.com/engine/reference/builder/#copy
ARG
VS ENV
https://docs.docker.com/engine/reference/builder/#arg
https://docs.docker.com/engine/reference/builder/#env
https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#env
CMD
VS ENTRYPOINT
https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#entrypoint
https://docs.docker.com/engine/reference/builder/#cmd
https://docs.docker.com/engine/reference/builder/#entrypoint
区分好docker daemon
和docker client
的proxy设置
https://docs.docker.com/network/proxy/
https://docs.docker.com/config/daemon/systemd/#httphttps-proxy