skip to Main Content

I have a container with an entrypoint that runs a long-lived service.

ENTRYPOINT /entrypoint.sh

The service outputs logs to stdout and stderr in an unstructured format:

Informational message
An error

I need to wrap these logs in a JSON-structured format in order for them to be machine parsable, but I don’t have control over the binary being run. The output should be like this:

{
    "service": "foo",
    "msg": "Informational message",
    "severity": "stdout",
    "t": 1692606427758000000,
}
{
    "service": "foo",
    "msg": "An error",
    "severity": "stderr",
    "t": 1692606427772000000,
}

My idea was to create a wrapper entrypoint and then stream the output of the entrypoint into jq to generate the structure I want. I came up with this:

#! /bin/bash

exec /entrypoint.sh | jq -R 'split("n")|{service:"foo", msg:.[0], severity:"stdout"}'

This works, but I want to separately handle stdout and stderr to change the severity field accordingly.

I tried using redirects but can’t figure out how to make jq ingest from them, my attempt below results in nothing being output:

#! /bin/bash

exec /entrypoint.sh 
    >  >( jq -R 'split("n")|{service:"foo", msg:.[0], severity:"stdout"}' ) 
    2> >( jq -R 'split("n")|{service:"foo", msg:.[0], severity:"stderr"}' )

Any tips to how I can make jq behave how I want?

2

Answers


  1. You redirect stdout to be processed by the first jq. The second jq inherits this redirection. This causes garbage.

    Let’s demo this.

    jsonify() {
       local sev=$1
       shift
       jq -cR --arg sev "$sev" '
          split("n") |
          { service: "foo", msg: .[0], severity: $sev }
       ' "$@"
    }
    
    driver() {
       echo "Message 1"
       echo "Message 2" >&2
       echo "Message 3" >&2
       echo "Message 4"
    }
    
    driver > >( jsonify stdout ) 2> >( jsonify stderr )
    

    Output:

    {"service":"foo","msg":"Message 1","severity":"stdout"}
    {"service":"foo","msg":"Message 4","severity":"stdout"}
    {"service":"foo","msg":"{"service":"foo","msg":"Message 2","severity":"stderr"}","severity":"stdout"}
    {"service":"foo","msg":"{"service":"foo","msg":"Message 3","severity":"stderr"}","severity":"stdout"}
    

    We can fix this by duplication stdout before it’s redirection.

    driver 3>&1 > >( jsonify stdout ) 2> >( jsonify stderr >&3 )
    

    Output:

    {"service":"foo","msg":"Message 2","severity":"stderr"}
    {"service":"foo","msg":"Message 3","severity":"stderr"}
    {"service":"foo","msg":"Message 1","severity":"stdout"}
    {"service":"foo","msg":"Message 4","severity":"stdout"}
    

    There’s a second issue. The output of the jq processes can come after the command completes.

    $ driver 3>&1 > >( jsonify stdout ) 2> >( jsonify stderr >&3 )
    
    $ {"service":"foo","msg":"Message 2","severity":"stderr"}
    {"service":"foo","msg":"Message 1","severity":"stdout"}
    {"service":"foo","msg":"Message 3","severity":"stderr"}
    {"service":"foo","msg":"Message 4","severity":"stdout"}
    

    This is easily solved by adding a wait.

    driver 3>&1 > >( jsonify stdout ) 2> >( jsonify stderr >&3 ); wait   # Fixed version
    

    I don’t know why you get nothing, but I presume one of these two problems is somehow responsible.


    BUT! As nice as it is to put everything in one command, having two programs write to the same handle can’t end well. Fixed:

    out="$( mktemp )"
    err="$( mktemp )"
    
    driver >"$out" 2>"$err"
    
    jsonify stdout "$out"
    jsonify stderr "$err"
    
    rm "$out" "$err"
    
    Login or Signup to reply.
  2. It seems you need to redirect stderr before stdout.

    If you want to see output when you docker run, add --tty. Or use stdbuf -oL (or better jq --unbuffered) before jq as suggested by @CharlesDuffy

    Dockerfile ==>

    FROM ubuntu
    ENV ACCEPT_EULA=Y DEBIAN_FRONTEND=noninteractive
    RUN apt-get update && apt-get install -y jq
    WORKDIR /tmp
    COPY entrypoint.sh entrypoint2.sh /tmp/
    RUN chmod +x entrypoint.sh entrypoint2.sh
    ENTRYPOINT ./entrypoint2.sh
    

    entrypoint.sh ==>

    #!/bin/bash
    c=0
    while sleep 1; do
        ((c=1-c)) && date || date >&2
    done
    

    entrypoint2.sh ==>

    #!/bin/bash
    ./entrypoint.sh 
       2> >( jq -R '{service:"foo", msg:., severity:"stderr"}' ) 
        > >( jq -R '{service:"foo", msg:., severity:"stdout"}' )
    

    Run the whole thing with :

    docker build -t test . && docker run --tty --rm --name test test
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search