Handcrafting a Docker Image
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:
-
with a
Dockerfile
+ docker build (archive);
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)
-
Directory representing our first layer
-
(Legacy) Version file
-
(Legacy) Json file describing the current layer
-
Tarball containing the filesystem of the layer (aka the linux namespace)
-
Image description file
-
Manifest (file linking all other files together)
-
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.