Structured log events and distributed tracing were covered in the first two posts of our series about debugging microservices. In this post we will even further enhance our request tracing, and finally be able to peek into a running microservice from our IDE.
Color me interested
With the help of Sleuth and Zipkin we are able to search for and correlate log events belonging to a particular trace. For that we need to know its unique trace id, that could be part of a REST API response header and noted down for later log analysis in case of an error. Another approach is to first come up with a trace id and already provide it along with the initial REST API request to be carried throughout the whole request processing. You can think of it as painting your request with a signal color, that is easy to spot between all the other dull requests.
Howling at the moon
With the help of the scripting language Lua (Portuguese for “moon”) we can enhance our nginx that acts as a central API gateway.
We use OpenResty as an alternative to vanilla nginx, as it comes along with a preconfigured Lua environment with many useful libraries.
Put the following snippet somewhere inside a server {}
block of your nginx configuration:
The set_by_lua_block {}
directive allows us to directly inline Lua scripting code.
Due to Lua’s limited pattern matching support, we can’t use curly braces as repetition quantifiers known from PCRE.
Instead, the local function hex32()
serves as a workaround to create a pattern to exactly match 32 hexadecimal characters like ^[0-9a-f]{32}$
- that is the 128-bit format our trace ids must follow.
This solution checks if a request header named X-B3-TraceId
exists by evaluating the ngx.var.http_x_b3_traceid
nginx variable (note: HTTP headers are case-insensitive).
If such a header is found and it matches our desired trace id format, we will use it for all proxied upstream requests.
Otherwise we let nginx create a fresh trace id randomly via its ngx.var.request_id
nginx variable.
We also derive a 64-bit span id from the first 16 characters of the trace id, as required by the B3 specification.
X-B3-SpanId
as well as a number of typical X-Forwarded-*
headers are also sent to our upstream microservices, while we send back the X-B3-TraceId
as response header.
Now we can mark our HTTP request with a custom trace id, and it will be propagated upstream into our system of distributed microservices before being echoed back to us as an HTTP response header:
Searching for dead0000beef0000cafe0000babe0000
in our log analysis systems will yield only those log events, that have been generated while processing our marked request.
Logging is just a poor man’s debugging
To quote Twitter, since Twitter is never wrong:
There are good logging and bad logging. Good logging show 4W: when it happened (timestamp), where it happened (process), what cause it (input/data), and why (error message). Bad logging lack one or more of those values which equivalent to poor's man debugging (e.g. printf).
— Shulhan (@_shuLhan) November 20, 2017
But it still happens that you did not foresee to include a proper log statement in your application code. Then one final option is to start a real debugger (as provided by your favourite IDE) and attach it to a running process to have better insights of what is actually going on in your code.
At ePages we are running compiled Java code inside Docker containers in the cloud.
The Java Platform Debugger Architecture (JPDA) specifies a Java Debug Wire Protocol (JDWP) for allowing debuggers to connect to a remotely running Java virtual machine via TCP/IP.
We secured network access to our Docker containers’ debug port, so that only authorised clients can use this.
That enables us to use JDWP by providing a debug port to our Java processes using the JAVA_TOOL_OPTIONS
environment variable with a value of -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000
.
In a local Docker environment, you can use this command:
In our example we started a microservice responsible for determining the tax rates for transactions between two countries. We can send a sample REST request to its local Docker IP and port:
The emitted log message fetching tax rates
does not give us enough details about how these tax rates have been determined, so we want to start a deeper crime scene investigation by stepping through the request processing with a debugger.
CSI: Microservices
In order to attach a debugger to a running microservice, we need to setup our favorite IDE by providing the correct JDWP settings:
When the next request is being served by our microservice, our IDE will automatically interrupt the execution at a breakpoint that we have explicitly provided. Now we can step through single lines of code execution in order to inspect runtime variables and find out if our code is already prepared for an upcoming Brexit tax rate change 😉
The loot of our heist: pearls of wisdom
Now that we have all the tools and processes at hand to properly hunt for bugs in our system, let us come to realise this final conclusion:
"Debugging is like being the detective in a crime movie where you are also the murderer." - Filipe Fortes
— Programming Wisdom (@CodeWisdom) August 16, 2017