eBPF CPU-kosten met ongeveer 90% verlagen via memoisatie (Geen AI)

Een paar weken geleden heb ik de eBPF-code geprofileerd en ontdekt dat het duurste onderdeel van de beveiliging niet het daadwerkelijk handhaven van een beleid (toestaan/weigeren) is, maar het bepalen van welk beleid van toepassing is op een specifieke bestandsopening.

Onze beleidsregels zijn gebaseerd op paden, dus maakt onze eBPF gebruik van een LSM-hook die wordt geactiveerd bij het openen van een bestand. We reconstrueren vervolgens het pad, lopen omhoog via de ouder-dentries en controleren of het bestand of een overkoepelende map een overeenkomstig beleid heeft. Hoewel dit werkt, is het niet performant; we herhalen veel werk voor bestanden die we al eerder hebben gezien (bijvoorbeeld database-toegangen die herhaaldelijk dezelfde bestandspaden benaderen).

daarom cachen we welk beleid van toepassing is voor elke inode. Dit verminderde onze CPU-kosten in de kernel met ongeveer 90%.

Daarnaast hebben we onlangs onze repo open source gemaakt, dus alles in dit blogbericht is te vinden op https://github.com/bomfather/agent.

Het probleem

Vóór de implementatie van de cache zou elke bestandsopening het volledige pad doorlopen. De flow zag er als volgt uit:

  1. Haal het bestandspad op.
  2. Loop het bestandspad omhoog met dentries.
  3. Controleer op elk niveau of er een beleid bestaat voor het pad.
  4. Combineer de resultaten om tot een definitief beleid te komen, dat gebruikt kan worden om te beslissen of toegang wordt toegestaan of geweigerd.

Dit werkt, maar als hetzelfde bestand meerdere keren wordt geopend of als meerdere bestanden in dezelfde submap worden geopend, moeten we deze stappen voor elk bestand herhalen.

Voorbeeld: In Postgres willen we bijvoorbeeld dat Postgres alleen toegang heeft tot /var/lib/postgres. We kunnen dan dit voorbeeldbeleid hanteren:

policies:
  - executable: "filepath = /usr/lib/postgresql/16/bin/postgres"
    can_access_dirs:
      - "/var/lib/postgres:read"

Vervolgens haalt Postgres bestanden op uit var/lib/postgres/data/base/123, var/lib/postgres/data/base/234 en var/lib/postgres/data/base/345. We zouden het volledige pad van dentries voor elk van deze bestandstoegangen moeten doorlopen, wat zeer inefficiënt is.

In de rest van dit blogbericht noem ik deze inefficiënte pad-loop "het langzame pad" (the slow path).

Wat zit er in de cache?

Onze oplossing is het gebruik van een cache. We moeten er echter zeker van zijn dat de cache niet te zwaar is en dat het veilig is om gecachte items te hergebruiken.

We hebben overwogen om dentries te gebruiken, maar dentries zijn pointers, en pointers kunnen niet worden opgeslagen in eBPF-maps. Als we dentries wilden gebruiken, zouden we de inhoud van de dentries in een struct kunnen opslaan en die struct als map-sleutel kunnen gebruiken, maar dat zou een vrij zware struct worden.

In plaats daarvan hebben we besloten een inode-gebaseerde cache te gebruiken. Onze cachesleutel bevat drie velden: de mount namespace ID, de mount ID en het inode-nummer.

We kunnen de inode niet op zichzelf cachen, omdat inode-nummers uniek zijn voor een specifieke mount-tree (als een beleid meerdere mount-trees beslaat, zouden inodes kunnen overlappen). De mount ID helpt ons te identificeren via welke gemounte tree we het bestand hebben waargenomen. De mount namespace ID voorkomt daarnaast dat we gecachte vermeldingen gebruiken in een andere namespace.

De cachewaarde bestaat uit twee delen: een accessindex en een cache-status. We slaan onze beleidsregels op als bitmasks voor maximale ruimte-efficiëntie, waarbij de accessindex de bitpositie is voor het padbeleid.

Onze cache, inclusief de sleutels en waarden, ziet er als volgt uit:

#define INODE_POLICY_CACHE_NO_POLICY 0
#define INODE_POLICY_CACHE_ACCESS_INDEX 1
#define INODE_POLICY_CACHE_GLOBAL_READ_ONLY 2
#define INODE_POLICY_CACHE_ACCESS_INDEX_AND_GLOBAL_RO 3

struct inode_cache_key {
    u64 mntns_id;
    u64 mount_id;
    u64 inode;
};

struct inode_policy_cache_value {
    u32 access_index;
    u8 state;
};

struct {
    __uint(type, BPF_MAP_TYPE_LRU_HASH);
    __uint(max_entries, 10000);
    __type(key, struct inode_cache_key);
    __type(value, struct inode_policy_cache_value);
} bomfather_inode_policy_cache SEC(".maps");

Met de cache ziet onze flow er nu als volgt uit:

  1. We bouwen de cachesleutel.
  2. We zoeken de sleutel op in de LRU hash map.
  3. Bij een hit kunnen we de bestandsopening afhandelen op basis van het gecachte resultaat.
  4. Bij een miss volgen we het langzame pad en slaan we het resultaat op in de cache.

Prestatieveranderingen

In onze benchmarktests hebben we hetzelfde bestand 200.000 keer geopend om de prestaties te analyseren. De cache verminderde de kernel-cycli van 28 miljard naar 3,03 miljard. Zonder de cache verscheen tailcallsecuritycheck in 89,2% van de tijd op de stack, isrestrictedfilepath in 81,9% en pathcheck_callback in 63,7%.

In de flamegraphs (onderstaande referentie) is te zien dat de kosten van de pad-traversering met de cache vrijwel verdwijnen na de eerste lookup. Zo krimpen isrestrictedfilepath en pathcheckcallback elk tot ongeveer 0,02%, wat klein genoeg is om effectief uit de grafiek te verdwijnen.

  • Voor (zonder cache): [Referentie naar flamegraph]
  • Na (met cache): [Referentie naar flamegraph]

We hebben de kernel CPU geprofileerd met perf met het cycles:k event. Dit meet de CPU-kosten aan de kernelzijde tijdens bestandsopeningen.

Uitzonderingen (Edge Cases)

Een punt waar we rekening mee moesten houden is dat meerdere paden een enkele inode kunnen delen. Hardlinks zijn het eenvoudigste voorbeeld; bij een hardlink kunnen twee verschillende paden dezelfde inode delen. Dit is een groot probleem, omdat nauwkeurige resultaten belangrijker zijn dan cache-prestaties.

Onze oplossing is eerder een workaround dan een volledige oplossing. Inodes hebben een link-teller (i_nlink) die ons vertelt hoeveel paden naar de inode wijzen. We kunnen dit uitlezen, en als deze waarde groter is dan 1, gebruiken we die cache-vermelding niet en vallen we terug op het langzame pad.

if (BPF_CORE_READ_INTO(&nlink, inode, i_nlink)) {
    return false;
}

if (nlink != 1) {
    inode_cache_stats_inc(INODE_CACHE_STATS_SKIPS_NLINK);
    return false;
}

Dit is een compromis, aangezien we een deel van de cache-dekking opgeven, maar ik denk dat dit geen groot probleem is omdat een nauwkeurige cache het belangrijkste is.

Slotbeschouwingen

Uiteindelijk was dit een erg leuk project om aan te werken, omdat ik verschillende ideeën voor de cache moest testen voordat ik bij deze oplossing uitkwam.

Ik ben ook erg blij dat de cache volledig intern is, waardoor het beleid van een gebruiker niet hoeft te worden aangepast om de agent te versnellen!