深入分析Docker hello-world镜像

学习Docker时一般刚开始接触的第一个docker image就是hello-world,这个image运行起来的效果也很简单直接,仅仅是在屏幕上输出一段Docker的使用说明就结束了。这个镜像虽然简单,然而仔细分析下还是涉及不少底层机制的。

我之所以会对这个镜像感兴趣,是发现它的大小仅仅只有1.84kB,这实在是太小了,写一个printf("Hello Wolrd\n");的程序编译出来大小就远超1.84kB了,所以很好奇这个镜像是如何构建出来的。

Dockerfile

Docker的镜像构建过程是由其镜像描述文件Dockerfile决定的,所以就先找到其Dockerfile来看看。hello-world用于AMD64架构的Dockerfile可以在Github上找到,只有简单的3行:

1
2
3
FROM scratch
COPY hello /
CMD ["/hello"]

第1行导入了一个名为scratch的东西,这并不是一个真正的image,可以把它视为是所有image的最底层虚拟镜像,类似于一个基本抽象类,Docker官方对其的说明如下

This image is most useful in the context of building base images (such as debian and busybox) or super minimal images (that contain only a single binary and whatever it requires, such as hello-world).

As of Docker 1.5.0 (specifically, docker/docker#8827), FROM scratch is a no-op in the Dockerfile, and will not create an extra layer in your image (so a previously 2-layer image will be a 1-layer image instead).

……

You can use Docker’s reserved, minimal image, scratch, as a starting point for building containers. Using the scratch “image” signals to the build process that you want the next command in the Dockerfile to be the first filesystem layer in your image.

后面两行的含义也很直接,把一个名为hello的程序copy到根目录下,在运行image的时候运行此程序。下面就来看下这个如此小的hello world程序是如何实现的。

主程序

hello.c文件的源码也在同一个Github仓库中,省略掉过长的字符串常量后很简单:

1
2
3
4
5
6
7
8
9
10
#include <sys/syscall.h>

const char message[] =
"Hello World!"
"\n";

void _start() {
syscall(SYS_write, 1, message, sizeof(message) - 1);
syscall(SYS_exit, 0);
}

这个最简版本的Hello World和C语言教科书中第一个Hello World是有不小差别的。首先是程序入口点上,众所周知正常C/C++程序的入口点是main(),然而这里使用的是_start()

我们的程序是运行在Linux系统上的,程序的加载与运行必然是由OS发起的,对于Linux来说,OS层面的程序入口点就是_start()而不是main() 函数,一个程序要能正常运行在main()之前是有一些准备工作要做的,比如建立程序运行环境(初始化.bss全局变量等);在main()返回之后也有些收尾工作要处理,比如调用exit()通知系统等。这些工作正常情况下是由语言标准库来完成的,也就是所谓的Runtime运行环境,对于C语言来说就是crt0.o。大部分程序的_start()就位于其中,在建立好运行环境后_start()会调用main()跳转到用户定义的入口点处。当main()返回后程序又将回到ctr0.o中,最终调用exit()通知OS回收进程资源。

这里为了缩小程序体积和简单起见,没有使用标准的ctr0.o Runtime,事实上这一个简单的程序也不需要什么Runtime。程序最后直接通过syscall函数调用了SYS_exit系统调用结束了自身的运行。

将字符串输出到屏幕上也没有使用标准库中的printf(),同样是直接调用了SYS_write这个系统调用,其第一个参数显式的写为了1,其实就是STDOUT_FILENO,Linux系统在unistd.h中定义了stdin, stdout, stderr这几个标准文件描述符。

可以看到,这样一个程序是可以不依赖于任何其他的库在Linux上独立运行的,为了实现不链接C标准库的目的,需要使用一些特殊的编译选项。从编译这个hello-world程序使用的Makefile中可以找到使用的编译选项为:

1
CFLAGS := -static -Os -nostartfiles -fno-asynchronous-unwind-tables
  • -static表示静态链接,虽然对这个程序来说无所谓动态链接还是静态链接……
  • -Os表示为空间进行-O2级别的优化,专门用于减少目标文件大小;
  • -nostartfiles是关键编译选项,此选项表示不使用标准C语言运行库(即crt0.o),也不链接C标准库;
  • -fno-asynchronous-unwind-tables选项也是用于减少代码空间的,其大概含义是不产生C++异常处理机制中使用的.eh_frame段,关于什么是unwind-tables.eh_frame是个比这篇文章复杂多了的问题,文末有几篇参考资料,之后有空可以深入学习下C++的底层机制……

进行了以上诸多特殊优化处理后,终于可以得到一个只有1k多的可以正常运行于Linux上的Hello World程序了。


参考资料:

What is the use of _start() in C?

When is the gcc flag -nostartfiles used?

GCC x86 code size optimizations

c++ 异常处理(2)