A Makefile for your dockerfiles
I have a git repository containing multiples Dockerfile for images I use in my
Kubernetes cluster and I wanted to have a simple way
to build, rebuild, run, test and push my images without needing to type weird
docker
invocations.
Also my requirements for the build system where:
- Able to handle dependencies: if image B depends on image A, build A first.
- Able to parallelize build when possible: two independent images can be built concurrently
- Able to rebuild image with
--no-cache
when packages updates are available. I use alpine and debian stable releases and package updates are mostly security updates. - Obviously be generic, no hard-coded images so we can add new images without modifying the build system.
So after failing to write a “smart” python script to handle this, I remembered that “dependencies” and “parallelism” where the fundamentals of the Make tool.
For the most part of developers, including me, reading and writing Makefiles appear to be complex, but I think this is a magnitude less complex than any other build system handling dependencies and concurrency properly. Also, and unless you’re a buildroot developer, we don’t read and write makefiles everyday, this may explain why we need to remember how it works when we need it.
My layout for the dockerfiles repository looks like:
.
├── base
│ └── Dockerfile # FROM alpine
└── wordpress
├── Dockerfile # FROM registry/base
├── lts
│ └── Dockerfile # FROM registry/base
└── cli
└── Dockerfile # FROM registry/wordpress
We have multiple directories (docker relative registry) containing one or several subdirectories (docker tags).
For dependencies we have
registry/wordpress
depends onregistry/base
registry/wordpress:lts
depends onregistry/base
registry/wordpress:cli
depends onregistry/wordpress
Also we can build some images concurrently
registry/wordpress
andregistry/wordpress:lts
registry/wordpress:lts
andregistry/wordpress:cli
If we express dependencies properly, concurrency will “just work” by calling make -j N
!
So here is my first version of the Makefile.
REGISTRY?=registry
DOCKERFILES=$(shell find * -type f -name Dockerfile)
IMAGES=$(subst /,\:,$(subst /Dockerfile,,$(DOCKERFILES)))
DEPENDS=.depends.mk
.PHONY: all clean $(IMAGES)
all: $(IMAGES)
clean:
rm -f $(DEPENDS)
$(DEPENDS): $(DOCKERFILES) Makefile
grep '^FROM $(REGISTRY)/' $(DOCKERFILES) | \
awk -F '/Dockerfile:FROM $(REGISTRY)/' '{ print $$1 " " $$2 }' | \
sed 's@[:/]@\\:@g' | awk '{ print $$1 ": " $$2 }' > $@
sinclude $(DEPENDS)
$(IMAGES): %:
docker build -t $(REGISTRY)/$@ $(subst :,/,$@)
Reminders: $@
is an alias for the target name and the :
character used to specify docker tag should be escaped in make target file.
Basically we have all images names generated by recursively finding
Dockerfile
in the current directory, one generic target to run docker build -t <tag> <directory>
and a target generating dependencies automatically. The
sinclude
will trigger the generation of the dependency file and include it.
Given my example, .depends.mk
contains:
wordpress: base
wordpress\:cli: wordpress
wordpress\:lts: base
So if I run make --dry-run
we have:
$ make --dry-run
docker build -t registry/base base
docker build -t registry/wordpress wordpress
docker build -t registry/wordpresss:cli wordpress/cli
docker build -t registry/wordpress:lts wordpress/lts
Images are built in the correct order, hurray !!
I can also build images individually, because each image name has its own target, and this will trigger the build of all parent images.
$ make --dry-run wordpress
docker build -t registry/base base
docker build -t registry/wordpress wordpress
$ make --dry-run wordpress:cli
docker build -t registry/base base
docker build -t registry/wordpress wordpress
docker build -t registry/wordpress:cli wordpress/cli
Don’t forget that docker has a build cache, it will rebuild only if the Dockerfile or the docker context has changed.
Now if I run concurrent building of all images (I suppressed some docker build output for ease of read):
$ make -j 2
docker build -t registry/base base
Successfully tagged registry/base:latest
docker build -t registry/wordpress wordpress
docker build -t registry/wordpress:lts wordpress/lts
Successfully tagged registry/wordpress:latest
docker build -t registry/wordpress:cli wordpress/cli
Successfully tagged registry/wordpress:cli
Successfully tagged registry/wordpress:lts
We can see that wordpress
and wordpress:lts
are built concurrently, and build
of wordpress:cli
starts directly when wordpress
is successfully built.
Now I want to be able to force a rebuild of images only if they have security updates. Images pulled from the official docker hub might have security issues which are already fixed in upstream distribution. On my Debian servers I use the tool unattended-upgrades which installs those updates automatically. I want to keep this feature, in a docker world this means rebuild the image and deploy it automatically everyday.
I wrote a simple script to check whenever image based on alpine (>= 3.8) or debian has update available or not:
#!/bin/sh
if test -f /.dockerenv; then
if test -x /sbin/apk; then
apk --no-cache list -u | grep 'upgradable from' && exit 1
elif test -x /usr/bin/apt-get; then
apt-get update > /dev/null
apt list --upgradable 2>/dev/null | grep 'upgradable from' && exit 1
fi
exit 0
fi
if docker run --rm --entrypoint sh -u root -v $(readlink -f $0):/check_update.sh $1 /check_update.sh; then
echo "\033[0;32m$1 is up-to-date\033[0m"
else
echo "\033[0;31m$1 need update\033[0m" && exit 1
fi
(Yes the script executes itself in docker)
$ ./check_update.sh debian:stretch && echo $?
debian:stretch is up-to-date
0
$ ./check_update.sh debian:testing && echo $?
passwd/testing 1:4.5-1.1 amd64 [upgradable from: 1:4.5-1]
util-linux/testing 2.32-0.4 amd64 [upgradable from: 2.32-0.1]
debian:testing need update
1
Now it’s quite easy to integrate this in the makefile to force a rebuild when the script exit with error:
REGISTRY?=registry
DOCKERFILES=$(shell find * -type f -name Dockerfile)
IMAGES=$(subst /,\:,$(subst /Dockerfile,,$(DOCKERFILES)))
DEPENDS=.depends.mk
.PHONY: all clean checkrebuild $(IMAGES)
all: $(IMAGES)
clean:
rm -f $(DEPENDS)
$(DEPENDS): $(DOCKERFILES) Makefile
grep '^FROM $(REGISTRY)/' $(DOCKERFILES) | \
awk -F '/Dockerfile:FROM $(REGISTRY)/' '{ print $$1 " " $$2 }' | \
sed 's@[:/]@\\:@g' | awk '{ print $$1 ": " $$2 }' > $@
sinclude $(DEPENDS)
$(IMAGES): %:
docker build -t $(REGISTRY)/$@ $(subst :,/,$@)
ifeq (checkrebuild,$(filter checkrebuild,$(MAKECMDGOALS)))
./check_update.sh $(REGISTRY)/$@ || \
(docker build --no-cache -t $(REGISTRY)/$@ $(subst :,/,$@) && ./check_update.sh $(REGISTRY)/$@)
endif
So I can just run make checkrebuild all
or make checkrebuild some-image
.
I created a virtual target checkrebuild
, if present in the requested target
list this will trigger ./check_update.sh
and rebuild with --no-cache
. The
second ./check_update.sh
call is used to validate the image has been rebuild
with new packages. Since all parent images are build before the target image,
they will also being checked and rebuilt if needed.
I used the same trick using MAKECMDGOALS
to add push
, run
and exec
(spawn interactive shell) features, this add a bit of complexity to the makefile because run
and exec
only apply to a single image and not their parent images. So I splitted this in different targets.
Also I have my own bootstraping of alpine:3.8
which may interest you. The
actual Makefile
I use is available on my dockerfiles git
repository. I use it in a jenkins
server running make -j 4 checkrebuild push all
which only rebuild and push
images that need updates.
Enjoy, and share your enhancements if you use it !