While creating a Docker image, one of the most important decisions to make is what base image to use. In this post I’ll compare three kinds of base images for you.
A universal base image
Lots of the official Docker Hub images seem to use universal-purpose base images such as debian – e.g. nginx, mysql or redis. Unsurprising due to:
- It’s one of the most well-known, widespread and forked server OS
- You can’t misdo a lot of things
- It’s stable, i.e. there aren’t any breaking or unexpected changes across minor versions
- It provides a lot of packages: 56903 (
docker run --rm -it debian:stable-slim bash -c 'apt update && apt list |wc -l'
)
If you prefer an RPM-based distro, centos should fit your needs. However:
- There’s the one or other unexpected change across minor versions
- It provides much less packages: 10525 (
docker run --rm -it centos:7 bash -c 'yum makecache && yum list |wc -l'
) - Even with the “must-have” repos SCL and EPEL there are only 29619 ones (
docker run --rm -it centos:7 bash -c 'yum install -y centos-release-scl epel-release && yum makecache && yum list |wc -l'
)
A minimal base image
The smaller a Docker image is, the less time it takes to pull it on every update, the better. Unsurprisingly, a lot of the official images also provide a version with alpine as base image due to it’s only 5.61 MB large compared to:
debian:stable-slim
: 69.2 MBdebian:stable
: 114 MBcentos:7
: 203 MB
However the amount of provided packages is ways smaller as well: 11270 (docker run --rm -it alpine sh -c 'apk update && apk list |wc -l'
)
I.e. if the app to be shipped has special dependencies, Alpine is much likely not to provide one of them by itself.
No base image
In special cases where an app has (almost) no dependencies or they are provided by something other than a distro’s package manager you can also omit the base image at all. E.g.:
Project
- main.go
- go.mod
- go.sum
- Dockerfile
main.go
package main import "github.com/kataras/iris" func main() { _ = iris.Default().Run(iris.Addr(":80")) }
Dockerfile
FROM golang as build ADD . /example-server WORKDIR /example-server ENV CGO_ENABLED 0 RUN ["go", "build", "."] FROM scratch COPY --from=build /example-server/example-server /example-server ENTRYPOINT ["/example-server"]
How it works
Did you notice more than one FROM in the Dockerfile above? This is possible thanks to multi-stage builds. The first stage (“build”) builds a Go application (and automatically fetches all dependencies). The second stage (the actual image) is based on an empty base image (“scratch”) and contains just the previously built binary. Thanks to CGO_ENABLED=0 it doesn’t even link against libc – and has no dependencies at all.
Caveats
- That “scratch” image actually doesn’t contain anything – neither nslookup, nor even bash – which makes debugging very hard
- You have to write the entrypoint script (if any) in Go as well
- That “scratch” image also don’t contain an
/etc/passwd
– i.e. there’s only the superuser who may be not allowed by the container platform
Conclusion
So which of the three ist the best one? Well, that depends on the concrete app to be shipped.
Like a certain popular Russian YouTuber would say:
It’s for you to decide: To be – or …
We’re going through the same decision process as well with our newest project, providing Icinga 2 Docker images.