一次 Docker 镜像瘦身实记

2019.08.20

笔者在此之前完全没有在意过 Docker 镜像的大小…

(或许 副标题可以取成 《如何将你的 Docker 镜像大小缩减 99%!!XD》:joy: 太羞耻了233 )

缘起

在书写 Dockerfile 构建镜像并放到 Kubernetes 中运行之后, 发现 Worker Node 的磁盘容量被消耗的很快, 发现是 Pod 内的 Docker Container 过大导致. 一个仅仅 用于运行 micro/micro 的 Micro Api 的容器占地面积 1G+ …., 让人有些汗颜, 遂开始尝试 对容器的 Image 进行瘦身.

第一个尝试 (删除剩余缓存)

如下面这个不做任何清理的 DockerFile 所示, 让我们看看构建后, 构建出来的 Image 占多少空间

FROM alpine:latest

USER root

RUN apk update && apk add go git musl-dev

# 添加 go 环境变量
RUN echo "export PATH=\$PATH:/root/go/bin" >> /etc/profile \
    && echo "export GO111MODULE=on" >> /etc/profile \
    && echo "export GOPATH=/root/go" >> /etc/profile  \
    && echo "export GOPROXY=https://mirrors.aliyun.com/goproxy/" >> /etc/profile \
    && source /etc/profile

# 安装 micro
RUN cd \
    && go get -u -v github.com/micro/micro \
    && go install github.com/micro/micro

EXPOSE 8080

CMD ["micro","api"]

构建 Docker Image

$ docker build . --tag=micro:v1.01
# 查看镜像大小
$ docker image ls | grep micro
micro    v1.01    2b1330a05f90   27 seconds ago   948MB

ok, 构建出来的镜像占地面积 948 MB, 接近 1G 的体积. 但是 go 编译的二进制文件顶多才几十 M 才对鸭, 一定是有一些不必要的占用, 那么接下来我们进入容器看看主要的空间占用集中在哪些位置

$ docker run -it --rm micro:v1.01 /bin/sh
# 因为 busybox 的 du 命令版本较低, 这里使用 ncdu
/  apk add ncdu
/  ncdu

然后我们可以观察到 大小主要集中在

  • /root/go/src 471+MB // Go 编译时下载的依赖文件
  • /root/go/bin 36+MB // micro 编译出来二进制文件, 我们所需要的
  • /usr/lib 320+MB // 我们 apk add 时得到的 lib 文件
  • /usr/libexec 66+MB

简单分析一下, 主要是我们上面 apk 命令安装的软件和 lib , 以及 go 编译 micro 所需的依赖.

实际上我们编译得到 Micro 二进制文件完全不需要这些就能运行.

所以我们改造一下上面的 Dockerfile , 把 apk 安装的软件以及 Go 的依赖全部删掉, 以缩小镜像体积

FROM alpine:latest

USER root

# 添加 go 环境变量
RUN echo "export PATH=\$PATH:/root/go/bin" >> /etc/profile \
    && echo "export GO111MODULE=on" >> /etc/profile \
    && echo "export GOPATH=/root/go" >> /etc/profile \
    && echo "export GOPROXY=https://mirrors.aliyun.com/goproxy/" >> /etc/profile \
    && source /etc/profile

# 安装 micro
RUN apk update && apk add go git musl-dev \
    && cd \
    && go get -u -v github.com/micro/micro \
    && go install github.com/micro/micro \
    && apk del go git musl-dev \
    ; rm /root/go/pkg -rf \
    ; rm /root/go/src -rf \
    ; rm /root/.cache -rf \
    ; rm -rf /var/cache/apk/* /tmp/*

EXPOSE 8080

CMD ["micro", "api"]

我们构建看看效果.

$ docker build . --tag=micro:v1.02
# 查看镜像大小
$ docker image ls | grep micro
micro    v1.02    03e65f4ba3ed    8 seconds ago    44.1MB

这次的优化效果显著 , 构建后的镜像大小 44.1MB, 仅为原来的 4.6% ! 第一阶段工作完成!

PS 这里有个小插曲, 第一次优化的时候犯傻 X…. 我把 dockerfile 写成了这样…

FROM alpine:latest

USER root

## 安装 软件环境
RUN apk update && apk add go git musl-dev

# 添加 go 环境变量 并 安装 micro
RUN echo "export PATH=\$PATH:/root/go/bin" >> /etc/profile \
    && echo "export GO111MODULE=on" >> /etc/profile \
    && echo "export GOPATH=/root/go" >> /etc/profile \
    && echo "export GOPROXY=https://mirrors.aliyun.com/goproxy/" >> /etc/profile \
    && source /etc/profile \
    && go get -u -v github.com/micro/micro \
    && go install github.com/micro/micro \

# 删除不必要的东西
RUN cd \
    &&
    && apk del go git musl-dev \
    ; rm /root/go/pkg -rf \
    ; rm /root/go/src -rf \
    ; rm /root/.cache -rf \
    ; rm -rf /var/cache/apk/* /tmp/*

EXPOSE 8080

CMD ["micro", "api"]

咋眼一看可能感觉没啥问题, 逻辑清晰明了, 但是构建之后, 镜像体积完全没有缩小, 那么问题出现在呢?

问题就出现在这清晰明了上 :joy:, 由如下两个原因组合而成:

  1. Docker 使用 aufs, 也就是联合文件系统
  2. Dockerfile 中, 一个 命令 即为一层

那么也就是说, 在这个 Dockerfile 中, 我们妄图在下面的层删除上面的层的东西, 这样当然是删不到的: joy:

第二个尝试 (采用多阶段构建)

在上一阶段中, 我们将镜像体积控制到 44+MB , 效果不错. 但是有个很麻烦且不优雅的地方. 就是我们每次写 Dockerfile 难道都要这样手动删除安装的东西吗?!! .

答案是 否定 的.

Docker 17.05 版本之后, Docker 支持了 多阶段构建 (multistage builds), 容器仅仅保存最后一个阶段构建的内容, 我们在前面的阶段可以随便写, 安装十个软件写十一个 Run apk add foo 都行 :joy:(开玩笑的). 于是我们就把 Dockerfile 改造成了这样.

FROM alpine:latest

USER root

# 添加 go 环境变量 和 alpine 镜像
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories \
    && echo "export GO111MODULE=on" >> /etc/profile \
    && echo "export GOPATH=/root/go" >> /etc/profile \
    && echo "export GOPROXY=https://mirrors.aliyun.com/goproxy/" >> /etc/profile \
    && source /etc/profile

# 安装 micro
RUN apk update && apk add go git musl-dev xz binutils \
    && cd \
    && go get -u -v github.com/micro/micro \
    && go install github.com/micro/micro


#1 ----------------------------
FROM alpine:latest

COPY --from=0 /root/go/bin/micro /usr/local/bin/

EXPOSE 8080

CMD ["micro", "api"]

让我们构建镜像验证一下

$ docker build . --tag=micro:v1.03
# 查看镜像大小
$ docker image ls | grep micro
micro    v1.03    8529f0d7aaca    7 seconds ago    43.7MB

可以看到看到, 容器体积和前面差不多, 甚至更小了.

这样能基本达到我们的预期, 但是还有没有办法变得更小呢, 当然是有的!!!!

以下方法针对 Go 这种编译型语言, PHP/Python 这种动态语言可能需要用其他思路.

第三个尝试 (缩小二进制文件体积)

经过 Google 的检索, 我们知道了 stripupx 两种工具.

这里简单介绍一下 strip 和 upx

strip 通过删除可执行文件中 ELF 头的 typchk 段、符号表、字符串表、行号信息、调试段、注解段、重定位信息等来实现缩减程序体积的目的。简而言之, 你可以直译理解成给程序脱衣服…

UPX 本来是一个 BIN 文件加壳器, 但是他有一种 叫做 UCL 的压缩算法, 可以进一步减小体积, 运行时先在内存中解压, 对性能影响非常小 (副作用是 会增加程序的启动时间, 建议谨慎使用)

我们接下来把 strip + upx 应用到我们的 Dockerfile 中.

FROM alpine:latest

USER root

# 添加 go 环境变量 和 alpine 镜像
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories \
    && echo "export GO111MODULE=on" >> /etc/profile \
    && echo "export GOPATH=/root/go" >> /etc/profile \
    && echo "export GOPROXY=https://mirrors.aliyun.com/goproxy/" >> /etc/profile \
    && source /etc/profile

# 安装 micro
RUN apk update && apk add go git musl-dev xz binutils \
    && cd \
    && go get -u -v github.com/micro/micro \
    && go install github.com/micro/micro

# 压缩 和 加壳
RUN wget https://github.com/upx/upx/releases/download/v3.95/upx-3.95-amd64_linux.tar.xz \
    && xz -d upx-3.95-amd64_linux.tar.xz \
    && tar -xvf upx-3.95-amd64_linux.tar \
    && cd upx-3.95-amd64_linux \
    && chmod a+x ./upx \
    && mv ./upx /usr/local/bin/ \
    && cd /root/go/bin \
    && strip --strip-unneeded micro \
    && upx micro \
    && chmod a+x ./micro \
    && cp micro /usr/local/bin

#1 ----------------------------
FROM alpine:latest

COPY --from=0 /usr/local/bin/micro /usr/local/bin/

EXPOSE 8080

CMD ["micro", "api"]

让我们构建镜像看看效果

$ docker build . --tag=micro:v1.04
# 查看镜像大小
$ docker image ls | grep micro
micro    v1.04    1d22ac38352c    5 seconds ago    14.2MB

镜像的体积进一步下降到 14.2 MB, 让我们进入 容器看看 编译出来的 micro 二进制文件大小,

$ docker run -it --rm micro:v1.04 /bin/sh
/ ls -hal /usr/local/bin/micro
-rwxr-xr-x    1    root    root    8.2M    Aug 12 07:59    micro

缩小到了 8.2M, 那么让我们算一下, 还有那些多余的体积.

使用 docker image 命令看看我们的基础镜像 Alpine 的大小

$ docker image ls | grep alpine
alpine    latest    b7b28af77ffe    5 weeks ago    5.58MB

8.2 + 5.58 = 13.78 MB , 也就是 还有 14.2 - 13.78 = 0.42 MB 的多余占用, 我们已经基本接近极致. 优化工作可以到此基本告一段落.

后记 - 未了的事

到目前为止, 我们将容器镜像体积从一开始的 948MB 降到 14+MB, 减少了将近 98.5% :joy: 的镜像体积, 可喜可贺!

但是我们也看到还有 0.42MB 的多余占用等待着我们去优化, 可能是 计算错误 或者是对 Docker 的 多阶段构建以及 aufs 的理解不够到位, 导致认为有多余的占用空间, 这便是未了的事.

(我也想像 Docker 镜像一样,瘦的这么快鸭:joy:)

Last modified 2019.08.20