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