Building container images - using no tools
In a couple of earlier articles, we looked at Linux constructs used in building container images and the OCI image specification which standardizes the container image format. It is now time to understand how we can create container images. Knowing this is important to optimize the image size and building secure container images. And, of course, this knowledge also helps us appreciate what tools like Docker CLI or Buildah among many others help us achieve. There are many tools to build container images. We shall look at each of these methods in-depth in later parts of this series of articles on container images. For today, we will look into building container images using no tools! Actually, just using built-in OS tools.
As we learned already, a container image is a made up of layers packaged as tarballs. Each of these tarballs represent a portion of the file system that is needed for the application in the container to function. For example, here is how the Golang image looks like [on the file system] when you extract it using Skopeo.
|
|
index.json
at the root of the OCI layout contains a pointer to the manifest that describes the configuration and layers within the image. As seen here, both manifest and image config have file’s SHA256 hash as the file name and both files are JSON documents.
Image config, as we learned earlier, is what the container runtime uses to setup the container. It contains an ordered collection of file system layers and the execution parameters to create the container. What is important for our discussion is at the beginning of the configuration JSON. Here is an example from the golang image.
|
|
Each layer in the image is a gzipped tarball, once again, containing the SHA256 hash as the file name. So, if we need to build a container image by hand, we need to really follow the same process of packaging each of our image layers. Let us look at that.
Building a hello-cloud image
To understand the process of building a container image, we shall use a simple hello-cloud Go program. Our image will contain only one layer – the hello-cloud
program. Here is our simple Go program.
|
|
Nothing fancy here. This program takes an optional input. This optional input can be your name or whatever you like. If there is no input, it prints Hello, 🌍!. You can build the binary using go build hello-cloud.go
command.
|
|
Go programs are statically linked and therefore have no OS dependencies. Let us now look at creating a folder structure needed for this.
|
|
We shall now move the hello-world binary into the bin folder.
|
|
We have the contents of our image layer ready. We can now package this into a gzipped tarball.
|
|
This is good. However, to be able to run a container from this image layer, we need to feed tell the container runtime how to use the layers and the execution parameters to setup the container. So, let us step back a bit and see what the earlier example of golang image’s configuration JSON contained. Every container image is specific to an operating system and architecture. The golang image that we pulled is a linux/amd64 image. Our hello-cloud
binary is built on a Linux amd64 system as well.
To be able to invoke our hello-cloud
binary, we need to set the right environment variables. Also, we want this binary to get invoked when the container starts. These environment and execution parameters needs to be within the config section of the configuration JSON.
So, here is how our configuration JSON will start.
|
|
We set the Entrypoint
to the hello-world binary and the Cmd
array to a single argument that will be passed to the binary.
The next important thing that needs to be added is the DiffID of the image layer. The diff ID is the digest over the layer’s uncompressed tarball. We can obtainthis using the sha256sum
command.
|
|
We need to add the serialized version of this digest to the configuration JSON. So, here is how our final configuration JSON looks like.
|
|
As we saw in the golang image example earlier, we need a manifest that combines configuration JSON and the layers. These artifacts are referenced using their digest values as well. So, let us generate the digests for both configuration JSON and the layer tar.gz. We will also need the size in bytes which can be derived from the ls
output. The layer digest is different from the DiffID we captured earlier. The layer digest identifies the compressed content and DiffID, as mentioned earlier, identifies the uncompressed content. Container runtimes deal with uncompressed layer content and therefore the configuration JSON must include the digest of the uncompressed layer.
|
|
We need to add the appropriate mediaType for both configuration JSON and each layer. Here is how our manifest.json looks like.
|
|
Alright. Do we have the image ready? Not quite yet. We need to push it to an image registry. While, on surface, it looks like a container image is a singular entity but in reality it is not. As we have already seen, a container image includes a bunch of layers, a configuration, and a manifest. When Docker CLI or any other tool pushes images to a registry, these tools do so in a specific sequence. Since we are building this without any tools such as Docker CLI, we need to push these artifacts manually using the Docker registry HTTP API.
Here is the workflow.
Let us start. We need to first authenticate with Docker Hub. There are many ways to do this but we will use token authentication.
|
|
The above command will prompt for a password to authenticate with Docker Hub and retrieve the token from the response.
Upload blobs
Once we have the auth token, we need to upload each blob (layers and configuration JSON) to the repository. Let us start with hello-world.tar.gz.
|
|
The first command starts an upload and retrieves the location for the upload. The second command uses the location from first command and uploads the blob. Finally, the third command commits the upload at the location returned by second command and specifies the digest of the blob.
we just need to repeat these steps for each layer and configuration JSON. Since we have only one layer, let us just push the configuration JSON as well.
|
|
Upload the manifest
Finally, we need to upload the manifest.
|
|
Once the manifest upload is complete, you can find the image on Docker Hub.
Can we not run the image we created without pushing it to Docker Hub or another registry? We can! Let us save that for a different day. For now, we will pull the image we just pushed and try to run a container.
|
|
This is it! What’s next? We shall look at some tools to build these container images. Stay tuned.
Share on: