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).

dependencies everywhere

For dependencies we have

  • registry/wordpress depends on registry/base
  • registry/wordpress:lts depends on registry/base
  • registry/wordpress:cli depends on registry/wordpress

Also we can build some images concurrently

  • registry/wordpress and registry/wordpress:lts
  • registry/wordpress:lts and registry/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.

heyyy noone leaves until the job is done

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.

job done

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 !

Tags: Docker
comments powered by Disqus