How to build the resultant docker image file directory?

Viewed 239

I have the following Dockerfile:

FROM busybox
COPY ./manifest.json /usr/manifest.json
RUN rm /usr/manifest.json

where manifest.json is just some random file I found..

When I build an image from this Dockerfile, there will be 3 layers as expected.. And when I inspect these 3 layers, I will see the file hierarchies as expected.. So the first one will be pretty lengthy like..

bin/acpid
bin/add-shell
bin/addgroup
bin/adduser
bin/adjtimex
bin/ar
bin/arch
bin/arp
bin/arping
bin/ash
... many more

Second layer will be:

usr/manifest.json

And the third layer will be:

proc/.wh..wh..opq
sys/.wh..wh..opq
usr/.wh.manifest.json

I am guessing .wh.manifest.json means remove this file when applying this layer? (Not sure, just guessing..)

My ultimate goal is to actually create the folder structure that the resulting image will have. So for the above case it should essentially be equal to the first layer since first I am adding a file then deleting it.

I could not find any documentation on what .wh stands for.. Does it make sense to iterate through layers like this and keep adding files (to some target, does not matter for now) and deleting the added files in case they are found with a .wh prefix? Or am I totally off and is there a much better way?

If anyone is interested, I have some Java code that downloads and inspects existing layers for an image:

package com.docker.poc;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.GZIPInputStream;

import com.google.gson.Gson;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;

public class Main
{
  static class TokenResponse
  {
    String access_token;
  }

  static class Layer
  {
    String digest;

    long size;
  }

  static class ManifestResponse
  {
    List<Layer> layers;
  }

  static String imageName = "your-image-name";

  static String repoName = "your-repo-name";

  static String base64EncodedUsernamePassword = "not-gonna-tell-ya";

  public static void main(String[] args) throws Exception {
    try (CloseableHttpClient httpclient = HttpClients.createDefault()) {
      HttpGet httpget = new HttpGet(
          "https://auth.docker.io/token?service=registry.docker.io&scope=repository:" + repoName + "/" + imageName +
              ":pull");
      httpget.setHeader("Authorization", "Basic " + base64EncodedUsernamePassword);

      CloseableHttpResponse response = httpclient.execute(httpget);
      String text = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8.name());
      String token = new Gson().fromJson(text, TokenResponse.class).access_token;

      httpget = new HttpGet("https://registry-1.docker.io/v2/" + repoName + "/" + imageName + "/manifests/latest");
      httpget.setHeader("Authorization", "Bearer " + token);
      httpget.setHeader("Accept", "application/vnd.docker.distribution.manifest.v2+json");

      response = httpclient.execute(httpget);
      text = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8.name());
      ManifestResponse manifestResponse = new Gson().fromJson(text, ManifestResponse.class);

      List<File> files = new ArrayList<>();

      int counter = 100;

      for (Layer layer : manifestResponse.layers) {
        System.out.println("Downloading layer: " + layer.digest);
        System.out.println("Layer size:" + layer.size + " bytes.(" + ((double) layer.size) / 1_000_000 + " MB)");
        httpget =
            new HttpGet("https://registry-1.docker.io/v2/" + repoName + "/" + imageName + "/blobs/" + layer.digest);
        httpget.setHeader("Authorization", "Bearer " + token);
        httpget.setHeader("Accept", "application/vnd.docker.distribution.manifest.v2+json");

        response = httpclient.execute(httpget);
        File targetFile = new File(counter + "_" + layer.digest.substring(7, 17) + ".tar");
        counter++;
        files.add(targetFile);

        InputStream inputStream = response.getEntity().getContent();
        OutputStream outputStream = new FileOutputStream(targetFile);
        IOUtils.copy(inputStream, outputStream);
        outputStream.close();
      }

      for (File file : files) {
        TarArchiveInputStream tarInput = new TarArchiveInputStream(new GZIPInputStream(new FileInputStream(file)));
        TarArchiveEntry entry;
        while (null != (entry = tarInput.getNextTarEntry())) {
          System.out.println(entry.getName());
        }
        System.out.println("==========");
      }
    }
  }
}
4 Answers

I understand you want to basically just extract the files and directory structure of a docker image, and only the final result of them applying all layers.

The easiest way to do this is to just use the standard docker commands and let docker do all the heavy lifting, so you don't have to bother with the internal details yourself.

For this docker provides the docker export command which exports a container as a tar archive. To create a container for your image without starting it (to not modify any files) you can use the docker create command:

# create container from image without starting it
docker create --name my_container my_image

# export container file system to tar archive
docker export -o my_image.tar my_container

# or directly extract the archive in the current folder
docker export my_container | tar -x

# clean up temporary container
docker rm my_container

As mentioned in the comments, wh is short for whiteout. This is an indication of files that should be deleted by the overlay filesystem when it reassembles the layers.

To minimize the size of your resulting image, a multi-stage build is the current best practice. E.g.:

FROM busybox as build
COPY ./source.tgz /app/source.tgz
WORKDIR /app
RUN tar -xzf ./source.tgz \
 && ./install.sh
RUN rm ./source.tgz 

FROM busybox as release
COPY --from=build /app /app

Does it make sense to iterate through layers like this and keep adding files (to some target, does not matter for now) and deleting the added files in case they are found with a .wh prefix? Or am I totally off and is there a much better way?

There is a much better way, you do not want to reimplement (with worse performances) what Docker already does. The main reason is that Docker uses a mount filesystem called overlay2 by default that allows the creation of images and containers leveraging the concepts of a Union Filesystem: lowerdir, upperdir, workdir and mergeddir.

What you might not expect is that you can reproduce an image or container building process using the mount command available in almost any Unix-like machine.

I found a very interesting article that explains how the overlay storage system works and how Docker internally uses it, I highly recommend the reading.


Actually, if you have read the article, the solution is there: you can mount the image data you have by docker inspecting its LowerDir, UpperDir, WorkDir and by setting the merged dir to a custom path. To make the process simpler, you can run a script like:

image="testing"
targetDir="target"
lowerDir="$(docker inspect --format "{{ .GraphDriver.Data.LowerDir }}" $image)"
upperDir="$(docker inspect --format "{{ .GraphDriver.Data.UpperDir }}" $image)"
workDir="$(docker inspect --format "{{ .GraphDriver.Data.WorkDir }}" $image)"
mergedDir="/mnt/$targetDir"

sudo mkdir "$mergedDir"
echo "mounting in $mergedDir ..."
sudo mount -t overlay -o lowerdir=$lowerDir,upperdir=$upperDir,workdir=$workDir overlay "$mergedDir"
echo "copying $mergedDir in $targetDir/ ..."
sudo cp -a "$mergedDir/." "$targetDir/" 
echo "unmounting $mergedDir ..."
sudo umount overlay
sudo rmdir "$mergedDir"

Perhaps you can mount the image filesystem directly in the target directory instead of passing through /mnt/$targetDir. I am not an expert on this topic, therefore I have preferred an intermediate step so that I can eventually umount it.


Alternative solution (... not quite)

The first thing I thought when I read your question was the docker cp command. Basically, a not completely correct solution would be to create a container (not run it) and then copy the entire filesystem of it in the hosting machine. This is done with:

docker create your_image sh

Instead of sh you can put whatever you want, it will not be executed anyway because you are not interested in starting the container

Next, copying and exporting the root folder with:

docker cp your_container_name:/ custom_directory

Why is this not a completely correct answer?

By inspecting the LowerDir of the container, with

docker inspect --format "{{ json .GraphDriver.Data }}" container_name

You will notice that there is an additional layer in the form of <sha256 hash>-init, used to initialize the container.

You can even find the files added in this layer by running a tree on that directory.


Extra 1

I could not find any documentation on what .wh stands for..

I have found the image specification that explains what a .wh file means. Basically every layer stores the changeset applied to that layer instead of the entire image created up to that moment. How can you store a deletion? By reserving a special file format that tells to the following layers that the deleted file does not exist anymore. These files are called white-out files and

For this reason, it is not possible to create an image root filesystem which contains a file or directory with a name beginning with .wh.

Extra 2

If you need a command-line tool that inspect image layers, I suggest you skopeo. I discovered it by watching a talk in this year's DockerCON and it seems very good. To export the layers of an image you can run:

skopeo copy docker-daemon:your_image:your_tag "dir:$(pwd)/dir_for_layers"

I ended up with an approach that downloads the layers and then extracts them to build the final directory layout.

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

  <groupId>gd.wa</groupId>
  <artifactId>minimal-pom</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>jar</packaging>
  <modelVersion>4.0.0</modelVersion>

  <dependencies>
    <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-compress -->
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-compress</artifactId>
      <version>1.21</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
    <dependency>
      <groupId>com.google.code.gson</groupId>
      <artifactId>gson</artifactId>
      <version>2.8.9</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
    <dependency>
      <groupId>commons-io</groupId>
      <artifactId>commons-io</artifactId>
      <version>2.11.0</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient -->
    <dependency>
      <groupId>org.apache.httpcomponents</groupId>
      <artifactId>httpclient</artifactId>
      <version>4.5.13</version>
    </dependency>
  </dependencies>

  <properties>
    <java.version>1.8</java.version>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
</project>

Main.java

package com.sonatype.docker.poc;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;

import com.google.gson.Gson;
import org.apache.commons.compress.archivers.ArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;

public class Main
{
  static class TokenResponse
  {
    String access_token;
  }

  static class Layer
  {
    String digest;

    long size;
  }

  static class ManifestResponse
  {
    List<Layer> layers;
  }

  static String imageName = "httpd";

  static String repoName = "library";

  static String base64EncodedUsernamePassword = "username:password[base64-encoded]";

  public static void main(String[] args) throws Exception {
    try (CloseableHttpClient httpclient = HttpClients.createDefault()) {
      // Get a token
      HttpGet httpget = new HttpGet(
          "https://auth.docker.io/token?service=registry.docker.io&scope=repository:" + repoName + "/" + imageName +
              ":pull");
      httpget.setHeader("Authorization", "Basic " + base64EncodedUsernamePassword);

      CloseableHttpResponse response = httpclient.execute(httpget);
      String text = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8.name());
      String token = new Gson().fromJson(text, TokenResponse.class).access_token;

      // Get the list of layers
      httpget = new HttpGet("https://registry-1.docker.io/v2/" + repoName + "/" + imageName + "/manifests/latest");
      httpget.setHeader("Authorization", "Bearer " + token);
      httpget.setHeader("Accept", "application/vnd.docker.distribution.manifest.v2+json");

      response = httpclient.execute(httpget);
      text = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8.name());
      ManifestResponse manifestResponse = new Gson().fromJson(text, ManifestResponse.class);

      List<File> files = new ArrayList<>();

      int counter = 100;

      // Download layers (tar files)
      for (Layer layer : manifestResponse.layers) {
        System.out.println("Downloading layer: " + layer.digest);
        System.out.println("Layer size:" + layer.size + " bytes.(" + ((double) layer.size) / 1_000_000 + " MB)");
        httpget =
            new HttpGet("https://registry-1.docker.io/v2/" + repoName + "/" + imageName + "/blobs/" + layer.digest);
        httpget.setHeader("Authorization", "Bearer " + token);
        httpget.setHeader("Accept", "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip");

        response = httpclient.execute(httpget);
        File targetFile = new File(counter + "_" + layer.digest.substring(7, 17) + ".tar");
        counter++;
        files.add(targetFile);

        InputStream inputStream = response.getEntity().getContent();
        OutputStream outputStream = new FileOutputStream(targetFile);
        IOUtils.copy(inputStream, outputStream);
        inputStream.close();
        outputStream.close();
      }

      unTarGz(files);
    }
  }

  public static void unTarGz(List<File> files) throws IOException {
    for (File file : files) {
      TarArchiveInputStream tararchiveinputstream =
          new TarArchiveInputStream(
              new GzipCompressorInputStream(
                  new BufferedInputStream(Files.newInputStream(file.toPath()))));

      ArchiveEntry archiveentry;
      while ((archiveentry = tararchiveinputstream.getNextEntry()) != null) {
        Path pathEntryOutput = Paths.get("./fs").resolve(archiveentry.getName());
        if (archiveentry.isDirectory()) {
          if (!Files.exists(pathEntryOutput)) {
            Files.createDirectory(pathEntryOutput);
          }
        }
        else {
          if (archiveentry.getName().contains(".wh")) {
            try {
              Files.delete(Paths.get("./fs").resolve(archiveentry.getName().replace(".wh.", "")));
            }
            catch (NoSuchFileException noSuchFileException) {
              noSuchFileException.printStackTrace();
            }
          }
          else {
            if (Files.exists(pathEntryOutput)) {
              Files.delete(pathEntryOutput);
            }
            Files.copy(tararchiveinputstream, pathEntryOutput);
          }
        }
      }

      tararchiveinputstream.close();
    }
  }
}

Any updates will be at: https://github.com/koraytugay/docker-poc-mvn/

Related