Intro

For my first blog I decided to write about building docker images, since it was the first thing I did when I started deploying this blog.

We are going to talk about how to build a container using stages. Stages usually are used by engineers to control the size of the final container, but they can be used for more advanced cases, like implementing a build cache for your CI.

Below we see the parts that we will go through. The first three are the build stages, part D is a formal intoduction on how to build with Makefile, and the last one simply starts the blog container.

  • Part A: Base Container
  • Part B: Development Container
  • Part C: Blog Container
  • Part D: Building the Container
  • Part E: Working with the blog

The idea here is that we are going to create a base container that will be capable of running our blog. Then we will build ontop of that a development container that we will use for debugging and testing. Our Blog container initially will use the development container as its foundation, and when we are done we will switch the foundation to the base container

Base Container

First let’s create a new directory called myblog


mkdir -v myblog


NOTE: From now on myblog will be called rootDir or root directory


Inside in the rootDir, create the following directories


cd myblog
mkdir -v base dev blog

Run the following command to create base/Dockerfile.base file inside base directory.


cat >base/Dockerfile.base<<EOF
# base/Dockerfile.base

FROM ubuntu:20.04 as base

# Dockerfile Arguments
ARG BASE_VERSION
ARG RELEASE_DATE

LABEL build.type="base"
LABEL release-date="\${RELEASE_DATE}"
LABEL org.primef.version="\${BASE_VERSION}"

# Update System
RUN apt update -y

# Install Hugo
RUN apt install hugo -y
EOF

In the code above we:

  • Set ubuntu:20.04 as our base
  • Define 2 Args the will be used during the building of base
  • Define Labels for our container
  • Update the system
  • Install Hugo

This base container is ready to run a hugo blog project if we install one inside and run hugo serve

Development Container

This container can have anything we want. You can install tools that can help you with the development and debugging.

In my development container I usually add the following tools

  • vim
  • git
  • curl

NOTE: For this build, a development container is hardly needed, but it is a good example to show how such stage is used


It is up to you to decide what tools you want to add or if you want to skip a development container and go straight to building the blog container.

If you decide to go through with this, then run the following command to create the dev/Dockerfile.dev file.


cat >dev/Dockerfile.dev<<EOF
# dev/Dockerfile.dev

from local/blog-base

LABEL build.type="dev"

# Install tools
RUN apt-get install git curl vim -y

EOF

In the code above we:

  • Used local/blog-base as our base (we have not build local/blog-base yet)
  • Set some Label
  • Installed the packages we need for development

With the dev Dockerfile being ready to build a new dev container, we are ready to proceed to with the blog container

Blog Container

The blog container will be our final image. For that reason we want it to be as small as possible.

Before we create the dockerfile for blog, let’s first install the blog locally.


pushd blog
git clone https://github.com/ulfox/ulfox-blog.git
pushd ulfox-blog
mkdir themes
git clone https://github.com/ulfox/hugo-theme-m10c.git themes/m10c
popd; popd

In the code above we:

  • Installed an already created hugo blog under blog directory
  • Installed a hugo theme under blog/ulfox-blog/themes/

We can now create the blog/Dockerfile.blog inside blog directory


cat >blog/Dockerfile.blog<<EOF
# blog/Dockerfile.blog
ARG SOURCE_IMAGE

FROM \${SOURCE_IMAGE}

# Dockerfile Arguments
ARG BLOG_VERSION
ARG RELEASE_DATE
ARG DISABLE_FAST_RENDER

# Add labels
LABEL build.type="prod"
LABEL release-date="\${RELEASE_DATE}"
LABEL org.primef.version="\${BLOG_VERSION}"

# Set workdir
WORKDIR /opt/blog

# Copy blog into container
ADD ./ulfox-blog /opt/blog

EXPOSE 8080

ENTRYPOINT ["hugo"]
CMD ["server", "--bind", "0.0.0.0", "--port", "1313", "\${DISABLE_FAST_RENDER}"]

EOF

In the code above we defined:

  • ARG SOURCE_IMAGE which will be used to set our base container
  • A dynamic FROM that will use the SOURCE_IMAGE to set the base
  • Some Labels
  • The Containers default workdir. This is the default location that is used when we invoke a command from now on
  • Copied the blog under /opt/blog
  • Exposed port 8080 which will be used by hugo to serve our blog
  • Entrypoint as hugo. This is used by the Init command during the container startup
  • CMD which will pass arguments to our Init command (Hugo)

Please note how simple is this stage. We do not do builds, we do not install packages. Here all we need to do is add the project and set some labels


NOTE: If this project was let’s say a golang project that needed build, we would have implemented a build stage prior to this. In the build stage we create the artefacts and in the final stage we copy them from the build container to the final one.


Building

For the build we can use docker build ... to invoke the build, however for this tutorial we will create a Makefile instead, to show how we can automate the build process. The makefile will help us later on to invoke all stages and pass arguments on each stage without effort.

In the rootDir create the a file called Makefile with the following content


# Makefile


## This simply sets which target is run as default, in our case it is the run target
.PHONY: default
default: run;

## Here we export the Makefiles parent directory. This is relative to the Makefile
## and not relative to the invokation path
mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST)))
mkfile_dir := $(shell dirname $(mkfile_path))

## Logdir creates a .build directory that will be used to store logs
logdir:
	@if [ ! -e "$(mkfile_dir)/.build.build" ]; then mkdir -vp "$(mkfile_dir)/.build"; fi

## This target will build the base stage.
build-base: logdir
	@cd base \
		&& docker build \
			--build-arg BASE_VERSION="${BASE_VERSION}" \
			--build-arg RELEASE_DATE="${RELEASE_DATE}" \
			-f Dockerfile.base . \
			-t local/blog-base 2>&1 | tee -a $(mkfile_dir)/.build/base.log

## dev stage
build-dev: logdir build-base
	@cd dev \
		&& docker build \
			-f Dockerfile.dev . \
			-t local/blog-dev 2>&1 | tee -a $(mkfile_dir)/.build/dev.log

## blog stage
build-blog: logdir
	@cd blog \
		&& docker build \
			--build-arg BLOG_VERSION="${BLOG_VERSION}" \
			--build-arg RELEASE_DATE="${RELEASE_DATE}" \
			--build-arg SOURCE_IMAGE="${SOURCE_IMAGE}" \
			-f Dockerfile.blog . \
			-t local/blog 2>&1 | tee -a $(mkfile_dir)/.build/blog.log

## Here we simply check if BUILD_ENV == "dev". In such case, we call
## build-blog with build-dev
run:
ifeq ($(BUILD_ENV),dev)
run: build-dev build-blog
else
run: build-base build-blog
endif

In the code above we:

  • Defined the default target that will be invoked when make is called
  • Set the absloute path of Makefile, so makefile can create logs relative to itself and not relative to the invokation path
  • Defined a logdir target that checks if .build directory exists and creates it in cases it does not
  • Defined a build-base target which invokes docker build along with build-args to create the base container. It also calls logdir path prior to the docker build
  • Defined a build-base target which invokes docker build along with build-args to create the dev container
  • Defined a build-blog target which invokes coker build along with build-args to create the blog container
  • Defined the run target which runs either the (build-dev build-blog) set targets in the given order or the (build-base build-blog) set targets in the given order
  • For the run target, the BUILD_ENV is passed during build along with BASE_VERSION, RELEASE_DATE, BLOG_VERSION, SOURCE_IMAGE

Now we are missing two more files. The first one is build which will be used to export variables and invoke the makefile for us, and the last one is build.versions which we will use to set variables which will be consumed from the build script.

Create the build file in the rootDir with the following content


#!/usr/bin/env bash

set -e


WDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"

source "${WDIR}/build.versions"

BASE_VERSION="${BASE_VERSION}"
BLOG_VERSION="${BLOG_VERSION}"
RELEASE_DATE="${RELEASE_DATE}"
BUILD_ENV="${BUILD_ENV}"

function die() {
    for m in "${@}"; do
        echo -e "${m}"
    done
    exit 1
}

declare -a ENV_ARRAY=("BASE_VERSION" "RELEASE_DATE" "BLOG_VERSION" "BUILD_ENV")

echo -e "\nChecking Environment"
for e in "${ENV_ARRAY[@]}"; do
    [[ -z "${!e}" ]] && die "\tEnv ${e} was not provided" \
    "\tUsage: ./build-dockers base_version release_date ..."

    echo -e "\tEnv ${e} has been set with: ${!e}"
done

echo -e "\nBuliding\n"

if [[ "${BUILD_ENV}" == "dev" ]]; then
    SOURCE_IMAGE="local/blog-dev"
elif [[ "${BUILD_ENV}" == "prod" ]]; then
    SOURCE_IMAGE="local/blog-base"
else
    echo "Wrong ${BUILD_ENV} is not supported"
    exit 1
fi
 
make BLOG_VERSION="${BLOG_VERSION}" \
    RELEASE_DATE="${RELEASE_DATE}" \
    BUILD_ENV="${BUILD_ENV}" \
    SOURCE_IMAGE="${SOURCE_IMAGE}"

In the code above we:

  • Set exported the working direcoty of our project. Thsi is relative to the project and not to the invokation point
  • Set our Envionment variables by using source … to export them first (the build.versions has not yet been cread, we will creat it next)
  • Defined a die function that can be used at will to terminate the process
  • Declared an array with our Envionment variables names. We do this because we are going to iterate in the for loop that follows the array
  • Created a for loop that checks if our environment variables are empty, incase they are it calls die function
  • Set the SOURCE_IMAGE variables based on the BUILD_ENV variable. The SOURCE_IMAGE variables defines our base for the blog.
  • Invoked make to build our blog container

Please note how we switch over the base of our blog container. If BUILD_ENV == dev, we use the dev container ase base, otherwise we use base container as base

Next create in the rootDir the build.versions file with the following content


export BASE_VERSION="0.0.1-stage"
export BLOG_VERSION="0.0.1-stage"
export RELEASE_DATE="2020-11-13T03:35:12+08:00"
export BUILD_ENV="dev"

We can now build the Blog Docker container by issuing

bash build

Expected output


Checking Environment
        Env BASE_VERSION has been set with: 0.0.1-stage
        Env RELEASE_DATE has been set with: 2020-11-13T03:35:12+08:00
        Env BLOG_VERSION has been set with: 0.0.1-stage
        Env BUILD_ENV has been set with: dev


Buliding

...
Sending build context to Docker daemon  2.048kB
Step 1/11 : FROM ubuntu:20.04 as base
...
Successfully tagged local/blog-base:latest
...
Step 1/11 : FROM local/blog-dev
...
Successfully tagged local/blog-dev:latest
...
Step 1/11 : FROM local/blog-dev
...
Successfully tagged local/blog:latest

Start developing with docker

During development we need to edit the blog and refresh the content.

We can do that by issuing the following command


docker run -d --name blog -v "${PWD}/blog/ulfox-blog:/opt/blog" -p 1313:1313 local/blog

or with docker compose


version: '3'
services:
  dev:
    image: local/blog
    container_name: blog
    ports:
      - 1313:1313
    volumes:
      - ./blog/ulfox-blog:/opt/blog

Both code sinppets from above mount our blog in the container. We do this becase we want to make changes in the blog and let hugo know about these changes. Later on, when we are done editing the blog, we will remove these volumes and rebuild the blog container

You can now start editting the blog and preview live the changes by visiting http://localhost:1313

For example you can create a new post under blog/ulfox-blog/content/posts/2020-11-13/my-new-post.md with the following content


---
date: "2020-11-13T03:35:12+08:00"
draft: false
type: "post"
title: "My cool new post"
tags: ["docker", "linux"]
summary: "This is my new cool post"
toc: true
---

my cool new post

After creating the my-new-post.md file from above, if you visit http://localhost:1313/posts/ you should see the following content


Example image


Build final blog

After we are done editing the blog, we are ready to build our production blog container.

Stop the running container


docker stop dev

Update the versions in build.versions


export BASE_VERSION="0.0.1-final"
export BLOG_VERSION="0.0.1-final"
export RELEASE_DATE="2020-11-13T03:35:12+08:00"
export BUILD_ENV="prod"

Build the container


bash build

Checking Environment
        Env BASE_VERSION has been set with: 0.0.1-final
        Env RELEASE_DATE has been set with: 2020-11-13T03:35:12+08:00
        Env BLOG_VERSION has been set with: 0.0.1-final
        Env BUILD_ENV has been set with: prod


Buliding

...
Sending build context to Docker daemon  2.048kB
Step 1/11 : FROM ubuntu:20.04 as base
...
Successfully tagged local/blog-base:latest
...
Step 1/11 : FROM local/blog-base
...
Successfully tagged local/blog:latest

That is it!!! The final blog container image is ready to be served.


docker run -d --name blog -p 1313:1313 local/blog

Notce that we removed the volume from the docker command’s arguments.

I hope you enjoyed my first blog post. For any issues please contact me at @ mailto:kotsis.christos@primef.org