Shieldlist

What the agent costs the machine it protects

The agent runs on a server bought to run something else. Everything below is what it takes, what bounds it, and the one thing that no setting can.

Memory it controls

limits.memory in agent.yaml is the ceiling the agent holds itself to. It accepts a share of the machine or an absolute size, and defaults to half the machine:

limits:
  memory: 50%      # or "512MB", "2GB", "1TB"

Half is a lot for a guest program, and it is deliberate: this is the program that stops the attack, and evidence it cannot hold is an attacker it cannot convict. It is a ceiling and never a reservation — the agent takes only what its rules need, which on a quiet machine is a few tens of megabytes.

It is a fixed ceiling. The agent does not read how much memory the machine has free and does not shrink when something else grows. That is deliberate, and it was not always so: an earlier version subtracted a reserve from available memory, and on a hosting node with a database and twenty PHP pools a nightly job pushed available below the reserve, so the budget collapsed from 929446 buffered events to 16384 while the process was holding 16 MB. It shed a capacity it had never taken, because of what something else was doing.

A program whose behaviour depends on the rest of the box is one whose behaviour cannot be reproduced, and one that competes with every other program for the same slack. Tell this one what it may have; dividing the rest is the administrator's job.

Past the ceiling the agent does not stop reading. It remembers less far back — uniformly across every rule, so no offender is forgotten whole while others are kept intact — and it says so once, with what you can do about it: shorten the rules' windows, narrow what they match, raise the ceiling, or give the machine more memory. shield status counts the evidence that went, under shed.

Memory it does not control

A ban lives in the kernel, and kernel memory is not ours to bound. Enforcement is nftables sets with per-element timeouts, which is what makes a ban survive the agent's death — and what puts it outside every limit above. Measured on Linux 6.12, an address in a set costs roughly a hundred bytes of unswappable kernel memory:

active bans kernel memory
100 000 ~10 MB
1 000 000 ~100 MB
5 000 000 ~500 MB

Five million bans were measured at about 460 MB of slab on a machine with 3.8 GB, alongside an agent holding to its own 1.9 GB ceiling: it held, but the two together left little room. On a small machine facing a distributed flood from millions of distinct addresses, this is the limit you will meet first, and neither limits.memory nor systemd's MemoryMax covers it — those bound the agent, not the kernel.

And processor, which is the part that surprises

A set whose elements carry timeouts is walked by the kernel to reclaim what has expired, and that walk grows with the set. Measured on the same machine, with five million elements and the agent itself doing nothing:

kworkers: 26.4% of a core, sustained

A quarter of a core, permanently, on a two-core machine — and it appears in none of the agent's own counters, because it is not the agent's process. Watch it in top as kworker/…-events_power_efficient, which is the queue nftables uses. Flushing the sets took it to 0.1%, which is how it was pinned on them.

The agent therefore declares gc-interval 1h on its sets, and on the same machine that changed everything:

5M elements, machine otherwise idle CPU
default collection interval 17.2% of the machine
gc-interval 1h 1.1%

It costs nothing that matters. An element whose timeout has passed stops matching traffic immediately, collected or not — collection only frees the memory afterwards. So the trade is permanent processor against dead elements lingering in memory a while longer, which under a flood is the right way round.

If that is your risk, the levers are:

  • Ban ranges rather than addresses where the evidence supports it: one element covers a whole prefix.
  • Shorter first rungs in the policy ladder, so a one-off scanner leaves the set sooner. Elements expire on their own timeout; the set drains without anyone doing anything.
  • Kernel guardrails — connection and rate caps, which are dynamic sets bounded by the kernel itself — to absorb volume that would otherwise become one element per source.
  • A longer gc-interval on the sets, the cheapest of them, and it costs nothing an operator cares about: an expired element stops matching traffic the moment its timeout passes, whether or not the collector has reached it. Collecting less often keeps dead elements in memory a little longer; it never delays an unban.
  • More memory and more cores, honestly, if the fleet's traffic warrants holding millions of bans at once.

Watch the memory with slabtop or the Slab: line of /proc/meminfo; it grows with the active set and falls as bans expire.

Never list a set without -t. Any nft listing that reaches the elements — nft list set …, nft list sets, nft list table …, nft list ruleset — walks every one of them and prints it, which at five million takes longer than anything else on the machine and burns a core doing it. nft get element is no escape: on an interval set holding millions it too takes minutes — ask the store instead (shield why answers in milliseconds and knows the history besides). The terse flag omits set contents and answers in ten milliseconds:

nft -t list table inet shieldlist    # chains, rules, counters, set declarations
nft -t list sets                     # set declarations only

There is no cheap way to count elements through nft. shield status answers that from the store instead.

Processor

One core is enough for a busy server: measured at tens of thousands of log lines a second on a two-core virtual machine, with the rules a real web server needs. Under a flood the agent spends its time in the evaluator and in the collector, and it degrades by falling behind on reads — never by making the protected service wait. The shipped systemd unit gives it a processor share (CPUWeight=50, half the default), not a cap: under contention it yields to the service it protects, and when the processor is idle it takes what it needs — a restart restoring hundreds of thousands of bans, a flood being parsed and enforced. A hard CPUQuota rations exactly those moments; if you set one, size it against a restart of your own store, not against idle.

Disk

Sanctions and their evidence are durable, and evidence is what makes shield why able to tell you the story. It is capped at twenty lines a sanction, but the sanctions themselves are not: five million bans with ten convicting lines each measured about 4 GB. Size the state directory for the fleet you expect, and keep an eye on state_size in shield status.