← Blog

Go 1.24 made our connection tracking miss every Go server

October 15, 2026 · The Ultraviolet team

We were building a feature that reports long-lived TCP connections — the gRPC channels and keep-alive connections that make up most service-to-service traffic, and that a tool which only sees connections when they close never sees at all. On our test cluster the first results looked right except for one thing: for some programs, the server end of a connection was reported as if it were the client.

Not for every program. Traefik was fine; Redis was fine; nginx was fine. The ones we got wrong were k3s’s kubelet, metrics-server, and our own components. Every one of them was a Go program built with a recent Go.

How we tell a client from a server

When our sensor starts, some connections are already open, and nobody saw them being made. To decide which end connected and which end accepted, we look at what’s listening: if a connection’s local port is a port a socket is listening on, in the same network namespace, then this end accepted the connection.

To find sockets — listening and connected — we walk every process’s open file descriptors and follow each socket file to its kernel socket, keeping the TCP ones. (We started with the kernel’s TCP socket iterator, but that only walks the network namespace of whoever runs it, so it never saw a single pod’s sockets.)

The connected sockets were all there. The listening sockets for the Go programs were not. ss -tlnp listed them, owned by the right processes, on the right ports — yet our walk never produced them.

Multipath TCP, on by default

Go 1.24 changed a default: on Linux, net.ListenConfig now uses Multipath TCP (MPTCP) for listeners where the kernel supports it. It’s controlled by GODEBUG=multipathtcp=…, and the new default enables it on listeners only, not on dialers (GODEBUG docs). Every net.Listen("tcp", …) in a Go 1.24+ binary — and so every Go HTTP or gRPC server — gets an MPTCP listening socket.

MPTCP is a different socket type. The socket a Go server holds has protocol IPPROTO_MPTCP (262), not IPPROTO_TCP (6). Inside, the kernel creates an ordinary TCP socket to do the listening (kernel implementation notes), and that inner socket is what ss and /proc/net/tcp show, which is why everything looked normal from the command line. But follow a process’s file descriptor to its socket, as we do, and you find the MPTCP socket — which a “keep TCP sockets” filter throws away.

Why did the connected sockets look normal? Because an MPTCP listener accepts a connection from a client that didn’t ask for MPTCP as a plain TCP socket. Almost no client asks — Go’s own dialers don’t by default — so nearly every accepted connection is ordinary TCP. Only the listener changed.

That’s exactly the failure we saw: the connections were there, the listening port wasn’t, so the accepting end looked like a client.

The fix

We keep MPTCP sockets too, for listeners — a listener’s port is all we need from it:

__u16 proto = BPF_CORE_READ(sk, sk_protocol);
if (proto != IPPROTO_TCP && !(proto == IPPROTO_MPTCP && state == TCP_LISTEN))
    return 0;

After that, kubelet’s port 10250, the API server’s 6443 and metrics-server’s listener appeared, and every connection’s direction came out right.

Does this affect your tooling?

If anything you run discovers sockets by walking file descriptors and checks for TCP — a BPF program, an agent, a script reading /proc/<pid>/fd and /proc/<pid>/net — Go servers built with Go 1.24 or later may be invisible to it. Things to check:

  • Filters on protocol. Accept IPPROTO_MPTCP (262) where you accept IPPROTO_TCP, at least for listening sockets.
  • ss looks normal. It lists the inner TCP listener; ss -Ml shows the MPTCP sockets themselves.
  • Opting out, if you’d rather: GODEBUG=multipathtcp=0 turns MPTCP off for a Go program’s listeners and dialers. You lose nothing on a network that doesn’t use MPTCP — but you’d have to set it on every Go program you run, and you don’t build most of them.

The change is reasonable — MPTCP falls back to TCP transparently and can help mobile and multi-homed clients — and it’s documented. It’s just the kind of default that changes what the kernel shows you about software you didn’t write, one upgrade at a time.