Building OCI images with Buildah: Take control on how you build your container

Jean-Nicolas Moal - Oct 20 '21 - - Dev Community

Photo by Ian Taylor on Unsplash

Introduction

In the Linux world, container technology has evolved in a way that we can consider them as a standard way to package and run an application.
Because Docker is the most known tools to play with containers, Dockerfile is the most common way to build them.

But today I want to share with you my preferred tool: Buildah.

It's a powerful tool for building container image in a simple but yet efficient way.

In this article, we'll see how to build a basic image, how to optimize build time using cache, and how to install OS packages without having the OS installed within the container.

Prerequisites

To follow this article, you'll need:

And a Fedora based machine.

Building an image

First, we'll create a basic container image and install Ansible in it.

Create a file called build-unoptimized.sh with the following content:

#!/usr/bin/env bash

set -o errexit

# Creates a new container instance, and stores its name in CONTAINER_ID.
CONTAINER_ID=$(buildah from docker.io/fedora:34)

# Runs commands within the created container.
buildah run "${CONTAINER_ID}" /bin/bash -c "dnf install -y ansible-$ANSIBLE_VERSION"

# Save the container into an image.
buildah commit "${CONTAINER_ID}" "${IMAGE_NAME}:${IMAGE_TAG}"
Enter fullscreen mode Exit fullscreen mode

Before running this script, let's add a Taskfile.yaml to easily configure and build the image:

version: "3"

env:
  IMAGE_TAG:
    sh: git log -n 1 --format=%h
  ANSIBLE_VERSION: "2.9.25"

tasks:
  build-unoptimized:
    env:
      IMAGE_NAME: "buildah-demo-unoptimized"
    desc: "Build the container image without any optimization"
    cmds:
      - time -p ./build-unoptimized.sh
Enter fullscreen mode Exit fullscreen mode

Now, if you run task build-unoptimized, Buildah will create a container image based on docker.io/fedora:34.

It will install ansible using dnf.

On my computer, running this task took 123 seconds and the image size is 614 MB.
We could optimize the size of the image by cleaning up all the DNF cache, but there is a better alternative.

Optimizing by caching

We can kill two birds with one stone with caching, this will help on building time and on the image size.

Let's create a file called build-with-cache.sh with the following content:

#!/usr/bin/env bash

set -o errexit

DNF_CACHE_FOLDER=${HOME}/.dnfcache/
RUN_OPTION="-v ${DNF_CACHE_FOLDER}:/var/cache/dnf:Z"

mkdir -p "${DNF_CACHE_FOLDER}"

# Creates a new container instance, and stores its name in CONTAINER_ID.
CONTAINER_ID=$(buildah from docker.io/fedora:34)

# Runs commands within the created container.
# The keepcache=true in the dnf.conf file tells dnf not to delete successfully installed packages.
buildah run ${RUN_OPTION} "${CONTAINER_ID}" /bin/bash -c "echo 'keepcache=True' >> /etc/dnf/dnf.conf && dnf install -y --nodocs ansible-$ANSIBLE_VERSION"

# Save the container into an image.
buildah commit "${CONTAINER_ID}" "${IMAGE_NAME}:${IMAGE_TAG}"
Enter fullscreen mode Exit fullscreen mode

This new script does the exact same installation as the previous one, but this time it shares a specific local folder with the container,

so that all files downloaded with dnf are kept locally for next runs.

Let's add the corresponding task within the Taskfile.yaml file:

  build-with-cache:
    env:
      IMAGE_NAME: "buildah-demo-with-cache"
    desc: "Build the container image using caching"
    cmds:
      - time -p ./build-with-cache.sh
Enter fullscreen mode Exit fullscreen mode

In order to test this new script, we need to run it twice.

The first time to create the cache, and the second one to see the improvements.

On my computer this build took 35 seconds, and the image size is 365 MB.

That's a huge improvement, we reduced the time by almost 4, and the size by almost 2.

The size is reduced because with this new script the cache is outside the container, so it doesn't end within the container.

We could call it a day, but, in my opinion, containers shall only contains what is necessary to run.

So I don't want all the extra pre-installed packages that comes from the base image.

Let's try to install Ansible from a scratch container.

Installing OS package, without the OS

The idea is simple, we'll tell DNF to install all the package in a specific location.

If the package properly declares all its dependencies, then this should work fine.

But if it doesn't, then we'll need to find the missing packages and install them too.

Let's create a file called build-without-os.sh with the following content:

#!/usr/bin/env bash

set -o errexit
set -x
CONTAINER_ID=$(buildah from scratch)
MOUNT_POINT=$(buildah mount "${CONTAINER_ID}")
DNF_CACHE_FOLDER=${HOME}/.dnfcache

mkdir -p "${DNF_CACHE_FOLDER}"

dnf --installroot "${MOUNT_POINT}" --releasever 34 --nodocs --setopt install_weak_deps=false --setopt cachedir="${DNF_CACHE_FOLDER}" install -y ansible-$ANSIBLE_VERSION

# Save the container into an image.
buildah commit "${CONTAINER_ID}" "${IMAGE_NAME}:${IMAGE_TAG}"

Enter fullscreen mode Exit fullscreen mode

In this script, I added the --installroot option, which tells dnf to install package in a specific location.

I also configured DNF with the keepcache=True on my workstation.

With this new script, we do not use command inside the container, but command available from the host.

This way, no need to have an OS installed or any other package manager inside the container.

Let's add the corresponding task in the Taskfile.yaml file:

  build-without-os:
    env:
      IMAGE_NAME: "buildah-demo-without-os"
    desc: "Build the container image from scratch"
    cmds:
      - time -p buildah unshare ./build-without-os.sh
Enter fullscreen mode Exit fullscreen mode

Running task build-without-os took 115 seconds on my computer, and the image size is 284 MB.

We've lost the build time improvement, but won on the size.

Since we started from scratch, dnf needs to install more packages.

IMHO, it's an acceptable tradeoff since this new image only contains what's necessary.

But from now we only built images, but never tested it, and I can't say it's working if I don't test it.

Testing the image

I won't use a container registry just to test the image, so we're going to export the image and reload it from a container runtime.

Note that buildah builds OCI images, which might not work as-is for docker.

Don't worry, there is an option --format that you can use to build docker compliant images.

In my case, I'm going to use podman to test the image, hereunder a generic task to test images:

  test:
    vars:
      IMAGE_NAME: '{{ default "" .IMAGE_NAME }}'
    env:
      IMAGE_ID:
        sh: buildah images --filter=reference={{ .IMAGE_NAME }} --format='{{ printf "{{ .ID }}" }}'
    desc: "Test"
    cmds:
      - buildah push $IMAGE_ID oci-archive:${PWD}/{{ .IMAGE_NAME }}.tar.gz
      - podman import ./{{ .IMAGE_NAME }}.tar.gz
      - podman run -ti --rm localhost/{{ .IMAGE_NAME }}:$IMAGE_TAG /bin/ansible --version
Enter fullscreen mode Exit fullscreen mode

And now the task to test the image built from scratch:

  test-without-os:
    vars:
      IMAGE_NAME: "buildah-demo-without-os"
    desc: "Load the image in podman and run a test on it"
    cmds:
      - task: test
        vars: { IMAGE_NAME: "{{ .IMAGE_NAME }}" }
Enter fullscreen mode Exit fullscreen mode

If you run task test-without-os ansible version shall be printed.

This is it for now, thank you for reading this post.
Feel free to share it, and see you later for more tech.

Have a nice day!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .