LynxTrac · By · 3 min read

Inside the LynxTrac agent: lightweight, powerful, and fast

One binary covers monitoring, remote access, log shipping, and deployments. Keeping it under 15 MB and well under 1% CPU took some specific design choices.

One binary covers monitoring, remote access, log shipping, and deployments. Keeping it under 15 MB on disk and well under 1% CPU at steady state took a handful of specific design choices, which is what this post is about.

One supervisor, independent workers. A crashed worker restarts alone; the agent keeps its single outbound connection.

Design constraints

We started with four non-negotiables:

  1. One process per endpoint. Not a bundle of agents with a supervisor. One PID.
  2. No runtime dependencies. Ship a static binary; don’t require a particular Python or .NET.
  3. Outbound-only. Agent never listens on an inbound port.
  4. Sub-1% CPU in steady state. On the machines we manage, every 1% matters.

These constraints ruled out a lot of common patterns: no Electron, no bundled JVM, no Python-with-pip-install-this.

Language choice

Go, for three reasons: static linking out of the box, a decent standard library, and predictable memory behavior. We considered Rust, great tool, but the binary size with dependencies needed would have pushed us past 20 MB. The performance difference doesn’t matter for our workload.

What the agent actually does

  • Metric collection. Procfs on Linux, WMI on Windows, sysctl on macOS. Sub-second poll intervals, configurable.
  • Log shipping. Tails named files and journald; batches to the relay with backoff on network issues.
  • Session brokering. Accepts session requests from the relay, opens pty or RDP-equivalent streams.
  • Script runner. Executes operator-authorized scripts with output capture and timeout enforcement.
  • Self-update. Signed binary upgrades, staged per policy.

How we kept the size down

  • Single static binary. No shared libraries.
  • No embedded UI. The agent has no GUI. Zero.
  • Shared internal frameworks. Metric poll, log tail, and session brokering all use the same serialization layer.
  • Strip debug symbols in release builds.
  • No bundled interpreters. Scripts run via the host’s existing shell.

How we kept CPU down

  • Event-driven everywhere. No busy loops. Log tailing uses inotify / kqueue / ReadDirectoryChanges.
  • Batched writes. Metrics accumulate for 200ms before sending, so we don’t syscall 60 times a second.
  • Compression. Logs and metrics are gzip-compressed before shipping; CPU cost is minimal, bandwidth cost drops 90%.
  • Idle backoff. If no one is connected and nothing is changing, the agent is effectively silent.

Steady state numbers

On a typical 4-core, 8 GB Linux VM:

  • CPU: 0.3% (occasional 1-2% spikes during metric flush)
  • RSS: 38 MB
  • Disk I/O: negligible
  • Network: 1-4 KB/s inbound (heartbeat), 10-50 KB/s outbound (metrics + logs)

During an active shell session add ~2 MB RSS per session and bandwidth proportional to user activity.

What we sacrificed

  • Rich local buffering. If the relay is unreachable for an hour, we drop old metrics rather than consume unbounded disk.
  • Plugins. You can’t extend the agent itself. You can, however, run arbitrary scripts via the operator surface.
  • Local dashboards. There’s no “localhost:8080” on the agent. Everything goes through the relay.

These are deliberate trade-offs. You can’t simultaneously be tiny, always-on, and Swiss-army-knife.

Related posts