Build Multi-arch Docker Images on Ubuntu Linux

Since I’ve made my Raspberry PI Kubernetes cluster hybrid, now I have good reasons to build multi-arch(which means multi CPU architecture) Docker images so I don’t care if my pod is deployed to a Raspberry PI node or a X86 node.

I followed a lot of instructions from this guide and finally made it work on my Ubuntu Linux laptop. Here are the relevant steps for Ubuntu:

First, just in case, the package docker.io needs to be installed with

# installation
sudo apt install docker.io

# add current user to the docker group, so you don't need to sudo to use docker commands
# and you might need to logout and login again to let this be effective
sudo usermod -a -G docker $(whoami)

# verification
docker version
Client:
 Version:           19.03.8
...

I’ve followed this tutorial to install buildx on Ubuntu. The exact commands are

# instructions to build the buildx plugin for docker CLI
export DOCKER_BUILDKIT=1
docker build --platform=local -o . git://github.com/docker/buildx
mkdir -p ~/.docker/cli-plugins
mv buildx ~/.docker/cli-plugins/docker-buildx

# verification
docker buildx create --help

Usage:  docker buildx create [OPTIONS] [CONTEXT|ENDPOINT]
...

A simplest “Hello world” golang app will be used to build this multi-arch docker image

# main.go
package main

import (
  "fmt"
)

func main() {
  fmt.Println("Hello!")
}

The Dockerfile for this golang app looks like

ARG ARCH=
FROM ${ARCH}golang:1.13.1 AS builder

ENV CGO_ENABLED=0 GOOS=linux
WORKDIR /app
COPY . .
RUN go build -a -installsuffix cgo -o hello main.go

FROM scratch

WORKDIR /app
COPY --from=builder /app/hello .

ENTRYPOINT ["/app/hello"]

This can be tested with the default docker build command just to iron out any error before going into multi-arch. The buildx command will be like

docker buildx build --push --platform linux/arm/v7,linux/arm64/v8,linux/amd64 -t <docker user>/<repo>:<tag> .

It might take a while to build all 3 images. After that the same image should be able to run on AMD64 or ARM platforms.

# in AMD64 or ARM environment
docker run --rm <docker user>/<repo>:<tag>

EDIT: When there’s some strange errors with buildx such as

#8 23.71 Error while loading /usr/sbin/dpkg-split: No such file or directory                                                                                                                                       
# or
#13 0.187 Can't open perl script "adduser": No such file or directory                                                                                                                                      

A potential fix is a set of commands

docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name multiarch --driver docker-container --use
docker buildx inspect --bootstrap

🙂

Golang and Docker Multi-Stage Build MK2

In my previous post I used Docker multi stage technique to build a Docker container image which only has the golang executable in a tiny Alpine Linux base image. I’ll go further to use the scratch base image which has basically nothing.

Here’s the Dockerfile I tested on my own project, I’ve also added comments to help understand important lines:

FROM golang:1.13.1 AS builder

# ENVs to ensure golang will need no external libraries
ENV CGO_ENABLED=0 GOOS=linux
WORKDIR /app
COPY . .
# build switches to ensure golang will need no external libraries
RUN go build -a -installsuffix cgo -o myapp main.go && \
# create non-privileged user and group to run the app
  addgroup --system --gid 2000 golang && \
  adduser --system --gid 2000 --uid 2000 golang

FROM scratch
# some sample ENVs for the app
ENV API_KEY=xxx \
  API_EMAIL=xxx
WORKDIR /app
# copy the golang executable over
COPY --from=builder /app/myapp .
# scratch has no adduser command so just copy the files from builder
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
# use the CA cert from builder to enable HTTPS access
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER golang
# run the executable directly
ENTRYPOINT ["/app/myapp"]

The result image is only 7.7MB, only 1MB larger than the golang app itself. 🙂

Golang and Docker Multi-Stage Build

I have noticed a common pattern amonst some new utilities such as kubectl, kops and terraform: There’s only 1 single executable file to install, and by ‘install’ it can be put anywhere as long as it’s in $PATH. This was before I learned some Golang but it’s easy to find out that the reason behind this pattern is that they are all written in Go.

And in the containers’ realm, the new-ish multi-stage build steps of Docker released in 2017 are super beneficial to Golang containers. A TL;DR example looks like:

  1. use a 1GB Debian container with all Golang tools and build dependencies to build the Golang executable( FROM ... AS in the sample ).
  2. put the executable into a tiny run-time container such as Alpine Linux, resulting in a < 20MB container image(depending on the size of the app obviously) ( COPY --FROM in the sample )

A multi-stage ‘hello world’ Dockerfile looks like:

FROM golang:1.12.5-alpine3.9 as builder
ENV GO111MODULE=on
RUN apk update --no-cache && \
apk add git
WORKDIR /app
ADD ./ /app
RUN go build -o golang-test .

FROM alpine:3.9.4
WORKDIR /app
RUN addgroup -g 2000 golang && \
adduser -D -u 2000 -G golang golang
USER golang
COPY --from=builder /app/golang-test .
CMD ["/app/golang-test"]
EXPOSE 8000

Note: To be able to use the multi-stage feature, the Docker version has to be > 17.06.

🙂

Home VPN with OpenVPN

Here are step to run a simple OpenVPN service at home, so that I can access home network easily while not at home.

First, clone the git repo for OpenVPN docker container:

git clone https://github.com/kylemanna/docker-openvpn.git

I can use the pre-built docker image from docker hub but it has just been breached so I’d rather build it myself:

cd docker-openvpn && docker build -t openvpn .

Create a docker volume to persist data if the OpenVPN container to be rebuilt:

export $OVPN_DATA=ovpn_data
docker volume create --name $OVPN_DATA

Generate OpenVPN configurations, if there’s no DNS record for the server, use the public IP of the home broadband alternatively.

docker run -v $OVPN_DATA:/etc/openvpn --log-driver=none --rm openvpn ovpn_genconfig -u udp://VPN.SERVERNAME.COM

Build a new secret key which will be used to generate user keys. I’d advise to use a strong password which can be saved in a password manager or vault. This is needed everytime when I create a new user.

docker run -v $OVPN_DATA:/etc/openvpn --log-driver=none --rm -it openvpn ovpn_initpki

Then the OpenVPN server container can be run as a service:

docker run -v $OVPN_DATA:/etc/openvpn -d -p 1194:1194/udp --cap-add=NET_ADMIN openvpn

Generate the first user profile. The password for secret key will be needed. Then retrieve the OpenVPN configuration with the 2nd command.

docker run -v $OVPN_DATA:/etc/openvpn --log-driver=none --rm -it openvpn easyrsa build-client-full <username> nopass
docker run -v $OVPN_DATA:/etc/openvpn --log-driver=none --rm openvpn ovpn_getclient <username> > <username>.ovpn

This .ovpn file can be used to configure OpenVPN client softwares on laptops or phones.

At last, ensure UDP 1194 port is forwarded to the host of the docker container. This is usually done in the home broadband router.