Handcrafting a docker image is surprisingly easy. Let’s package a statically-compiled program.

1. Building a Docker Image

An image is basically a filesystem snapshot (potentially layered), and some metadata to specific environment variables, etc.

I know of 2 ways to create images:

The docker specification(archive) is relatively short, so could we create one manually just with bash?

⚠️ For real world tools, you should follow the standard OCI Image Specification (archive) instead of the docker one. ⚠️

2. Overview

We want to package a single statically compiled binary into a single-layer image.

We need to generate a tarball containing the following structure:

.
├── a65da33792c5187473faa80fa3e1b975acba06712852d1dea860692ccddf3198 (1)
│   ├── VERSION (2)
│   ├── json (3)
│   └── layer.tar (4)
├── 47bcc53f74dc94b1920f0b34f6036096526296767650f223433fe65c35f149eb.json (5)
├── manifest.json (6)
└── repositories (7)
  1. Directory representing our first layer

  2. (Legacy) Version file

  3. (Legacy) Json file describing the current layer

  4. Tarball containing the filesystem of the layer (aka the linux namespace)

  5. Image description file

  6. Manifest (file linking all other files together)

  7. Repositories file

We need to generate this bottom-up, in this order: 2, 4, 3, 1, 7, 5, 6.

3. Generating the namespace tarball of the layer

name="$1"
binary="$2"

tempDirectory="${name}-image-temp"

mkdir -p "$tempDirectory/layer-1/temp-fs"

cp "$binary" "./$tempDirectory/layer-1/temp-fs/"

cd "./$tempDirectory/layer-1/temp-fs/" || exit

tar -cvf "../layer.tar" .

cd - || exit

rm -rf "./$tempDirectory/layer-1/temp-fs/"

At this point, we have the following structure:

.
└── ${name}-image-temp
    └── layer-1
       └── layer.tar

4. Legacy Metadata of the layer

We now generate the legacy metadata of the layer, and rename the layer correctly:

echo '1.0' > "./$tempDirectory/layer-1/VERSION"

randomLayerId=$(xxd -l "32" -p /dev/urandom | tr -d " \n")
checkSum=$(sha256sum --binary "./$tempDirectory/layer-1/layer.tar" | awk '{ print $1 }')

layerJson=$(cat << EOF
{
    "id": "${randomLayerId}",
    "checksum": "tarsum.v1+sha256:${checkSum}",
    "created": "1970-01-01T00:00:00.000000000Z",
    "author": "script",
    "architecture": "amd64",
    "os": "linux",
    "Size": 271828,
    "config": {
        "Entrypoint": [
            "/${binary}"
        ],
        "WorkingDir": "/"
    }
}
EOF
)

echo "$layerJson" | jq '.' > "./$tempDirectory/layer-1/json"

mv "./$tempDirectory/layer-1" "./$tempDirectory/${randomLayerId}"

At this point, we have the following structure:

.
└── ${name}-image-temp
    └── a65da33792c5187473faa80fa3e1b975acba06712852d1dea860692ccddf3198
       ├── VERSION
       ├── json
       └── layer.tar

5. Image metadata

We now generate the 3 metadata files for the image itself.

First, the repositories:

repositories=$(cat << EOF
{"$name":{"latest":"${randomLayerId}"}}
EOF
)

echo "$repositories" | jq '.' > "./$tempDirectory/repositories"

Then, the image description:

imageDescription=$(cat << EOF
{
  "created": "1970-01-01T00:00:00.000000000Z",
  "author": "script",
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Entrypoint": [
      "/${binary}"
    ],
    "WorkingDir": "/"
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:${checkSum}"
    ]
  },
  "history": []
}
EOF
)

randomTopId=$(xxd -l "32" -p /dev/urandom | tr -d " \n")

echo "$imageDescription" | jq '.' > "./$tempDirectory/${randomTopId}.json"

Finally, the manifest:

manifest=$(cat << EOF
[
  {
    "Config": "${randomTopId}.json",
    "RepoTags": [
      "$name:latest"
    ],
    "Layers": [
      "${randomLayerId}/layer.tar"
    ]
  }
]
EOF
)

echo "$manifest" | jq '.' > "./$tempDirectory/manifest.json"

We now have the full structure:

.
└── ${name}-image-temp
   ├── a65da33792c5187473faa80fa3e1b975acba06712852d1dea860692ccddf3198
   │   ├── VERSION
   │   ├── json
   │   └── layer.tar
   ├── 47bcc53f74dc94b1920f0b34f6036096526296767650f223433fe65c35f149eb.json
   ├── manifest.json
   └── repositories

6. Image packaging

The last thing we need to do is to package the image in a tarball:

cd "$tempDirectory" || exit

tar -cvf "../${name}.tar" .

cd - || exit

rm -rf "$tempDirectory"

7. Testing it

We can now ask docker to load our image, and to run it:

docker load -i "${name}.tar"
docker run -i "${name}:latest" <args>

8. Super lean

As you can see, we didn’t need any special tool, nor any bulky base image to produce our final image.

Furthermore, the generated image size is only slightly bigger than the binary of the program we’ve packaged.

This is only really working for statically compiled binaries (or just plain data), because we would otherwise need to ship all the *.so libs required by the program, and to correctly set environment variables for this to work correctly.

This is where nix or docker build provide their value.