前言

TL;DR

怎样才能给一个GO语言开发的项目打包成一个容器镜像, 并且做到又快又小(嗯?).

  1. 妥善安排Dockerfile里面的layer

  2. 尽量选择体积小的基础镜像

  3. 容器内容尽量精简

Golang-with-docker.png

demo介绍

我把这个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…

另外, 基于我们特殊的网络环境, 我有两个小建议:

  1. 找一个好使的网络代理

  2. 设置好国内的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"}
复制代码

好了, 现在已经能跑了.

但如果在生产环境里面用这种镜像, 可能你的老板都想让你跑.

后面我们来改进一下.

改进过程

上面的镜像, 很明显存在几个问题:

  1. 镜像体积过于巨大, 1GB有余.

  2. layer的安排不合理

  3. 太臃肿

初始镜像的选择

在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新手可以重点了解一下一些常用命令的区别: