ArchyntArchynt.api
Back to blogEngineering

Mapping service dependencies from /proc, no agent SDK required

You don't need to instrument anything to know which service talks to which. The kernel already wrote it down.

July 10, 2026 · 8 min read

"Add an agent to every service" is the answer most observability tools give you for service discovery, and it's a reasonable answer if you're willing to touch every deployment manifest, restart every process, and keep the SDK version in sync across a dozen services owned by people who don't report to you. It's a much less reasonable answer if you're trying to map a system in the first five minutes of using a product, before anyone has agreed to change a single line of config.

The kernel already knows which sockets are open and who owns them. You just have to go read it.

What's actually in /proc

Every process on Linux gets a directory under /proc/<pid>, and inside it, net/tcp (and net/tcp6 for IPv6) lists every TCP socket visible in that process's network namespace: local address, remote address, connection state, and — the field that matters here — an inode number. That inode is the same number you'll find in /proc/<pid>/fd/ as a symlink target for any file descriptor that's actually that socket. Walk the file descriptors of every process, match inodes back to the socket table, and you have a process-to-connection map without asking a single process for permission.

From there it's addition, not innovation: map each PID to its container (cgroup path or the Docker API tells you that directly), and a raw list of "process 4821 has an established connection to 10.0.4.12:5432" becomes "the orders container is talking to the postgres container." No agent inside orders, no agent inside postgres, no code path in either one aware it's being watched.

The part that actually took the iterations

Reading the table once is the easy 80%. The problem is that /proc/net/tcp is a snapshot: a connection that opens and closes between two polls simply never appears in either sample. A health check that fires every 45 seconds against a 60-second poll interval, an outbound call that completes in 200ms, a batch job that connects once during your five-minute window and never again — all of them are real dependencies, and a naive snapshot-diff misses them constantly. On a busy service, most of the interesting traffic is exactly this kind of short-lived connection, which is a frustrating thing to learn after your topology view has already convinced you it's complete.

Our fix is a sliding retention window rather than a bare snapshot: an edge seen in any of the last five poll cycles stays in the graph, aged out only after it's been genuinely absent for that whole span. It's not a clever algorithm — it's closer to "remember what you saw for a bit longer than you think you need to" — but it's the difference between a topology that looks plausible and one that's actually right.

Where this approach runs out

It's worth being upfront about the edges. Namespace-per-container setups (the common case in Docker and Kubernetes) mean you're reading each container's own network namespace rather than one shared table for the host, which is more bookkeeping but not fundamentally harder. Encrypted traffic doesn't hide the connection — TCP metadata is all we need here, not payload — but it does mean we only know two containers are talking, not over what protocol or to what endpoint; that's what the Beyla-based RED metrics layer is for, and the two are designed to be read together rather than as substitutes for each other. And this is fundamentally a TCP-layer view: UDP-based protocols need a different read entirely, which is on the list, not yet in the agent.

Want to see this on your own system?

A repository URL is enough for the static graph. The runtime agent takes one docker-compose file.