Späť na blog

Linux Page Cache Thrashing v Kontajneroch: Keď Voľná Pamäť Nie Je Voľná

Na papieri sme mali pamate dost a aj tak sme rozmlatili page cache. “Kontajner má 2GB memory limit a používa len 500MB. Prečo je pomalý?” Pretože zvyšných 1.5GB je page cache, a vaše file I/O vytláča kód vašej aplikácie z pamäte.

Testované na: Linux 5.15, cgroups v2, Kubernetes 1.28, Go aplikácia so spracovaním súborov

Pochopenie Page Cache v Kontajneroch

Čo Je Page Cache

Linux Memory Model:

┌─────────────────────────────────────────────────────────────┐
│                    Container Memory Limit                    │
├─────────────────────────────────────────────────────────────┤
│  Application Memory (anonymous pages)                        │
│  - Heap alokácie                                             │
│  - Stack                                                     │
│  - mmap'd pamäť                                             │
├─────────────────────────────────────────────────────────────┤
│  Page Cache (file-backed pages)                              │
│  - Cached čítania súborov                                    │
│  - Memory-mapped súbory                                      │
│  - Executable code pages (z bináriek)                        │
└─────────────────────────────────────────────────────────────┘

Oboje sa počíta proti cgroup memory limitu!

Problém

Scenár: Kontajner s 2GB limitom spracovávajúci súbory

1. Aplikácia štartuje: 500MB anonymous memory
2. Prečítaj 100MB súbor: 500MB app + 100MB page cache = 600MB
3. Prečítaj 1GB súborov: 500MB app + 1000MB cache = 1500MB
4. Prečítaj ďalšie súbory: Cache sa naplní na 1500MB

Teraz na limite: 500MB app + 1500MB cache = 2000MB

5. Prečítaj ďalší súbor:
   - Kernel potrebuje niečo evictovať
   - Application code pages sú "inactive"
   - Code pages evictované pre miesto pre file cache!

6. Aplikácia spustí kód z evictovanej page:
   - Major page fault
   - Čítanie z disku pre reload kódu
   - Výkon sa prepadne

File I/O vytlačilo tvoj vlastný executable!

Demonštrácia

Test Program

// page_cache_demo.go
package main

import (
    "fmt"
    "io"
    "os"
    "runtime"
    "syscall"
    "time"
)

func main() {
    // Vynúť GC a vypísať memory stats
    printMemStats("Start")

    // Simuluj spracovanie súborov
    for i := 0; i < 100; i++ {
        processLargeFile(fmt.Sprintf("/data/file_%d.bin", i))
        if i%10 == 0 {
            printMemStats(fmt.Sprintf("Po %d súboroch", i))
            measureCodePageFaults()
        }
    }
}

func processLargeFile(path string) {
    f, _ := os.Open(path)
    defer f.Close()
    io.Copy(io.Discard, f)  // Prečítaj celý súbor
}

func printMemStats(label string) {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)

    // Prečítaj cgroup memory stats
    current, _ := os.ReadFile("/sys/fs/cgroup/memory.current")
    limit, _ := os.ReadFile("/sys/fs/cgroup/memory.max")

    fmt.Printf("[%s] Heap: %dMB, Cgroup: %s/%s\n",
        label, m.HeapAlloc/1024/1024,
        string(current[:len(current)-1]),
        string(limit[:len(limit)-1]))
}

func measureCodePageFaults() {
    var rusage syscall.Rusage
    syscall.Getrusage(syscall.RUSAGE_SELF, &rusage)
    fmt.Printf("  Major faults: %d, Minor faults: %d\n",
        rusage.Majflt, rusage.Minflt)
}

Výsledky

# Spusti v kontajneri s 512MB limitom
docker run --memory=512m -v $(pwd)/data:/data page-cache-demo

[Start] Heap: 2MB, Cgroup: 15728640/536870912
  Major faults: 0, Minor faults: 1542
[Po 10 súboroch] Heap: 2MB, Cgroup: 524288000/536870912  # Blízko limitu!
  Major faults: 0, Minor faults: 2341
[Po 20 súboroch] Heap: 2MB, Cgroup: 536870912/536870912  # Na limite!
  Major faults: 145, Minor faults: 8923                    # Thrashing začína
[Po 30 súboroch] Heap: 2MB, Cgroup: 536870912/536870912
  Major faults: 892, Minor faults: 15234                   # Severe thrashing

# App pamäť je len 2MB ale kontajner je na 512MB limite
# Major faults = code pages sa znovu načítavajú z disku

Riešenia

1. Direct I/O (Obídenie Page Cache)

// Použi O_DIRECT pre čítanie veľkých súborov
import "golang.org/x/sys/unix"

func processWithDirectIO(path string) error {
    fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECT, 0)
    if err != nil {
        return err
    }
    defer unix.Close(fd)

    // Buffer musí byť zarovnaný pre O_DIRECT
    bufSize := 4096 * 256  // 1MB zarovnané
    buf := make([]byte, bufSize+4096)
    alignedBuf := buf[4096-int(uintptr(unsafe.Pointer(&buf[0]))%4096):][:bufSize]

    for {
        n, err := unix.Read(fd, alignedBuf)
        if n == 0 || err != nil {
            break
        }
        // Spracuj dáta...
    }
    return nil
}

2. Povedz Kernelu Nech Dropne Cache

import "golang.org/x/sys/unix"

func processAndDropCache(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    // Spracuj súbor
    io.Copy(io.Discard, f)

    // Povedz kernelu že toto už nebudeme potrebovať
    fd := int(f.Fd())
    fi, _ := f.Stat()
    unix.Fadvise(fd, 0, fi.Size(), unix.FADV_DONTNEED)

    return nil
}

3. Streaming s Malými Buffermi

// Nečítaj celý súbor naraz
func processStreaming(path string, chunkSize int) error {
    f, _ := os.Open(path)
    defer f.Close()

    buf := make([]byte, chunkSize)  // 64KB chunky
    for {
        n, err := f.Read(buf)
        if n == 0 || err != nil {
            break
        }
        processChunk(buf[:n])

        // Periodicky povedz kernelu
        if shouldDropCache() {
            fd := int(f.Fd())
            pos, _ := f.Seek(0, io.SeekCurrent)
            unix.Fadvise(fd, 0, pos, unix.FADV_DONTNEED)
        }
    }
    return nil
}

4. Kubernetes Memory Konfigurácia

# Daj rezervu pre page cache
apiVersion: v1
kind: Pod
spec:
  containers:
    - name: file-processor
      resources:
        requests:
          memory: "512Mi"
        limits:
          memory: "1Gi"  # 2x request pre page cache rezervu
      env:
        # Povedz appke jej skutočný memory budget
        - name: APP_MEMORY_LIMIT
          value: "512Mi"  # App by mala použiť len polovicu

---
# Alebo použi memory.high pre soft limiting (cgroups v2)
apiVersion: v1
kind: Pod
metadata:
  annotations:
    # Soft limit na 512Mi, hard limit na 1Gi
    # Kernel bude reclaim cache agresívnejšie
    memory.high: "512Mi"

5. Memory.high pre Soft Limity

# cgroups v2: memory.high spustí reclaim pred dosiahnutím limitu

# Skontroluj či cgroups v2
cat /proc/mounts | grep cgroup2

# Nastav soft limit
echo 512M > /sys/fs/cgroup/memory.high
echo 1G > /sys/fs/cgroup/memory.max

# Správanie:
# - Pod memory.high: Normálna operácia
# - Nad memory.high: Agresívny page cache reclaim
# - Na memory.max: OOM killer ak potrebné

# Application code pages menej pravdepodobne evictované
# Page cache evictovaný prvý keď nad memory.high

Monitoring

Detekcia Page Cache Thrashingu

# Vnútri kontajnera
cat /sys/fs/cgroup/memory.stat

# Kľúčové metriky:
# file: bajty page cache
# anon: bajty anonymous memory
# pgmajfault: major page faulty (čítania z disku pre evictované pages)
# pgscan: pages skenované pre reclaim
# pgsteal: pages reclaimed

# Vysoké pgmajfault + vysoké pgscan = thrashing

Prometheus Metriky

# cadvisor exponuje tieto
container_memory_rss                    # Anonymous memory
container_memory_cache                  # Page cache
container_memory_mapped_file            # mmap'd súbory
container_memory_working_set_bytes      # "Skutočné" použitie

# Alert na cache tlak
- alert: ContainerPageCacheThrashing
  expr: |
    rate(container_memory_pgmajfault_total[5m]) > 100
  for: 5m
  annotations:
    summary: "Kontajner {{ $labels.container }} thrashuje"

Pochopenie Memory Metrík

# Čo zvyčajne vidíš
container_memory_usage_bytes  # Zahŕňa page cache

# Čo záleží pre OOM
container_memory_working_set_bytes  # Anon + aktívny cache

# Page cache špecificky
container_memory_cache

# Tvoja skutočná aplikačná pamäť
container_memory_rss - container_memory_mapped_file

# Indikátor thrashingu
rate(container_memory_pgmajfault_total[5m])

Bežné Vzory

Problém Log Rotácie

Problém: Logovanie do súborov plní page cache

┌─────────────────────────────────────────────────────────────┐
│ App píše logy → Page cache rastie → Evictuje app kód        │
│                                                              │
│ Riešenie: Loguj na stdout, nechaj container runtime         │
│           Alebo použi O_DIRECT pre log súbory               │
└─────────────────────────────────────────────────────────────┘

Spracovanie Temp Súborov

// Zlé: Vytvorí obrovský temp súbor, naplní cache
func processBad(data []byte) {
    tmp, _ := os.CreateTemp("", "process-*")
    tmp.Write(data)
    tmp.Seek(0, 0)
    // Spracuj zo súboru...
    // Page cache teraz drží celý temp súbor
}

// Dobré: Použi pamäť priamo alebo dropni cache
func processGood(data []byte) {
    tmp, _ := os.CreateTemp("", "process-*")
    tmp.Write(data)
    tmp.Sync()
    unix.Fadvise(int(tmp.Fd()), 0, int64(len(data)), unix.FADV_DONTNEED)
    // Alebo len spracuj v pamäti ak sa zmestí
}

Databáza v Kontajneri

# PostgreSQL page cache management
apiVersion: v1
kind: Pod
spec:
  containers:
    - name: postgres
      resources:
        limits:
          memory: "4Gi"  # Celkový limit
      env:
        # PostgreSQL shared_buffers (managed cache)
        - name: POSTGRES_SHARED_BUFFERS
          value: "1GB"  # Len 25% limitu
        # Necháva 3GB pre OS page cache + connections

Checklist

## Page Cache Management

### Detekcia
- [ ] Monitoruj container_memory_cache
- [ ] Alert na pgmajfault rate
- [ ] Kontroluj memory.stat pre cache vs anon pomer

### Prevencia
- [ ] Použi FADV_DONTNEED pre čítanie veľkých súborov
- [ ] Zváž O_DIRECT pre sekvenčné I/O
- [ ] Streamuj súbory namiesto čítania celých
- [ ] Daj 2x memory rezervu pre file-heavy workloady

### Konfigurácia
- [ ] Nastav memory.high (cgroups v2) pre soft limiting
- [ ] Konfiguruj app memory budget separátne od limitu
- [ ] Loguj na stdout namiesto súborov

### Architektúra
- [ ] Presun spracovanie súborov do dedikovaných podov
- [ ] Použi externé object storage namiesto lokálnych súborov
- [ ] Zváž tmpfs pre temp súbory (počíta sa k pamäti tak či tak)

Záver

Page cache nie je “voľná” pamäť v kontajneroch:

  1. File I/O plní page cache počítaný proti tvojmu limitu
  2. App code pages môžu byť evictované spôsobujúc major faulty
  3. Použi FADV_DONTNEED na uvoľnenie cache po čítaní
  4. Daj 2x memory rezervu pre file-heavy workloady

Monitoruj pgmajfault - keď vyskočí, thrashuje.


Súvisiace články

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Linux Page Cache Thrashing v Kontajneroch: Keď Voľná Pamäť Nie Je Voľná". https://www.michal-drozd.com/sk/blog/kontajner-page-cache-thrashing/ (Publikované 6. augusta 2025).