镜像大小其实是衡量我们容器打包技术的重要指标,我们应该在不影响应用正常运行的情况下,尽量让我们的容器镜像变得更小,这样,不管是从安全还是维护效率角度来讲,都是最佳实践。 本文我们从两种情况阐述我们的问题和解决方案,我们从实现我们的application的编程语言角度,按照语言是解释型还是编译型语言来演示如何解决容器镜像体积大的问题。

1 解释型语言

大部分的脚本语言都是解释型语言,像Ruby,Python,PHP等,我们只需要把我们的代码扔给解释器,解释器去运行就好了,但是这里的解释器有大有小,为什么? 首先Docker为一些“懒人”准备了一种Docker Image,比如Python,那么我们可以非常方便的从DockerHub上拉取某一个版本的Python镜像,比如Python3。6
  1. $ docker image ls
  2. python 3.6.5 a5b7afcfdcc8 2 weeks ago 912MB
  3. python 3.6.5-alpine 27e79c0fa4d2 2 months ago 87.4MB
我们会看到,同样是3.6.5, alpine linux的docker image就小了非常非常多,是普通的python3.6.5十分之一大小,为什么呢?主要是因为为了照顾“懒人”,Python:3.6.5 image预先安装了大量python的工具和编译的头文件等,包括C的编译环境等等。 而python:3.6.5-alpine 基本上除了Linux系统必须的一些文件以外,基本只包含基本的Python运行环境,例如像 gcc 等工具是不会预先安装的,都是用户需要的时候自行安装。 所以进一步来讲,我们要打包我们的Python应用,使用以上这两种base image的效果就显而易见了,一个打包完的image会上G大小,另一个只有100M左右。 例如下面是一个python flask的Dockerfile
  1. FROM python:3.6-alpine
  2. LABEL maintainer="XYZ <xxx@xxx.com>"
  3. RUN apk add --no-cache gcc musl-dev
  4. COPY . /app
  5. WORKDIR /app
  6. RUN pip install -r requirements.txt
  7. EXPOSE 8000
  8. CMD []

2 编译型语言

使用编译型语言(例如C,Go等)编写的应用程序打包成Docker镜像,这里面的优化空间就更大了,我们以Go语言为例。 假如我们有一个Go APP,假如使用普通的go image,那么我们构建出来的镜像会很大,例如这个app https://github.com/golang/example/blob/master/outyet/Dockerfile dockerfile FROM golang:onbuild EXPOSE 8080 构建完的docker image 700多M。
  1. $ docker images go-demo
  2. REPOSITORY TAG IMAGE ID CREATED SIZE
  3. go-demo latest f562d6efa39c 21 seconds ago 707MB
然后利用前面我们讲的,我们可以替换base image,选择一个alpine的base image,例如:
  1. FROM golang:alpine
  2. WORKDIR /app
  3. ADD . /app
  4. RUN cd /app && go build -o goapp
  5. EXPOSE 8080
  6. ENTRYPOINT ./goapp
构建的image,只有不到400M。
  1. $ docker images go-demo
  2. REPOSITORY TAG IMAGE ID CREATED SIZE
  3. go-demo latest f562d6efa39c 3 minutes ago 707MB
  4. $ docker images go-demo-alpine
  5. REPOSITORY TAG IMAGE ID CREATED SIZE
  6. go-demo-alpine latest a16d2986dbd1 9 seconds ago 385MB
但是,我们还可以让我们的image变得更小。根据我们之前讲的分阶段build,我们可以分阶段build我们的APP,然后最终只需要在一个很小的image里,运行我们程序编译后的结果即可,例如
  1. FROM golang:alpine AS build-env
  2. WORKDIR /app
  3. ADD . /app
  4. RUN cd /app && go build -o goapp
  5. FROM alpine
  6. RUN apk update && \
  7. apk add ca-certificates && \
  8. update-ca-certificates && \
  9. rm -rf /var/cache/apk/*
  10. WORKDIR /app
  11. COPY --from=build-env /app/goapp /app
  12. EXPOSE 8080
  13. ENTRYPOINT ./goapp
这样,又能省掉一部分空间。只有13M,惊不惊喜!!!
  1. $ docker images go-demo-muti-build
  2. REPOSITORY TAG IMAGE ID CREATED SIZE
  3. go-demo latest f562d6efa39c 3 minutes ago 707MB
  4. go-demo-alpine latest a16d2986dbd1 9 seconds ago 385MB
  5. go-demo-muti-build latest 1b1237a8fe0e 20 seconds ago 13.5MB

3 总结

所以,我们每次build自己的docker image的时候,一定要思考一下,怎么才能让我们的docker image变得更加小巧,更小的image其实也是更安全的,因为冗余的软件包少,那么漏洞就相应的少,另外小的docker image方便移动,不管是docker push还是pull,速度都很快。 快看一看自己手头上的docker image,有没有优化的空间吧!

4 Docker 镜像缩小神器

4.1 部署

  1. $ wget https://mirror.ghproxy.com/https://github.com/slimtoolkit/slim/releases/download/1.40.11/dist_linux.tar.gz
  2. $ tar -xvf dist_linux.tar.gz
  3. $ mv dist_linux/slim /usr/local/bin/
  4. $ mv dist_linux/slim-sensor /usr/local/bin/
  5. # 后续发布新版本之后直接使用命令进行更新
  6. $ slim update

4.2 使用

  1. # step1:查看镜像id
  2. $ docker images
  3. REPOSITORY TAG IMAGE ID CREATED SIZE
  4. nginx latest e0c9858e10ed 10 days ago 188MB
  5. # step2:缩小镜像
  6. $ slim build e0c9858e10ed
  7. cmd=build info=param.http.probe message='using default probe'
  8. cmd=build state=started
  9. cmd=build info=params target.image='e0c9858e10ed'continue.mode='probe' rt.as.user='true' keep.perms='true' tags='' image-build-engine='internal' target.type='image'
  10. cmd=build state=image.inspection.start
  11. cmd=build info=image id='sha256:e0c9858e10ed8be697dc2809db78c57357ffc82de88c69a3dee5d148354679ef' size.bytes='187669514' size.human='188 MB'
  12. cmd=build info=image.stack index='0' name='nginx:latest'id='sha256:e0c9858e10ed8be697dc2809db78c57357ffc82de88c69a3dee5d148354679ef'
  13. cmd=build info=image.exposed_ports list='80/tcp'
  14. cmd=build state=image.inspection.done
  15. cmd=build state=container.inspection.start
  16. cmd=build info=container status='created' name='slimk_1215_20240701055220'id='29fdbecafdc938cbbe505e071d7d6ce34a14a0bfeb6c5d463211b59fab4a8162'
  17. cmd=build info=container status='running' name='slimk_1215_20240701055220'id='29fdbecafdc938cbbe505e071d7d6ce34a14a0bfeb6c5d463211b59fab4a8162'
  18. cmd=build info=container message='obtained IP address' ip='172.17.0.2'
  19. time="2024-07-01T05:52:21Z" level=error msg="channel.Client.Read: read error (read tcp 127.0.0.1:57720->127.0.0.1:49154: read: connection reset by peer), exiting..."
  20. time="2024-07-01T05:52:21Z" level=error msg="channel.NewCommandClient: channel verify error = read tcp 127.0.0.1:57720->127.0.0.1:49154: read: connection reset by peer"
  21. cmd=build info=cmd.startmonitor status='sent'
  22. cmd=build info=event.startmonitor.done status='received'
  23. cmd=build info=container name='slimk_1215_20240701055220'id='29fdbecafdc938cbbe505e071d7d6ce34a14a0bfeb6c5d463211b59fab4a8162' target.port.list='49155' target.port.info='80/tcp => 0.0.0.0:49155' message='YOU CAN USE THESE PORTS TO INTERACT WITH THE CONTAINER'
  24. cmd=build state=http.probe.starting message="WAIT FOR HTTP PROBE TO FINISH"
  25. cmd=build info=continue.after mode='probe' message='no input required, execution will resume when HTTP probing is completed'
  26. cmd=build prompt='waiting for the HTTP probe to finish'
  27. cmd=build state=http.probe.running
  28. cmd=build info=http.probe.ports count='1' targets='49155'
  29. cmd=build info=http.probe.commands count='1' commands='GET /'
  30. cmd=build info=http.probe.call target='http://127.0.0.1:49155/' attempt='1' error='none' time='2024-07-01T05:52:35Z' status='200' method='GET'
  31. cmd=build info=http.probe.summary total='1' failures='0' successful='1'
  32. cmd=build state=http.probe.done
  33. cmd=build info=http.probe.crawler url='http://127.0.0.1:49155/' page='0'
  34. cmd=build info=probe.crawler.done addr='http://127.0.0.1:49155/'
  35. cmd=build info=event message='HTTP probe is done'
  36. cmd=build state=container.inspection.finishing
  37. cmd=build state=container.inspection.artifact.processing
  38. cmd=build state=container.inspection.done
  39. cmd=build state=building message="building optimized image" engine=internal
  40. cmd=build state=completed
  41. cmd=build info=results size.optimized='13 MB' status='MINIFIED' by='14.11X' size.original='188 MB'
  42. cmd=build info=results has.data='true' image-build-engine='internal' image.name='nginx.slim' image.size='13 MB' image.id='sha256:ed7eee6e6af9272dbfa18fb7e62b90352d97f4fba2be8def9b4bc8fd6da2bda7' image.digest='sha256:2e94ff7052fbc93de1e141ccf0b4712bf8f9065a9d9dbb2713a03c2fa3f6dff5'
  43. cmd=build info=results artifacts.location='/tmp/slim-state/.slim-state/images/e0c9858e10ed8be697dc2809db78c57357ffc82de88c69a3dee5d148354679ef/artifacts'
  44. cmd=build info=results artifacts.report='creport.json'
  45. cmd=build info=results artifacts.dockerfile.reversed='Dockerfile.reversed'
  46. cmd=build info=results artifacts.seccomp='nginx-seccomp.json'
  47. cmd=build info=results artifacts.apparmor='nginx-apparmor-profile'
  48. cmd=build state=done
  49. cmd=build info=commands message='use the xray command to learn more about the optimize image'
  50. cmd=build info=report file='slim.report.json'
  51. app='slim' message='GitHub Discussions' info='https://github.com/slimtoolkit/slim/discussions'
  52. app='slim' message='Join the CNCF Slack channel to ask questions or to share your feedback' info='https://cloud-native.slack.com/archives/C059QP1RH1S'
  53. app='slim' message='Join the Discord server to ask questions or to share your feedback' info='https://discord.gg/9tDyxYS'
  54. app='slim' message='Join the Gitter channel to ask questions or to share your feedback' info='https://gitter.im/docker-slim/community'
  55. # step3:查看镜像大小
  56. $ docker images
  57. REPOSITORY TAG IMAGE ID CREATED SIZE
  58. nginx.slim latest ed7eee6e6af9 6 seconds ago 13.3MB
  59. nginx latest e0c9858e10ed 10 days ago 188MB
  60. # step4:运行新镜像测试
  61. $ docker run -d -p 8080:80 nginx.slim
  62. 3e8226ddeb35563fa14fd5ec9badaf35a0503be367923724497fa5ffbb1a5d18
  63. $ curl -I http://127.0.0.1:8080
  64. HTTP/1.1200 OK
  65. Server: nginx/1.27.0
  66. Date:Mon,01Jul202405:57:32 GMT
  67. Content-Type: text/html
  68. Content-Length:615
  69. Last-Modified:Tue,28May202413:22:30 GMT
  70. Connection: keep-alive
  71. ETag:"6655da96-267"
  72. Accept-Ranges: bytes

4.3 总结

  • slim 不会更改 Docker 容器映像中的任何内容并将其缩小多达 30 倍。
  • slim 通过使用分析技术了解应用程序来优化您的容器。丢弃不需要的东西,减少容器的攻击面。

5 优化 Docker 镜像大小常见方法

平时我们构建的 Docker 镜像通常比较大,占用大量的磁盘空间,随着容器的大规模部署,同样也会浪费宝贵的带宽资源。本文将介绍几种常用的方法来优化 Docker 镜像大小,这里我们使用 Docker Hub 官方上的 Redis 镜像进行说明。

5.1 手动管理

我们能够直接想到的方法就是直接修改官方的 Redis 镜像 Dockerfile 文件,手动删除容器运行后不需要的组件,然后重新构建一个新镜像。这种方法理论上是可行的,但是容易出错,而且效果也不是特别明显。主要是不能和官方的镜像实时同步。

5.2 多阶段构建

Docker 在17.05 版本起提供了多阶段构建的功能来解决这个问题,这种方法是通过丢弃中间层来实现的,并通过中间层来提供有关如何创建最终镜像及其内容信息来完成的,只需要保留容器化应用所需的组件即可。在更上层的实现如下所示:
  • 以一些镜像作为构建的基础
  • 和平常一样运行命令来构造你的应用
  • 将所需的制品复制到另外一个单独的镜像

5.3 Distroless

在严重依赖容器化技术,尤其是 Docker 之后,谷歌早就意识到了使用臃肿镜像的弊端。所以他们提供了自己的方法来解决这个问题,即 distroless 镜像。与典型的Linux 基础镜像(绑定了很多软件)不同,在 distroless 上对你的应用进行 docker化,最终的镜像只包含应用及其运行时的依赖项,大多数 Linux 发行版中包含的标准软件,如包管理器,甚至 shell 都被会被排除在外。 同样的,要使用 Google 的 distroless 镜像,需要使用上面我们提到的多阶段构建,如下所示:
  1. FROM redis:latest AS build
  2. ARG TIME_ZONE
  3. RUN mkdir -p /opt/etc && \
  4. cp -a --parents /lib/x86_64-linux-gnu/libm.so.* /opt && \
  5. cp -a --parents /lib/x86_64-linux-gnu/libdl.so.* /opt && \
  6. cp -a --parents /lib/x86_64-linux-gnu/libpthread.so.* /opt && \
  7. cp -a --parents /lib/x86_64-linux-gnu/libc.so.* /opt && \
  8. cp -a --parents /usr/local/bin/redis-server /opt && \
  9. cp -a --parents /usr/local/bin/redis-sentinel /opt && \
  10. cp /usr/share/zoneinfo/${TIME_ZONE:-UTC} /opt/etc/localtime
  11. FROM gcr.io/distroless/base
  12. COPY --from=build /opt /
  13. VOLUME /data
  14. WORKDIR /data
  15. ENTRYPOINT ["redis-server"]
使用redis:latest为基础镜像,然后保留需要的一些二进制文件(redis-server二进制文件以及所有的相关依赖),然后使用 distroless 镜像作为构建的最终镜像的基础,将opt目录内容复制到该镜像目录中来。 然后我们只需要重新构建镜像即可:
  1. $ docker build -t redis:distroless .
  2. $ docker images
  3. REPOSITORY TAG IMAGE ID CREATED SIZE
  4. redis distroless 7d50bd873bea 15 seconds ago 28.2MB
  5. redis latest 1319b1eaa0b7 3 days ago 104MB
我们可以看到镜像由以前的 104MB 变成了 28.2MB,大大降低了镜像的大小。 注意:在 Linux 下面我们可以使用 ldd 工具来查找指定的二进制文件所需要的依赖,比如 $ ldd $(which redis-server) 使用 distroless 镜像来降低 Docker 镜像的大小是一个非常有效的方法,但是这样做也有一个明显的缺点就是最终的镜像中没有 shell 程序了,使得调试 Docker 容器就非常非常困难,当然这样也降低了应用被攻击的危险,使其更加安全,如果我们将应用部署到 Kubernetes 集群的话,我们可以利用 kubectl-debug 这样的工具来辅助调试应用。

5.4 Alpine Linux

另外一种比较常见的方式是选择在 Alpine Linux 基础上构建应用镜像,Alpine Linux 是一个特别适合创建最小化 Docker 镜像的发行版。Apline Linux 使用较小的 musl C 库代替 glibc,并将其静态链接,这意味着针对 musl 编译的程序将变成可重定位的 (relocatable)的二进制文件,从而无需包含共享对象,从而可以显著降低镜像的大小。 redis:alpine 镜像大概为 30MB 左右,这样做的缺点是,通常 musl 的性能不如 glibc。当然也有另外一个好处,那就是和上面的 distroless 相比,Alpine 是成熟的 Linux 发行版,提供基本的 shell 访问,使得调试 Docker 容器应用更为方便。在 Docker Hub 上面也可以找到几乎所有流行软件的 Alpine 版本,比如 Redis、Nginx、MySQL 等等。

5.5 GNU Guix

最后,我们可以使用 GNU Guix,一个多功能的软件包管理工具,其中就有一项可以创建 Docker 镜像的功能。Guix 区分了包的运行时依赖与构建依赖,所以 Guix 构建的 Docker 镜像将只包含明确指定的程序,加上他们的运行时依赖,就像 distroless 的方法一样。但和 distroless 不同的时候,distroless 需要你自己去查程序的运行时依赖关系(当然也要写 Dockerfile),而 Guix 只需要运行一条命令即可:$ guix pack -f docker redis 通过上面的命令创建的 Redis 镜像大小约为 70MB,和原本的镜像相比有明显的减少,虽然比 distroless 和 Alpine 方法创建的镜像稍大,但使用 Guinx 确实提供了一些其他的优点。比如,如果你想让你的最终镜像也包含一个 shell,以便像 Alpine 那样去调试,那么只需要在 Guxi 打包的时候指定上就可以了:$ guix pack -f docker redis bash ,如果你想包含其他软件,也可以继续在后面添加即可。 Guix 的功能特性意味着包的构建可以100%复用,所以我们可以在 CI/CD 流水线管道中加入 Guix 支持,这样构建过程就非常顺畅了。 有的人可能会觉得 Guix 听起来很酷,但是并不想为了构建更小的 Docker 镜像而去下载安装另外一个工具,更何况 Guix 只在 Linux 下面工作,很多开发者还是 MacOS 用户,去配置 Guix 也挺麻烦。其实这点并不用担心,Guix 本身也有 Docker 镜像在 Docker Hub 上,所以使用起来也并不会太复杂,只需要简单的使用 $ docker run guix 命令即可。 除了 Guix 之外,值得一提的还有一个名为 Nix 的软件包管理工具,对 Guix 所述的每一点都同样有效并且适用于 Nix。

5.6 原文连接