docker runx, how?
The beauty of OCI images
The beautiful part of docker runx
(at least for me) is it’s just about images. Then, it’s a question of client to read and interpret what’s in the image.
For the context, you can have a look at the previous post. It explains why I'm building
docker runx
and how that works from a user perspective.This post on the other side is about the technical details of
docker runx
.
Images and Metadata
To add more knowledge and context to a “docker” image could be done in different ways. One could be to have an API, a server that will reference images and add more data. That’s basically what you have if you browse the Docker Hub. You have the image and some extra data.
For instance, if you browse eunomie/scout-cli
you can see the description of the repository:
This description is nowhere in the image. It’s just metadata that is stored in the Docker Hub database. One of the downside of it, is you can’t have a strong link between this description and the image itself. Because the image is not the repository. For instance, what about a different description for a specific version of your image? Of course to have it in a separate database is possible, but we can do better.
Another downside of that way is that if you want to update your description, it’s not integrated in a simple docker push
command. You can do things, but in the end you need to call an API for that (or to manually edit it).
Decoration
To create a runx
compatible image, you can simply decorate an existing image. By decorate I mean to take an existing image and add to it the readme and the runx configuration file.
I’ll take the example from the why? post. In this case, the readme is named README.md
and the runx
configuration file runx.yaml
. You can then run the following command:
$ docker runx decorate -t eunomie/scout-cli:latest docker/scout-cli:latest
This will take the image, add the different files, and push the image under a new tag.
The input is an image. The output is an image. If you were able to docker run
the input image, you will be able to docker run
the output image. From a classical docker
or Docker Hub perspective, nothing has changed. But under the hood, the magic happens.
OCI Images FTW
The main idea here is to use the OCI image format to store metadata.
I might use OCI image, container image, Docker image interchangeably. They are all (almost) the same thing and sometimes the reality is a mix of them...
Let’s explore a bit how is made an image, and how we can extend it.
If you want a deeper dive in the OCI image format, you can have a look at the slides and recording of a talk I gave at the 2023 Dockercon: Container Images: Interactive Deep Dive.
Basically an image is composed of three main data:
- the image manifest referencing the two other data
- the manifest is the part of the image that will be tagged. If you reference in a
docker run
code> command the imageeunomie/docker-cli
what you are looking for is the manifest referenced bydocker.io/eunomie/docker-cli:latest
- the config blob
- the config blob is the configuration of the container. It's the part that will be used by the runtime to create the container. It contains for instance the exposed ports, the environment variables, etc.
- the layers
- this is where the filesystem of the container is stored. Each layer is a tarball that will be extracted in the container filesystem.
This is “an image”.
┌──────────────────┐
│ Image Manifest │
│ │
│ ┌───────────┐ │
│ │ Config │ │
│ └───────────┘ │
│ ┌──────────────┐ │
│ │ ┌──────────┐ │ │
│ │ │ Layer 1 │ │ │
│ │ └──────────┘ │ │
│ │ ┌──────────┐ │ │
│ │ │ Layer 2 │ │ │
│ │ └──────────┘ │ │
│ │ │ │
│ │ ... │ │
│ │ │ │
│ └──────────────┘ │
└──────────────────┘
When you docker run
an image, basically you download all those data and the runtime will create a container from it.
Based on this image, we can add an extra layer, the image index (or manifest list on previous docker images). As the name suggests, it’s a list of images. This is primarily used to reference multi-architecture images. But we can do more…
So let’s pick for instance an alpine
image. If you look at the image on Docker Hub this is what you will see:
You can explore here the content of the manifest list (it’s using a docker manifest list and not an OCI image index).
And a representation of the image could be:
┌───────────────────────────────────────────────────────┐
│ │
│ alpine:latest Image Index │
│ │
│ │
│ linux/amd64 linux/arm64 │
│ ┌──────────────────┐ ┌───────────────────┐ │
│ │ │ │ │ │
│ │ Image Manifest │ │ Image Manifest │ │
│ │ │ │ │ │
│ │ ┌──────────┐ │ │ ┌──────────┐ │ │
│ │ │ Config │ │ │ │ Config │ │ │
│ │ └──────────┘ │ │ └──────────┘ │ │
│ │ ┌──────────────┐ │ │ ┌──────────────┐ │ │
│ │ │ ┌──────────┐ │ │ │ │ ┌──────────┐ │ │ ... │
│ │ │ │ Layer 1 │ │ │ │ │ │ Layer 1 │ │ │ │
│ │ │ └──────────┘ │ │ │ │ └──────────┘ │ │ │
│ │ │ ┌──────────┐ │ │ │ │ ┌──────────┐ │ │ │
│ │ │ │ Layer 2 │ │ │ │ │ │ Layer 2 │ │ │ │
│ │ │ └──────────┘ │ │ │ │ └──────────┘ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ ... │ │ │ │ ... │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ └──────────────┘ │ │ └──────────────┘ │ │
│ └──────────────────┘ └───────────────────┘ │
└───────────────────────────────────────────────────────┘
When you docker run
this image, the high level steps will be:
- get the image index manifest
- find the entry corresponding to the asked architecture
- get the architecture specific image manifest
- run the image (get the config and the layers and create the container)
What’s interesting here is we can add almost everything we want in the image index. So that’s what we will do, we will add a new runx
specific image.
The runx
Image
We want to store two data:
- the readme file
- the
runx
configuration file
There’s multiple possibilities for that. The one I choose is to adapt an classical image:
- the config blob will be empty
- the image contains at max two layers:
- one will be the content of the description/readme file
- one will be the content of the
runx
configuration file (it’s a bit more than that, but this is a good enough approximation)
To differentiate them, each layer will have a specific media type. This media type helps to understand what’s inside the layer. The two media types I choose are:
application/vnd.runx.readme+txt
for the readme fileapplication/vnd.runx.config+yaml
for therunx
configuration file
With that, I can now represent a runx
image:
┌───────────────────────┐
│ Image Manifest │
│ │
│ ┌────────────────┐ │
│ │ Config (empty) │ │
│ └────────────────┘ │
│ ┌───────────────────┐ │
│ │ ┌───────────────┐ │ │
│ │ │ Readme │ │ │
│ │ └───────────────┘ │ │
│ │ ┌───────────────┐ │ │
│ │ │ runx config │ │ │
│ │ └───────────────┘ │ │
│ └───────────────────┘ │
└───────────────────────┘
And this is for instance the manifest.
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"size": 356,
"digest": "sha256:1f8d5751a6b6529c70d88603f54394b0baae97b4b988598e8e75cf95746bba3a"
},
"layers": [
{
"mediaType": "application/vnd.runx.config+yaml",
"size": 2918,
"digest": "sha256:cc0e1099267bc6048134f05ab6210156f75463888094d1fc1f5762cdead62f2d"
},
{
"mediaType": "application/vnd.runx.readme+txt",
"size": 1382,
"digest": "sha256:bedb01332fc6781df640c760bb9456dfd71d7d3bbcf616cc0178ad4f921e1a1a"
}
]
}
The last step, is to add this to the image index.
As this runx
image is not runnable, we will not define a specific platform:
"platform": {
"architecture": "unknown",
"os": "unknown"
}
By doing that, a docker run
command will not be able to run this image.
But we want docker runx
to know this image exist, so we will add a specific annotation.
"annotations": {
"vnd.docker.reference.type": "runx-manifest"
}
Basically docker runx
will go through all the images and pick the one with this specific annotation.
┌─────────────────────────────────────────────────────────────────────────────────┐
│ │
│ eunomie/scout-cli:latest Image Index │
│ │
│ │
│ linux/amd64 linux/arm64 unknown │
│ ┌──────────────────┐ ┌───────────────────┐ ┌────────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ Image Manifest │ │ Image Manifest │ │ runx-manifest │ │
│ │ │ │ │ │ │ │
│ │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌────────────────┐ │ │
│ │ │ Config │ │ │ │ Config │ │ │ │ Config (empty) │ │ │
│ │ └──────────┘ │ │ └──────────┘ │ │ └────────────────┘ │ │
│ │ ┌──────────────┐ │ │ ┌──────────────┐ │ │ ┌───────────────────┐ │ │
│ │ │ ┌──────────┐ │ │ │ │ ┌──────────┐ │ │ │ │ ┌───────────────┐ │ │ ... │
│ │ │ │ Layer 1 │ │ │ │ │ │ Layer 1 │ │ │ │ │ │ Readme │ │ │ │
│ │ │ └──────────┘ │ │ │ │ └──────────┘ │ │ │ │ └───────────────┘ │ │ │
│ │ │ ┌──────────┐ │ │ │ │ ┌──────────┐ │ │ │ │ ┌───────────────┐ │ │ │
│ │ │ │ Layer 2 │ │ │ │ │ │ Layer 2 │ │ │ │ │ │ runx config │ │ │ │
│ │ │ └──────────┘ │ │ │ │ └──────────┘ │ │ │ │ └───────────────┘ │ │ │
│ │ │ │ │ │ │ │ │ │ └───────────────────┘ │ │
│ │ │ ... │ │ │ │ ... │ │ └────────────────────────┘ │
│ │ │ │ │ │ │ │ │ │
│ │ └──────────────┘ │ │ └──────────────┘ │ │
│ └──────────────────┘ └───────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
You can explore here the content of the image index.
This way of storing extra data inside an image index is exactly how we are storing build attestations like provenance and SBOM.
Go Implementation
Here is a simple implementation of the decorate
function. It’s not checking for errors, so do not use that in production. But it gives you an idea of how it could be done.
This implementation is using
go-containerregistry
.This is a really convenient library to manipulate images.
At a very high level here is how this code works:
- create an empty image
- add the readme layer
- add the config layer
- set the architecture and os to unknown and add the config blob
- create the descriptor containing the runx annotation
- get the source image
- append the runx image to the source image
- write the index to the destination
func decorate(ctx context.Context,
src, dest name.Reference,
runxConfig, runxDoc []byte) error {
// Create the runx image:
// - 1 layer for the readme file (runxDoc)
// - 1 layer for the runx config (runxConfig)
// - empty config
// create the image
img := mutate.MediaType(empty.Image, types.OCIManifestSchema1)
// set the config media type as json
img = mutate.ConfigMediaType(img, types.OCIConfigJSON)
// create the readme layer
runxDocLayer := static.NewLayer(runxDoc, "application/vnd.runx.readme+txt")
// add it to the image
img, _ = mutate.Append(img, mutate.Addendum{
Layer: runxDocLayer,
})
// create the config layer
runxConfigLayer := static.NewLayer(runxConfig, "application/vnd.runx.config+yaml")
// add it to the image
img, _ = mutate.Append(img, mutate.Addendum{
Layer: runxConfigLayer,
})
// set the architecture and os to unknown
config, _ := img.ConfigFile()
config.Architecture = "unknown"
config.OS = "unknown"
img, _ = mutate.ConfigFile(img, config)
// create the descriptor
desc, _ := partial.Descriptor(img)
desc.Platform = config.Platform()
// add the runx specific annotation
desc.Annotations = make(map[string]string)
desc.Annotations["vnd.docker.reference.type"] = "runx-manifest"
// get the source image
index, _ := remote.Index(src)
// append the runx image to the source image
index = mutate.AppendManifests(
index,
mutate.IndexAddendum{
Add: img,
Descriptor: *desc,
})
// write the index to the destination
return remote.WriteIndex(dest, index)
}
With that, you can pick an image with an index, like alpine
, add to it the runx
specific image, and push it to a new reference. And this is exactly how it’s implemented in docker runx
: decorate.go
.
Images and Beyond
Images are a really powerful concept. They are generic, extensible, and flexible. And to support that, registries are most of the time agnostics. They do not care about the content if you follow the specifications. That way, you can use the existing registries to store advanced images, with use cases that were not even thought of when the registry was created.
This is the beauty of the OCI image format.
This is the second post on
docker runx
.I hope you enjoyed the read and learn some new stuff about images.
If you want to go deeper on Images, have a look at my 2023 Dockercon talk Container Images: Interactive Deep Dive.
And if you want to play with
docker runx
here are a few links:
- Previous post on the series: docker runx, why?
- GitHub repository: eunomie/docker-runx
- A few images to play with (including the
eunomie/scout-cli
): eunomie/runx-images- A more advanced example with the
eunomie/runx-go
image: eunomie/runx-go-toolbox
And if you have any question or feedback, feel free to reach out to me using the links below.