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:

Docker Hub

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 runcode> command the image eunomie/docker-cli what you are looking for is the manifest referenced by docker.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:

Alpine

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 file
  • application/vnd.runx.config+yaml for the runx 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:

  1. create an empty image
  2. add the readme layer
  3. add the config layer
  4. set the architecture and os to unknown and add the config blob
  5. create the descriptor containing the runx annotation
  6. get the source image
  7. append the runx image to the source image
  8. 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:

And if you have any question or feedback, feel free to reach out to me using the links below.