优化部署go的docker镜像大小

适度摸鱼,有益健康;过度摸鱼,不能毕业。

Posted by Les1ie on July 3, 2021

因为属实不想看论文不想做实验了(似乎我并没有认真搞多久呐)

决定摸鱼玩一玩 :)

方糖通知近期经常丢消息,延迟到达,他已经不是一个可靠的通知平台了,并且也因为腾讯限制了模板消息导致开发者准备下线业务。

除此之外,我还用了邮件通知作为辅助,不是很优雅,每次写代码的时候需要把一个写好的硬编码了 smtp 登录凭据的 python 脚本拿着到处跑,并且腾讯强制一个月修改一次密码,每次修改麻烦。此外从发送了通知到手机QQ 邮箱弹出消息可能延迟半分钟,保证尽量小的时间差这点,QQ 邮箱做得不够好。仔细想想似乎也不能把锅都给QQ,如果轮询时间间隔太小或者长连接可能消耗手机电量比较严重,半分钟已经是及时性和耗电二者的妥协了吧 :)

于是搞一个通知的小工具提上日程 :)

提出需求

希望可以实现多种方式推送的平台,类似于 server 酱,发一个 http 请求,手机收到通知。

通知的平台希望支持

  • 微信
  • 邮箱
  • 短信

那么这个通知平台需要实现一个 api,根据请求里面的 token判定用户,给用户发送通知消息。html页面可选。

寻找解决方案

首先,因为网络限制,FCM 推送是不可能的,ifttt 的 webhook,pushbullet, pushover 这些就暂不考虑了。

兜兜转转找了一圈,发现点过 star 的项目两个

  • https://github.com/gotify/server

  • https://github.com/nikoksr/notify

gotify 支持安卓、电脑的浏览器等终端,但是他需要一个常驻后台的安卓程序,而我的 ColorOS7 杀后台比较激进,除了微信有免死金牌,其他的基本都活不下来。

notify 支持 mail,一定程度上符合我的需求,但是时效性有不少问题。

手机短信通知大可不必,要钱钱的,于是乎用微信的通知成了唯一解。

那么就找一个支持企业微信的通知的轮子。

找了一圈有一个比较类似的

  • https://github.com/cloverzrg/wechat-work-message-push-go

但是存在一个问题,他还添加了 grafana,似乎是想用 grafana 的报警功能推送到微信

去除 grafana 基本就符合我用企业微信发送通知的功能了,但是除此之外我还需要短信推送、邮件推送的功能。

那么决定在他的基础上,根据我的需求写写。

写业务代码

思考了下用 python 还是 go 写这个东西。python的话就 flask/fastapi 一把梭,但是写起来少了不少乐趣,用 python 写更多的是为了实现这个需求,而不是开心的摸鱼 :)

非常巧,申请了开源之夏的项目过了,这个项目是要根据需求写 go 代码和改代码 bug,由于我写过的 go 代码不及 python 的百分之一,要顺利地完成开源之夏的项目我似乎还得再学习学习。

看完了 wechat-work-message-push-go 的执行逻辑,结合企业微信开发文档,发现企业微信发通知的逻辑比较简单,分为 2 步:

  1. 根据 corpID, corpSecret 请求微信的 api 获取 access_token,access_token 两个小时内有效,获取到之后保存起来,过期了及时更新即可
  2. 携带 access_token 请求消息通知的 api,请求体中放 AgentId ,接收人的 id和通知内容

用 python 实现这个基础的需求并且不考虑异常处理,我觉得用不到 30 行代码 :)

但是我还是决定用 go 写 :)

三下五除二写好了,目前只写了企业微信的通知,邮件通知就下次摸鱼的时候再写吧,剩下的就是部署了。

编译成二进制放到到服务器上,然后 caddy 套个反代加了层 https 就收工了。

docker 部署

丢一个二进制到服务器就完事儿了,似乎感觉少了点什么 :)

还没有摸鱼完呢 就搞完了

那么就再套娃一个 docker 吧

能跑就行的 dockerfile v1.0

首先写一个 dockerfile,因为 1.13 后默认开启了 go mod,因此 go build 的时候会自动下载 go mod的依赖,这里我们只需要设定 goproxy 环境变量就行。此外我之前一直用的七牛的 goproxy.cn,但是最近在实验室连接他会有网络问题,于是换成了 goproxy.io,速度伯仲之间吧。 这里专门为设定环境变量加了一层RUN大可不必,可以直接加 environment或者和 go build放到一起,不过和这巨大的尺寸比起来,这点优化 duck 不必

FROM golang:1.16 AS builder

WORKDIR /app
COPY . .
RUN go env -w GOPROXY=https://goproxy.io,direct
RUN go build -o app .
CMD ["./app"]

好的,构建完了,972MB,中规中矩,但这对于一个简单的 web 应用的镜像来说,可以用巨大来形容了

docker images wxworkmsgbot_bot
REPOSITORY         TAG       IMAGE ID       CREATED         SIZE
wxworkmsgbot_bot   latest    5c7364d4e983   2 minutes ago   972MB

多步构建,减小尺寸

既然用 go 写的,运行时的依赖不是问题,可以多步构建把 artifact 复制到第二部分的镜像里面。 于是,花了一分钟时间,把 docker官方文档示例 里面的多步构建的示例复制过来。

FROM golang:1.16
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html  
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM alpine:latest  
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=0 /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]  

示例里面加了不少参数,我刚复制的时候还不知道为什么他要做这些操作,于是减法减法减法,只留下我想要的部分,得到了 v2.0 版本的 dockerfile

FROM golang:1.16 AS builder

WORKDIR /app
COPY . .
RUN go env -w GOPROXY=https://goproxy.io,direct
RUN go build -o app .
CMD ["./app"]


FROM alpine:latest

WORKDIR /app
COPY --from=builder /app/app .
CMD ["./app"]

好的,非常完美, docker-compose up --build 一气呵成

一看大小只有 16.1MB,比刚刚好多了,缩小到了原来的 16.1/972=1.66% 心情愉悦 ^_^

docker images wxworkmsgbot_bot
REPOSITORY         TAG       IMAGE ID       CREATED         SIZE
wxworkmsgbot_bot   latest    5e8c0cb18517   6 seconds ago   16.1MB

再去看看启动情况,留下了一行日志就退出了

Recreating wxworkmsgbot_bot_1 ... done
Attaching to wxworkmsgbot_bot_1
bot_1  | standard_init_linux.go:219: exec user process caused: no such file or directory

问题不大,复制这行报错,面向 stackoverflow 编程

很快发现这是因为默认启用了 CGO 导致的,把他禁用就行了

此时此刻,恰如彼时彼刻,想起来, docker 文档里面就是做了这个操作的

FROM golang:1.16 AS builder

WORKDIR /app
COPY . .
RUN go env -w GOPROXY=https://goproxy.io,direct
RUN CGO_ENABLED=0 go build -o app .
CMD ["./app"]


FROM alpine:latest

WORKDIR /app
COPY --from=builder /app/app .
CMD ["./app"]

加上这一行,没有问题

$ docker images wxworkmsgbot_bot
REPOSITORY         TAG       IMAGE ID       CREATED        SIZE
wxworkmsgbot_bot   latest    1dfff8d58731   18 hours ago   16.1MB

那么 docker文档剩下的参数是干什么的,尤其是 installsuffix,以前没看到过。

$ go tool link
...
  -installsuffix suffix
    	set package directory suffix

可以看到意思是安装的路径前缀,不过还是不太懂,继续搜了下,发现 go 开发者 ianlancetaylor 在一个 issue 里面说在新版本的 go 里面不需要了,那么我就暂且不管他。

正常运行了,于是请求一下 api

Get "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ffffff&corpsecret=xfdasfasf": x509: certificate signed by unknown authority

看起来是缺了证书

此时此刻,恰如彼时彼刻

docker 的文档里面给 alpine 装了个证书,原来是搞这个用的

那么再加进去

FROM golang:1.16 AS builder

WORKDIR /app
COPY . .
RUN go env -w GOPROXY=https://goproxy.io,direct
RUN CGO_ENABLED=0 go build -o app .
CMD ["./app"]


FROM alpine:latest
RUN apk --no-cache add ca-certificates

WORKDIR /app
COPY --from=builder /app/app .
CMD ["./app"]

好的,这下真的一整个流程都 work 了

镜像大小为 16.4MB

docker images wxworkmsgbot_bot
REPOSITORY         TAG       IMAGE ID       CREATED          SIZE
wxworkmsgbot_bot   latest    59af2bbb0b1c   34 seconds ago   16.4MB

继续优化大小

真正的缩减大小,从现在开始,思路有两个,第一,减小二进制的大小;第二,使用 scratch 而不是 alpine。

首先减小二进制的大小。

很久以前摸鱼的时候,发现go build的结果有不少优化的空间,比如 -s去除符号表 -w 去除调试信息、-trimpath 去除路径信息(反溯源的目的)等,于是乎再修改一下 dockerfile

FROM golang:1.16 AS builder

WORKDIR /app
COPY . .
RUN go env -w GOPROXY=https://goproxy.io,direct
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" --trimpath -o app .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/app .
CMD ["./app"]

经过测试,正常运行,不过缩减大小并不明显,事后想想似乎是 alpine占据太大的空间,二进制文件可能已经减小了 1/3了。

docker images wxworkmsgbot_bot
REPOSITORY         TAG       IMAGE ID       CREATED          SIZE
wxworkmsgbot_bot   latest    ad00fe8a6c01   24 seconds ago   13.5MB

下一步就是并不常见的压缩二进制的步骤:upx加壳,虽然他更多的是用来混淆对抗杀软的,但是他的压缩性能是真的可以,让我想在这里试试

放一个 upx 进去,再加一个最大压缩比例的参数,于是有了新的 dockerfile

FROM golang:1.16 AS builder
COPY upx /app/upx

WORKDIR /app
COPY . .
RUN go env -w GOPROXY=https://goproxy.io,direct
RUN CGO_ENABLED=0 go build -ldflags="-w -s" --trimpath -o app .
RUN /app/upx --best app


FROM alpine
#FROM scratch
RUN apk --no-cache add ca-certificates

WORKDIR /app
COPY --from=builder /app/app .
CMD ["./app"]

其中构建过程中压缩的输出如下

 ---> Running in d9e31badd0b6
                       Ultimate Packer for eXecutables
                          Copyright (C) 1996 - 2020
UPX 3.96        Markus Oberhumer, Laszlo Molnar & John Reiser   Jan 23rd 2020

        File size         Ratio      Format      Name
   --------------------   ------   -----------   -----------
   7462912 ->   2428916   32.55%   linux/amd64   app

Packed 1 file.
Removing intermediate container d9e31badd0b6

经过测试,工作正常,现在的容器只有8.51MB了,其中,二进制文件压缩前有 7462912/1024/1024=7.1MB,压缩后只有 2.3 MB,剩下的空间是 alpine 占据的

docker images wxworkmsgbot_bot
REPOSITORY         TAG       IMAGE ID       CREATED          SIZE
wxworkmsgbot_bot   latest    72ec286b8d99   35 seconds ago   8.51MB

那么是时候干掉 alpine 了

使用 scratch

alpine 相比于 go 的二进制文件,还是过大了,那么可以试试把他去掉,用 scratch ,整个容器有且仅有这一个二进制文件,岂不是很酷 :)

只在多年以前刚开始看 docker 教程的时候用过静态编译的丢进去运行,从此再也没玩过了 :(

FROM golang:1.16 AS builder
COPY upx /app/upx

WORKDIR /app
COPY . .
RUN go env -w GOPROXY=https://goproxy.io,direct
RUN CGO_ENABLED=0 go build -ldflags="-w -s" --trimpath -o app .
RUN /app/upx --best app

FROM scratch
#FROM alpine
#RUN apk --no-cache add ca-certificates

WORKDIR /app
COPY --from=builder /app/app .
CMD ["./app"]

构建后大小为

docker images wxworkmsgbot_bot
REPOSITORY         TAG       IMAGE ID       CREATED         SIZE
wxworkmsgbot_bot   latest    aa2d5689d063   2 minutes ago   2.43MB

很明显,这里也会有缺证书的问题,搜索一圈可以发现有个全干工程师 在一年前发的一篇文章,从前到后和我的思路一样,连 upx 的参数都一样 太离谱了:)

那么就参考他的,直接拷贝证书过去

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

于是乎有了最终能用的版本的 dockerfile

FROM golang:1.16 AS builder
COPY upx /app/upx

WORKDIR /app
COPY . .
RUN go env -w GOPROXY=https://goproxy.io,direct
RUN CGO_ENABLED=0 go build -ldflags="-w -s" --trimpath -o app .
RUN /app/upx --best app

FROM scratch

WORKDIR /app
COPY --from=builder /app/app .
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["./app"]

最终容器的大小

docker images wxworkmsgbot_bot
REPOSITORY         TAG       IMAGE ID       CREATED         SIZE
wxworkmsgbot_bot   latest    706e3549f0ad   2 minutes ago   2.64MB

到此告一段落,一个有且仅有一个二进制,还有证书的容器搞完了。

到头来想想,似乎没必要这样追求极致。

毕竟这距离极致还有很远。

还可以把二进制里面再继续分析,继续减小。

就像看到了终极笔记的终极一样,终极真的就是想要追求的终极么?

alpine 因为用的 musl 而不是 glibc而导致的问题,过于精简导致的依赖缺失的问题,让我感觉还是 debian 比较靠谱 :)

所以我还是回归我最爱的 debian,把二进制放进去,完事儿 :)

FROM golang:1.16 AS builder

WORKDIR /app
COPY . .
RUN go env -w GOPROXY=https://goproxy.io,direct
RUN CGO_ENABLED=0 go build -ldflags="-w -s" --trimpath -o app .


FROM debian

WORKDIR /app
COPY --from=builder /app/app .
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["./app"]

主流的镜像都是基于 debian 的,因此我这里再开一次不会多占用空间的 :)

docker images wxworkmsgbot_bot
REPOSITORY         TAG       IMAGE ID       CREATED              SIZE
wxworkmsgbot_bot   latest    d83396e4d977   About a minute ago   122MB

所以,这就是真正的最后的镜像了 :)

refer

  • https://docs.docker.com/develop/develop-images/multistage-build/

  • https://colobu.com/2018/08/13/create-minimal-docker-image-for-go-applications/
  • https://juejin.cn/post/6844904174396637197
  • https://github.com/golang/go/issues/9344#issuecomment-69944514

适度摸鱼,有益健康

过度摸鱼,不能毕业 :)

看文档写代码花了半天时间

摸鱼写这篇博客,半天时间又没了

笑看下周一组会我如何交代我给导师说的我这周准备复现的论文

我现在还在看这篇论文,还没写代码 :)

事情越多越不想搞 :(

6月30号的时候,王爷说我们现在三年级了 :(

那么一瞬间

我突然就慌了55555

不慌

晚上再来一把 csgo

看看我白银一的真正实力

Les1ie

2021.7.3 15:19