If you’re familiar with docker, you probably know that docker image storage uses Union FS’s tiered storage technology. When you build a docker image, it is built one layer at a time, with the previous layer serving as the foundation for the next layer, and each layer is not changed after it is built. Because of this, when building a docker image, we have to be especially careful to include only what is needed in each layer, and to remove as much extra stuff as possible at the end of the build. For example, if you are building a simple application written in Go, in principle you only need a Go-compiled binary, and there is no need to keep the tools and environment for the build.

docker officially provides a special empty image scratch, using this image means that we don’t need any existing image as a base, and directly use our custom instructions as the first layer of the image.

1
2
FROM scratch
...

In fact, we can create our own scratch image.

1
$ tar cv --files-from /dev/null | docker import - scratch

So, the question arises, what images can we make using scratch images as a base? The answer is that all executables that don’t need any dependencies can be made using scratch as the base image. Specifically, for statically compiled programs under linux, there is no need for runtime support from the operating system, everything is already included in the executable, for example, many applications developed in Go language use the direct FROM scratch method to create images, so the final image size is very small.

Here is a simple web application code developed in Go language.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, you've requested: %s\n", r.URL.Path)
    })

    http.ListenAndServe(":80", nil)
}

We can use go build to compile this program and make a docker image based on scratch, with the following dockerfile.

1
2
3
FROM scratch
ADD helloworld /
CMD ["/helloworld"]

Next, start compiling and building the docker image.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
mc@mcmbp:~/gocode/src/hello# go build -o helloworld
mc@mcmbp:~/gocode/src/hello# docker build -t helloworld .
Sending build context to Docker daemon  7.376MB
Step 1/3 : FROM scratch
 --->
Step 2/3 : ADD helloworld /
 ---> 000f150706c7
Step 3/3 : CMD ["/helloworld"]
 ---> Running in f9c2c6932a34
Removing intermediate container f9c2c6932a34
 ---> 496f865c05e4
Successfully built 496f865c05e4
Successfully tagged helloworld:latest

So the image is built successfully, let’s take a look at its size.

1
2
3
mc@mcmbp:~/gocode/src/hello# docker image ls
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
helloworld          latest              496f865c05e4        8 seconds ago       7.37MB

But running this image, you will find that the container cannot be created.

1
2
mc@mcmbp:~/gocode/src/hello# docker run -ti -p 80:80 helloworld
standard_init_linux.go:207: exec user process caused "no such file or directory"

The reason is that our helloworld executable runs with some libraries like libc that are still dynamically linked, and the scratch image is completely empty, so when building the helloworld executable specify the static link flag -static and other parameters to make the generated helloowrld binary statically link all the libraries.

1
$ CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -o helloworld .

Then recreate the docker image.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
mc@mcmbp:~/gocode/src/hello# CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -o helloworld .
mc@mcmbp:~/gocode/src/hello# docker build -t helloworld .
Sending build context to Docker daemon  7.316MB
Step 1/3 : FROM scratch
 --->
Step 2/3 : ADD helloworld /
 ---> 3fec774cb2a4
Step 3/3 : CMD ["/helloworld"]
 ---> Running in cbe7dc97d6ad
Removing intermediate container cbe7dc97d6ad
 ---> d15a1e6d759a
Successfully built d15a1e6d759a
Successfully tagged helloworld:latest
mc@mcmbp:~/gocode/src/hello# docker image ls -a
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
helloworld          latest              d15a1e6d759a        3 seconds ago       7.31MB
<none>              <none>              3fec774cb2a4        3 seconds ago       7.31MB

Run the docker image.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
mc@mcmbp:~/gocode/src/hello# docker run -ti -d -p 80:80 helloworld
3c77ae750352245369c4d142e4e57fd3c0f1e11d67ef857235417ec475ef6286
mc@mcmbp:~/gocode/src/hello# curl -v localhost
* Rebuilt URL to: localhost/
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 80 (#0)
> GET / HTTP/1.1
> Host: localhost
> User-Agent: curl/7.47.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Tue, 19 Mar 2019 12:54:28 GMT
< Content-Length: 27
< Content-Type: text/plain; charset=utf-8
<
Hello, you've requested: /
* Connection #0 to host localhost left intact

But the question arises, if we compile helloowrld binaries and make images on MacOS, can we run docker containers? The answer is no!

We need to specify GOOS=linux, which means that the full compile command should look like this.

1
$ CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o helloworld .

For some OCD programmers who want to go a step further, why not compile the executable inside the container and then build the image? This has the advantage of controlling the Go language compilation environment, ensuring repeatable compilation, and is friendly for some projects that integrate with continuous integration tools.

Docker-CE 17.5 introduces a new feature for building images from scratch, called “Multi-Stage Builds”. With this new feature, we can write our dockerfile like this.

1
2
3
4
5
6
7
FROM golang as compiler
RUN CGO_ENABLED=0 go get -a -ldflags '-s' \
github.com/morvencao/helloworld
FROM scratch
COPY --from=compiler /go/bin/helloworld .
EXPOSE 80
CMD ["./helloworld"]

Yes, you read that right, it is indeed a dockerfile that contains two FROM directives, with the following caveats

  1. FROM golang as compiler is to give the first stage of the build a name called compiler
  2. COPY --from=compiler /go/bin/helloworld . is a reference to the output of the first stage build to build the second stage

If you don’t have a name for the first stage, you can use the build stage number (starting with 0) to specify it, like this: --from=0, but for readability reasons, it feels better to name it.

After the build is complete let’s look at the size of the image.

1
2
3
4
5
mc@mcmbp:~/gocode/src/hello# docker image ls
REPOSITORY        TAG      IMAGE ID       CREATED          SIZE
helloworld        latest   071ca07e23f5   1 minutes ago    7.31MB
<none>            <none>   2471fd63f0e7   1 minutes ago    720MB
golang            latest   6d0bfafa0452   2 weeks ago      703MB

Run the docker image.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
mc@mcmbp:~/gocode/src/hello# docker run -ti -d -p 80:80 helloworld
3c77ae750352245369c4d142e4e57fd3c0f1e11d67ef857235417ec475ef6286
mc@mcmbp:~/gocode/src/hello# curl -v localhost
* Rebuilt URL to: localhost/
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 80 (#0)
> GET / HTTP/1.1
> Host: localhost
> User-Agent: curl/7.47.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Tue, 19 Mar 2019 12:54:28 GMT
< Content-Length: 27
< Content-Type: text/plain; charset=utf-8
<
Hello, you've requested: /
* Connection #0 to host localhost left intact