← Home

Thoughts on layered image build with docker

Docker is an amazing abstraction on how we can put resources and environment configuration in controlled scopes.

With that in mind, for some time now, I’ve been using docker containers to run various gui applications that I didn’t wanted to install on my host machine (on my personal machine I use Ubuntu LTS).

Being able to do that is already an amazing thing, to jail application in a container and have fully control of it, though I’ve had some problems on image building dependencies.

For example, I would have this snippet to install nvidia drivers and interface libs, so I would copy that snippet on all images for gui applications image build.

That was a very naive approach that worked. Though I needed to improve it, because rebuilding the images was taking too long. I needed a dependency to rebuild images for environments.

There were some options to perform what I wanted:

Multistage build

For multistage build, on the same Dockerfile you are able to set a dependency build using multistage. That will help if you need to make some environment setup to build some artifact and then export it and bake a smaller image.

Example (source snippet from docker docs):

FROM golang:1.16
WORKDIR /go/src/github.com/alexellis/href-counter/
FROM golang:1.16
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html  
COPY app.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM alpine:latest  
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=0 /go/src/github.com/alexellis/href-counter/app ./
CMD ["./app"]  

The image generated from this is the perfect solution for golang builds as we will not need the golang runtime inside the image and will only need the output binary.

This is an improvement, though still not what I wanted.

Dockerfile –build-arg

Docker allows you to define build time arguments to provide configuration for your build images. You can use it to customize the docker build and keep it dynamic.

So, what do you think if we take a look on something like this:

ARG BASE_IMAGE
FROM $BASE_IMAGE

Exactly! You can build your image from a dynamic configuration. By using that, we can revamp the build process for docker images.

Imagine the context where you have different Dockerfile to create environment images for: golang, java, php, node, etc. Then you can make layer iteration and build it over the previous build.

The solution that worked for me involved build-args and Makefiles

Docker layered solution with build-arg and Makefile

The Gnu Make is almost omnipresent in linux environments and will allow us to build rules and dependencies to control our build.

Our abstraction will work like this:

root dir
|_ Makefile
| |_layer
| | |_golang
| | | |_Dockerfile
| | | |_Makefile
| | |_java
| | | |_Dockerfile
| | | |_Makefile
| | |_php
| | | |_Dockerfile
| | | |_Makefile
| |_utils
| |_EnvVars.mk

With that folder structure, we will able to define sub-rules on each Makefile. For this solution, we tried to keep code copy at a minimum and we have extracted the common rules into the util EnvVars.mk file.

Let’s take a look on the EnvVars.mk file:

REPO ?= 10.10.0.1:5000
REPO_PUSH ?= n

ifeq ($(BASE_IMAGE),)
	IMAGE = $(REPO)/$(PROJECT):$(TAG)
else
	IMAGE = $(BASE_IMAGE)-$(PROJECT)$(TAG)
	BUILD_ARG_BASE_IMAGE = --build-arg BASE_IMAGE=${BASE_IMAGE}
endif

ifneq ($(REPO),)
	BUILD_ARG_REPO = --build-arg REPO=$(REPO)
endif

all:
	@echo "Available targets:"
	@echo ""
	@echo "In case you want to push the image to remote, please, define:"
	@echo "  REPO_PUSH=y"
	@echo ""
	@echo "  * build - build a Docker image for $(IMAGE)"
	@echo "  * save - export the docker image"
	@echo "  * test - run a bash for the image"
	@echo "  * send-do - send the exported image to do and import it there"

.PHONY: build
build: Dockerfile
	docker build -t $(IMAGE) \
	                $(BUILD_ARG_REPO) $(BUILD_ARG_BASE_IMAGE) \
	                .

	if [ $(REPO_PUSH) = "y" ]; then \
		docker push $(IMAGE); \
	fi

Let’s understand what’s happening:

  • At the beginning, we do some checks do define the IMAGE name and BUILD_ARG_REPO and BUILD_ARG_BASE_IMAGE
  • In case BASE_IMAGE is provided, we will pass it as a config to the Dockerfile at build time and will append our docker image name and tag to the base image, to make it easier to identify

For the next file, lets take a look on the root Makefile file:

BASE_IMAGE = 10.10.0.1:5000/ubuntu20.04
NVIDIA_IMAGE = $(BASE_IMAGE)-nvidia470

.PHONY: nvidia
nvidia:
	BASE_IMAGE=$(BASE_IMAGE) \
	REPO_PUSH=y \
	make -C gui/nvidia build 

.PHONY: java-ui
java-ui: nvidia
	BASE_IMAGE=$(NVIDIA_IMAGE) \
	REPO_PUSH=y \
	make -C layers/java build 

.PHONY: jetbrains-idea
jetbrains-idea: java-ui
	BASE_IMAGE=$(NVIDIA_IMAGE)-java8 \
	REPO_PUSH=y \
	make -C gui/jetbrains/idea-ce build

In this file we define the build dependency between images and as you can see, building the image jetbrains-idea will trigger the dependency calls.

For the last step, let’s take a look on an example image build:

Java image Dockerfile:

ARG BASE_IMAGE
FROM $BASE_IMAGE

ENV DEBIAN_FRONTEND noninteractive

RUN set -ex \
  && apt-get update \
  && apt-get install -y --no-install-recommends \
    ca-certificates \
    openjdk-8-jdk openjdk-8-jdk-headless \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/*

Java image Makefile:

include ../../utils/EnvVars.mk

PROJECT ?= java
TAG     ?= 8

With these steps, I was able to achieve what I wanted (at least for now) in terms of dependency docker image build with a minimum of automation.

$ docker images
REPOSITORY                                                           TAG       IMAGE ID       CREATED        SIZE
10.10.0.1:5000/ubuntu20.04-nvidia470-nodejs16-webstorm2021.3.3   latest    18848f100d1a   13 hours ago   3.84GB
10.10.0.1:5000/ubuntu20.04-nvidia470-nodejs16                    latest    7e8d98a3d833   13 hours ago   2.43GB
10.10.0.1:5000/ubuntu20.04-nvidia470-go1.17-goland2021.2.3       latest    c9a2069d0d5a   16 hours ago   4.13GB
10.10.0.1:5000/ubuntu20.04-nvidia470-go1.17                      latest    b841a419c134   16 hours ago   2.74GB
10.10.0.1:5000/ubuntu20.04-nvidia470-java8-idea.ce2021.3.2       latest    7233e95dbacd   16 hours ago   4.77GB
10.10.0.1:5000/ubuntu20.04-nvidia470-java8                       latest    01189dbfab29   16 hours ago   2.5GB
10.10.0.1:5000/ubuntu20.04-nvidia470                             latest    452c8f71a46a   16 hours ago   2.33GB
10.10.0.1:5000/ubuntu20.04                                       latest    825d55fb6340   2 days ago     72.8MB

Let me know what you think. Thank you for reading it.