Give me a Unix Shell and I'll Build Container Images ... for Life

Written by inaeem | Published 2022/01/26
Tech Story Tags: open-container-initiative | linux | containers | docker | container-images | shell-script | unix-shell | build-container-images

TLDRA container image, in its most basic form, is a collection of tarball files in charge of the following tasks: Provide a root filesystem for the container process and its children to build an isolated environment, restrict access to files and commands outside of the root. via the TL;DR App

This article is not for those who are easily afraid by the low-level specifics of container images and rely on high-level tools (buildkit, docker, buildah etc.) to package the application and its runtime dependencies into a container image.

What is a container image?

A container image, in its most basic form, is a collection of tarball files in charge of the following tasks:

  1. Provide a root filesystem for the container process and its children to build an isolated environment, and restrict access to files and commands outside of the root filesystem.
  2. The instructions for setting up the container process with environment variables, cgroups, namespaces, security settings, and so on.

That's nice, but …

  • Is it good enough to deliver a container image for "Hello World"?
  • What is the format of a container image?
  • Is there any effort to standardize image formats for creating and transporting container images?
  • What should we be searching for? Will container runtimes recognize my container image?

Open Container Initiative

The Open Container Initiative is a non-profit open governance organization whose sole goal is to create open industry standards for container formats and runtimes. With that industry aligning around a set of open, universal container technology standards.

OCI Image Specification

This specification defines an OCI Image as a manifest, an image index (optional), a set of filesystem layers, and a configuration. The purpose of this standard is to make it possible to develop compatible tools for different container runtimes (docker, container, rkt, runc, crun, etc.)

At the top level, a OCI Image is just a tarball, and it has the layout as below.

# OCI container image layout
├── blobs
│   └── sha256
│       ├── DIFFID      (image.manifest)
│       └── DIFFID      (image.config)
│       └── DIFFID      (image.rootfs)
│       └── DIFFID      (image.application)
│       └── DIFFID      (image .... )
│       └── DIFFID      (image .... +n)
├── index.json
└── oci-layout

Image Index (index.json)

The image index is a collection of manifests for one or more platforms and is the starting point. It effectively acts as a manifest, listing all of the "resources" that a single container image requires.

Key Property Descriptions

Name

Description

architecture

Name of the compilation/CPU architecture.

os

Name of the OS. (Linux, windows)

os version

Version of the operating system targeted by the referenced blob.

manifests

List of manifests for specific platforms.

Sample OCI Image Index

~$ skopeo inspect --raw docker://yorek/multiarch-hello-world
{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
   "manifests": [
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 428,
         "digest": "sha256:9b874ccdb73e1aaf29e1c0fd3a550ddfa486d73525f2d77b452e30a09d3cb417",
         "platform": {
            "architecture": "amd64",
            "os": "linux"
         }
      }, 
      .....
   ]
}

Image Manifest

An image manifest specifies the layers and configuration of a single container image for a certain architecture and operating system.

Sample OCI Image Manifest Specification for Linux

~$ cat blobs/sha256/dfddba39ed871b3994726d34b0f33b313101c8262567590c02d3763f9f5a27f6 | jq
{
  "schemaVersion": 2,
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:97895c15ee91c7d4957334444c1a449c52fc6c2f90a7113b49d77c8718f7d8c4",
    "size": 3058
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:45337b3433fa437664b13287291679cf9edca3230f1f37043cc61dab03199d6d",
      "size": 26692286
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:36d0aa647669adaa137c59f52b7e91018ef63c75a0daee690963133217ee47b3",
      "size": 54120976
    },
    .......

  ]
}

You'll notice that the image manifest is merely a text file with no raw data streams from any of the filesystem layers. The manifest format provides a list of distribution artifact hashes.

This list of hashes is effectively a set of instructions on what to download, in what order, how to validate against the content hashes after decompression.

These hashes are represented in Digests format to enable content addressability and uniquely identify image resources.

Digest format is

digest ::= algorithm ":" encoded

sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b

Since we're talking about hashes, we need to distinguish between content hashes and distribution hashes.

Content hashes

Distribution hashes

A content hash is used for uncompressed data present in the layers.

A distribution artifact hash is simply a hash of the compressed blob.

To hash a gzipped item, compression must first occur.

Check the downloaded layers' integrity without first uncompressing them.

The use of distribution artifact hashes eliminates the requirement to maintain a mapping of content hashes.

But How To Build A Root Filesystem?

The root file system is the hierarchical file tree. It contains the files and directories critical for system operation, including the mounting device directory and programs for booting the container processes.

But how to build this hierarchical file order?

Well, the answer is image layers, also known as achieve files. We record file system changes (addition of files, removal of files, or updates) as achieve files. The first achieve file is the base layer; all the other achieve files contain only the changes to its base. Together these achieve files, in chronological order, are what make up the final Root Filesystem.

But ….. I still have questions

  • Can these achieve files hold configuration metadata such as environment variables or default arguments?
  • How to establish chronological order among these achieve files?
  • How to pass security parameters for the container or child processes?
  • etc.…

Well, the answer is in Container Configuration

  • It describes metadata information about the image such as date created, author, as well as execution/runtime configuration like its entry point, default arguments, networking, and volumes.
  • It establishes chronological order among these achieve files (layers).
  • It is JSON data structure and is considered to be immutable.

{
  "created": "2021-01-07T01:29:27.650294696Z",
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/hello"
    ]
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": [
        "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
        "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
    ],
    .....
    
  },
  "history": [
    .....
  ],
  .....
  .....
}


ENOUGH THEORY > WHEN WILL WE START BUILD THE IMAGE

Step 1: Prepare defaults

$ ALGORITHM="sha256"  
$ BUILD_DIR="build"  
$ BLOBS_DIR="$BUILD_DIR/blobs/$ALGORITHM"
$ mkdir -p $BLOBS_DIR

Step 2: Get the busybox image as a base image

# GET AUTH TOKEN
$ TOKEN=$(curl "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/busybox:pull" | jq -r .token)

# PULL LATEST MANIFEST (DOCKER V2) OF BUSYBOX FROM THE REGISTRY
$ curl https://registry.hub.docker.com/v2/library/busybox/manifests/latest  \
		-H "Accept: application/vnd.docker.distribution.manifest.v2+json"  \
        -H "Authorization: Bearer $TOKEN"  \
        -L \
        -o manifest.json 

# PARSE LAYERS REFs FROM THE MANIFEST 
$ CONFIG_LAYER=$(cat manifest.json | jq -r '.config.digest')
$ ROOTFS_LAYER_DIGEST=$(cat manifest.json | jq -r '.layers[0].digest')  
$ ROOTFS_LAYER_SIZE=$(cat manifest.json | jq -r '.layers[0].size')         

# PULL CONFIG LAYER OF BUSYBOX FROM THE REGISTRY
$ curl https://registry.hub.docker.com/v2/library/busybox/blobs/$CONFIG_LAYER \
       -H "Authorization: Bearer $TOKEN" \
       -L \
       -o config.json        
$ ROOTFS_LAYER_DIFF=$(cat config.json | jq -r '.rootfs.diff_ids[0]')  
        
# PULL ROOT FILE SYTEM LAYER OF BUSYBOX FROM THE REGISTRY
$ curl https://registry.hub.docker.com/v2/library/busybox/blobs/$ROOTFS_LAYER_DIGEST \
      -H "Authorization: Bearer $TOKEN" \
      -L \
      -o $BLOBS_DIR/${ROOTFS_LAYER_DIGEST//$ALGORITHM:/}

Step 3: Create Application Layer Locally

# CREATE APPLICATION LAYER LOCALLY
$ APP_LAYER="app-layer.tar"
$ GZIP_APP_LAYER="$APP_LAYER.gz"
$ echo 'echo "Hello World"' > hello.sh && chmod +x hello.sh
$ tar -cvf $APP_LAYER hello.sh

# CALCULATE LAYER'S DIGEST, DIFFID AND SIZE 
$ APP_LAYER_DIFF="$(sha256sum < $APP_LAYER | sed 's/\s*-//g')" 
$ APP_LAYER_DIGEST="$(gzip < $APP_LAYER > $GZIP_APP_LAYER | sha256sum | sed 's/\s*-//g')"
$ APP_LAYER_SIZE="$(stat -c%s $GZIP_APP_LAYER)"

# MOVE APPLICATION LAYER TO BLOBS FOLDER 
$ cp $GZIP_APP_LAYER "$BLOBS_DIR/$APP_LAYER_DIGEST"

Step 4: Create OCI Layout (Files And Directory Structure)

# OCI container image layout
# ├── blobs
# │   └── sha256
# │       ├── DIFFID      (image.manifest)
# │       └── DIFFID      (image.config)
# │       └── DIFFID      (image.rootfs)
# │       └── DIFFID      (image.application)
# ├── index.json
# └── oci-layout

# CREATE OCI-LAYOUT FILE
$ echo > "$BUILD_DIR/oci-layout" << EOF
{
  "imageLayoutVersion": "1.0.0"
} EOF

##
## CREATE OCI-CONFIG LAYER
$ CONFIG_LAYER="image-config.json"
$ echo > $CONFIG_LAYER << EOF 
{
  "created": "2020-04-07T01:29:27.650294696Z",
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/hello"
    ]
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "$ROOTFS_LAYER_DIFF",
      "$APP_LAYER_DIFF"
    ]
  },
  "history": []
}
EOF

$ CONFIG_LAYER_DIGEST="$(sha256sum < $CONFIG_LAYER | sed 's/\s*-//g')" 
$ CONFIG_LAYER_SIZE="$(stat -c%s $CONFIG_LAYER)"
$ cp $CONFIG_LAYER "$BLOBS_DIR/$CONFIG_LAYER_DIGEST"

# CREATE OCI-CONFIG LAYER
$ MANIFEST_LAYER="image-manifest.json"
$ cat > $MANIFEST_LAYER << EOF
{
  "schemaVersion": 2,
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "$CONFIG_LAYER_DIGEST",
    "size": $CONFIG_LAYER_SIZE
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "$ROOTFS_LAYER_DIGEST",
      "size": $ROOTFS_LAYER_SIZE
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "$APP_LAYER_DIGEST",
      "size": $APP_LAYER_SIZE
    }
  ]
}
EOF

$ MANIFEST_LAYER_DIGEST="$(sha256sum < $MANIFEST_LAYER | sed 's/\s*-//g')" 
$ MANIFEST_LAYER_SIZE="$(stat -c%s $MANIFEST_LAYER)"
$ cp $MANIFEST_LAYER "$BLOBS_DIR/$MANIFEST_LAYER_DIGEST"

# CREATE INDEX FILE
$ MANIFESTS_INDEX="index.json"
$ cat > "$BUILD_DIR/$MANIFESTS_INDEX" << EOF
{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "$MANIFEST_LAYER_DIGEST",
      "size": $MANIFEST_LAYER_SIZE,
      "annotations": {
        "org.opencontainers.image.ref.name": "latest"
      }
    }
  ]
}
EOF


# CREATE OCI-LAYOUT FOLDER STRUCTURE
$ tree .
├── blobs
│   └── sha256
│       ├── 01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b
│       ├── 5cc84ad355aaa64f46ea9c7bbcc319a9d808ab15088a27209c9e70ef86e5a2aa
│       ├── 9710b9c0c7c956ca8d2884ee73d7fb578921f127f1eae05078965b2e3125b8de
│       └── e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
├── index.json
└── oci-layout

2 directories, 6 files

Step 5 (Optional): Verify rootfs By Converted Into OCI Runtime Bundle

  • Extract busbox layer (5cc84ad355aaa64f46ea9c7bbcc319a9d808ab15088a27209c9e70ef86e5a2aa) and application layer (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855) into rootfs folder.
  • Use runc to initiate your hello world container by providing -b rootfs as bundle.

# $ 
# ├── blobs
# │   └── sha256
# │       ├── 5cc84ad355aaa64f46ea9c7bbcc319a9d808ab15088a27209c9e70ef86e5a2aa
# │       └── e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

# STEP0: CREATE ROOTFS FOLDER
cd $BUNDLE_DIR
BUNDLE_DIR="runtime_bundle"
mkdir $BUNDLE_DIR && cd $BUNDLE_DIR

# STEP1: EXTRACT BUSYBOX LAYER INTO ROOTFS FOLDER
$ tar -vxzf ../$BUNDLE_DIR/$BLOBS_DIR/5cc84ad355aaa64f46ea9c7bbcc319a9d808ab15088a27209c9e70ef86e5a2aa -C rootfs

# STEP2: EXTRACT APPLICATION LAYER INTO ROOTFS FOLDER  
$ tar -vxzf ../$BUNDLE_DIR/$BLOBS_DIR/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -C rootfs


# STEP3: ALLOW RUNC TO CREATE A DUMMY CONFIGURE FILE FOR YOU AND RUN 
# runc spec
# runc run test
# ls
bin       dev       etc       hello.sh  home      proc      root      sys       tmp       usr       var
# sh hello.sh 
Hello World


Written by inaeem | live @ PID 1
Published by HackerNoon on 2022/01/26