Docker manages containers but it is not uncommon to need to extract from a running container its own metadata - ImageID, Ports, Names... One common use case is to add these metadata in log messages or use some instance specific value as a seed for a random generator. Another interesting use case for a docker-compose usage is to grab the ports of the other containers and just autoconfigure the whole services without any user action even when ports are changed.

Several libraries enable to do it but they often bring some dependencies you can not desire? For instance, log4j2 integration can bring jackson which can trigger conflicts in your own application and would defeat the enhancements you want to bring to your logs.

So is it a lost fight? Actually not. The only real hard requirement you have is to be able to read a UNix socket. Once you are able to do it, then you can handle in plain Java (and control your dependencies) the retrieval of the metadata.

To read a UNix socket from java there are multiple libraries, one of the easiest to use is ipcsocket:

<dependency>
  <groupId>org.scala-sbt.ipcsocket</groupId>
  <artifactId>ipcsocket</artifactId>
  <version>1.0.0</version>
</dependency>

It transitively brings jna and jna-platform for the native integration with the OS. Once there, opening a UNix socket is as easy as a standard Java Socket and you just need to replace the default type by UnixDomainSocket.

To read the docker socket you need to mount it when launching your image, it can typically be done with this command:

docker run \
  -v /var/run/docker.sock:/var/run/docker.sock \
  rmannibucau/docker-meta-demo:1.0.0

At that point, you have access to docker socket from within your container and have a way to read a UNix socket:

public class Meta {
    public void onStart() throws Exception {
        final String socketLocation = args.length < 1 ? "/var/run/docker.sock" : args[0];
        try (final Socket socket = new UnixDomainSocket(socketLocation)) {
            // ....
        }
    }
}

Now we have the socket we can communicate on it through (almost) HTTP protocol. We can hack a HTTP client to use a custom SocketFactory but since the data we manipulate are simple, let's just parse the response ourself.

What we want to do, is to issue a GET on /containers/json endpoint to get the list of running containers in JSON.

The HTTP request will therefore looks like:

private static String get(final String path) {
    return "GET " + path + " HTTP/1.1\r\n" +
            "Host: whatever\r\n" +
            "User-Agent: java-unix-socket-client/1.0\r\n" +
            "Accept: */*\r\n" +
            "\r\n";
}

This is a plain standard HTTP GET request except we specify a host which is actually not used - but needed to ensure the request is valid.

Side note: if you use Java 13 already, this can be simplified using raw literal syntax instead of concatenation.

Then to send the request, we just write it on the socket:

try (final OutputStream os = socket.getOutputStream()) {
  os.write(get("/containers/json").getBytes(StandardCharsets.UTF_8));
}

At that point, the Docker daemon responds to the request through the socket so we have to parse the output as a HTTP request. Here is what the content looks like:

HTTP/1.1 200 OK
Api-Version: 1.38
Content-Type: application/json
Docker-Experimental: false
Ostype: linux
Server: Docker/18.06.3-ce (linux)
Date: Tue, 02 Jul 2019 17:10:47 GMT
Content-Length: 927

<the response payload>

So to parse it we need to read the first line, check it is a 200, then extract from the headers the Content-Length and read the payload to parse it. There are multiple ways to do it but here is one which is not too complicated:

try (final BufferedReader response = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
    String line;
    long len = -1;
    while ((line = response.readLine()) != null) {
        if (line.toLowerCase(ENGLISH).startsWith("content-length:")) {
            len = Long.parseLong(line.substring("content-length:".length()).trim());
        } else if (line.toLowerCase(ENGLISH).startsWith("HTTP/1.1 ")) {
            if (Integer.parseInt(line.split(" ")[1]) != 200) {
                throw new IllegalArgumentException("Invalid status: " + line);
            }
        }  else if (line.toLowerCase(ENGLISH).startsWith("content-type:")) {
            if (!line.contains("json")) {
                throw new IllegalArgumentException("Invalid content type: " + line);
            }
        } else if (line.isEmpty()) {
            if (len < 0) {
                throw new IllegalStateException("No response length found");
            }
            doReadResponse(response, len)
            break;
        }
    }
}

In other workds, we read each line of the response, we match the expected lines (response line and content type) and we get get an empty line it means we found the delimiter line before the payload so the rest is the payload.

And here is the "take care" point of this option, it is very tempting to just read the end of the socket stream to extract the whole payload, but actually it wouldn't work since the socket is reusable to issue another command so you must absolutely respect the Content-Length - this is why we extracted it. To do that I use a custom InputStream implementation counting the number of read bytes:

public class LimitedReader extends Reader {
    private final Reader delegate;
    private long remaning;

    public LimitedReader(final Reader response, final long len) {
        this.delegate = response;
        this.remaning = len;
    }

    @Override
    public int read(final char[] cbuf) throws IOException {
        if (remaning > 0) {
            remaning--;
            return delegate.read();
        }
        return -1;
    }

    @Override
    public int read(final char[] cbuf, final int off, final int len) throws IOException {
        if (remaning <= 0) {
            return -1;
        }
        final int toRead = (int) Math.min(remaning, len);
        remaning -= toRead;
        return delegate.read(cbuf, off, toRead);
    }

    @Override
    public void close() {
        // no-op, don't close delegate
    }
}

Once we have that class we can implement doReadResponse(response, len) the way which fits our application to extract the data. In the context of this post, and aligned on my development stack, I will use JSON-P to do that:

private static void doReadResponse(final Reader response, final long len) {
    try (final JsonReader jsonReader = Json.createReader(new LimitedReader(response, len))) {
        final JsonArray containers = jsonReader.readArray();
        // todo: use containers
    }
}

At that stage, you extracted all the running containers - by default the endpoint we called ignores not running containers - but you are not guaranteed it is the container you are running the code from.

To filter out the container you are running from you can use its image - assuming you don't run twice the same image:

final String imageName = "rmannibucau/docker-meta-demo:1.0.0";
final JsonObject container = containers.stream()
    .map(JsonValue::asJsonObject)
    .filter(it -> imageName.equals(it.getString("Image")))
    .findFirst()
    .orElseThrow(() -> new IllegalStateException("Didn't find myself in " + containers));

In the cases it is not enough, Docker will bind the hostname from the container id (smaller string) so you can use this algorithm:

final JsonObject container = containers.stream()
  .map(JsonValue::asJsonObject)
  .filter(it -> it.getString("Id").startsWith(hostName))
  .findFirst()
  .orElseThrow(() -> new IllegalStateException("Didn't find myself in " + containers));

You can also fallback on the IP of the instance as well:

final String lo;
try {
    lo = InetAddress.getLocalHost().getHostAddress();
} catch (final UnknownHostException e) {
    throw new IllegalStateException("No hostname", e);
}
final JsonObject container = containers.stream()
  .map(JsonValue::asJsonObject)
  .filter(it -> lo.equals(
           it.getJsonObject("NetworkSettings")
             .getJsonObject("Networks")
             .getJsonObject("bridge")
             .getString("IPAddress")))
  .findFirst()
  .orElseThrow(() -> new IllegalStateException("Didn't find myself in " + containers));

Tip: it is not forbidden to chain the strategies in an orElseGet and even add your own fallbacks - like using an environment variable ;).

In any case, at that stage you extracted a model of your container (as a JsonObject) and can extract and propagate the values you need in your application.

Note that for several values you don't need a JsonParser at all but it is quite common to have one in your application so don't hesitate to reuse it.

Happy dockerization!

 

From the same author:

In the same category: