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 MB
  • debian:stable: 114 MB
  • centos: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.