TL;DR
怎样才能给一个GO语言开发的项目打包成一个容器镜像, 并且做到又快又小(嗯?).
-
妥善安排Dockerfile里面的layer
-
尽量选择体积小的基础镜像
-
容器内容尽量精简
我把这个demo里面涉及到的代码, 依赖, 以及好几个Dockerfile都放到了github上, 大家可以自便:
顺便剧透一下, 最后构建出来的镜像, 在dockerhub: hub.docker.com/repository/…
# 镜像下载以及试运行
docker pull rondochen/go-demo-app:multistage
docker run -d -p 3000:3000 rondochen/go-demo-app:multistage
复制代码
代码
package main
import (
"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的运行环境: go.dev/doc/tutoria…
笔者使用的是Go的1.18版本.
# go version
go version go1.18.4 linux/amd64
复制代码
安装好go的运行环境之后, 我们可以直接试运行一下demo代码:
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服务, 如果一切顺利(小心端口冲突), 运行起来之后是这样子的:
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
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的运行环境了: docs.docker.com/get-started…
另外, 基于我们特殊的网络环境, 我有两个小建议:
-
找一个好使的网络代理
-
设置好国内的docker源
以上两点二选一, 但我个人倾向第一点, 大家可以自行斟酌.
Dockerfile.beginner
基于能跑就行的原则, 我们只需要找一个golang的镜像, 再把代码以及项目文件放进去就可以运行了.
Dockerfile.beginner
# syntax=docker/dockerfile:1
FROM golang:1.18
ARG GOPROXY=https://goproxy.cn,direct
WORKDIR /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" ]
复制代码
制作镜像
docker build
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
复制代码
使用容器运行
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
busterbullseye
golang:1.18-alpine
layer的概念
docker buildRUNCOPYADD
docker build
go.modgo.sumgo mod download*.gogo build
在我们构建一个容器镜像的时候, 一条长期被强调的原则, 就是要尽可能地复用已有的layer以及最大限度地减少layer的数量.
Dockerfile.better
Dockerfile.better
# syntax=docker/dockerfile:1
FROM golang:1.18-alpine
ARG GOPROXY=https://goproxy.cn,direct
WORKDIR /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" ]
复制代码
COPY *.GO .RUN go mod download
最后构建完成之后, 镜像的体积减少到了422MB.
# 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 buildUsing cache
Multi-stage builds
镜像体积减少到422MB, 还是太大了, 就引出了灵魂拷问:
程序都编译完了, 为什么还需要在容器里面保留一个编译环境?
那既然这样, 我能不能直接把编译好的程序放到一个小容器里运行就完了?
Multi-stage builds
Dockerfile.multistage
你或许会以为我们需要两个Dockerfile, 一个完成代码的编译并把文件复制到容器外, 再创建一个镜像把编译好的文件重新传回去镜像里面, 但这两个步骤可以合并在一个Dockerfile中完成.
# syntax=docker/dockerfile:1
# Build
FROM golang:1.18-buster AS build
ARG GOPROXY=https://goproxy.cn,direct
WORKDIR /app
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY *.go ./
RUN go build -o /docker-go-demo
# Deploy
FROM gcr.io/distroless/base-debian10
WORKDIR /
COPY --from=build /docker-go-demo /docker-go-demo
EXPOSE 3000
USER nonroot:nonroot
CMD ["/docker-go-demo"]
复制代码
docker build
# 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的镜像:
# 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的完全一样.
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, 大家可以自便:
docker pull rondochen/go-demo-app:multistage
复制代码
拾遗
本来弄出这个demo是为了在介绍CI/CD工具的时候用的, 但是在捣鼓这个demo的过程中也发现有很多值得讨论的话题, 就有了这篇文章. 诚然, 在现实生产环境中不会用到这么简单的demo, 但大致就是这样的一个打磨过程. 囿于篇幅, 很多话题也没能一一展开详细说明, 这里先留一个楔子, 后面有空再回头想想.
关于alpine和buster的选择
Dockerfile.multistagegolang:1.18-bustergolang:1.18-alpinegolang:-alpinegcr.io/distroless/base-debian10buster
关于golang的容器镜像部分后缀的说明, 以及alpine的一些说明, 在dockerhub的golang页面有大概描述过: hub.docker.com/_/golang
Dockerfile.tiny
笔者在选择buster之前, 还真用过alpine去编译代码, 再用alpine去运行, 最后构建出来的镜像只有13MB, 也可以正常运行.
# syntax=docker/dockerfile:1
# Build
FROM golang:1.18-alpine AS build
ARG GOPROXY=https://goproxy.cn,direct
WORKDIR /app
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY *.go ./
RUN go build -o /docker-go-demo
## Deploy
FROM alpine:3.15
RUN adduser -D nonroot
WORKDIR /
COPY --from=build /docker-go-demo /docker-go-demo
EXPOSE 3000
USER nonroot:nonroot
CMD ["/docker-go-demo"]
复制代码
但基于上面提到的原因, 所以最后不打算使用这个基于alpine的镜像作为最终成果.
是的, 设计容器构建方案就是这样一个反复斟酌和权衡的过程.
在开发过程灵活使用容器
docker builddocker run
golang:1.18-bustergolang:1.18-alpine
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镜像中给代码编译了一次, 继而可以查看两个不同的镜像编译出来的程序有什么不一样.
libc.musl-x86_64.so.1Ubuntu 20.04.3 LTSgcr.io/distroless/base-debian10
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 vendorgo mod download
go.modgo.sumdocker buildgo mod download
docker build
笔者认为, 这只是一个偏好问题, 可以灵活处理.
注意权限和安全设置
留坑.
扩展阅读Dockerfile新手可以重点了解一下一些常用命令的区别: