Cgroups

Až doteraz som sa motal v menných priestoroch. Niesu však jedinou technológiou Linuxu, na ktorej kontajneri stoja. Druhou dôležitou technológiou sú kontrolné skupiny nazývané aj Cgroups.

Nie raz sa mi stalo, že si robím svoju prácu na počítači. Zrazu sa spusti nejaký proces. Najčastejšie anti-ehm, veď asi viete. Ten mi začne neúmerne vyťažovať procesor a zje mi všetku pamäť. Ostatné bežiace aplikácie tak nemajú dostatok zdrojov a začnú byť spomalené.

Možno by bolo super, ak by som vedel tento proces nejak izolovať. Veď dnešné procesory disponujú viacerými jadrami. Vedel by som prikázať operačnému systému, aby tento záškodny proces používal len jedno jadro na 50%? Čo tak ešte vedieť obmedziť pamäť, s ktorou tento proces môže pracovať?

A presne o tomto sú Cgroups v Linuxe. Funguje to tak, že si viem vytvoriť skupinu, ktorej nastavím, ako obmedziť systémové zdroje ako CPU, pamäť alebo sieť. Tejto skupine potom priradím ID procesu bežiacej aplikácie.

S Cgroups manipulujem skrz súborový systém. Presne v znamení filozofie Unixu - všetko je súbor. Podľa mňa to je výborné rozhranie a nepotrebujeme sa zapodievať krkolomnými systémovými volaniami. Cgroups podobne ako súborový systém, používajú hierarchickú organizáciu. To znamená, že skupiny môžu obsahovať ďalšie podskupiny.

Kde sa Cgroups nachádzajú? Vo väčšine dnes dostupnej distribúcii Linuxu ich nájdeme namontované ako /sys/fs/cgroup. Obsah tohto adresára sa môže jemne líšiť podľa distribúcie a verzie.

$ ls -1 /sys/fs/cgroup
blkio
cpu
cpu,cpuacct
cpuacct
cpuset
devices
freezer
hugetlb
memory
net_cls
net_cls,net_prio
net_prio
perf_event
pids
rdma
systemd
unified

Tieto podadresáre reprezentujú kontrolery - akési typy zdrojov, ktoré môžeme obmedziť. Až ďalšie podadresáre sú jednotlivé skupiny.

Príklad

Hneď na úvod musím napísať nech mi odpustia všetci Systemd fanúšikovia. Ku Cgroups pristúpim priamo :)

Ako teda obmedzíme nejaký proces, aby bežal len na jednom jadre. Najprv si vytvorím záškodníka - aplikáciu, ktorá sa bude snažiť vyťažiť procesor a všetky jeho jadra na 100%. Aby som ešte s počítačom vedel pracovať tak to bude trvať minútu.

package main

import (
   "fmt" 
   "runtime"
   "time"
)

func main() {
   // ziskam pocet jadier/CPUs v systeme
   n := runtime.NumCPU()
   runtime.GOMAXPROCS(n)
   fmt.Printf("CPUs: %d", n)

   // spustim tolko go-rutin, kolko mam 
   // jadier.
   end := make(chan bool)
   for i := 0; i < n; i++ {
      go func() {
         for {
            select {
            case <-end:
               return
            default:
	             // toto prazdne miesto vytazi CPU
            }
         }
      }()
   }

   // necham to bezat 1 minutu
   time.Sleep(60 * time.Second)
   for i := 0; i < n; i++ {
      end <- true
   }
}

Kód je veľmi jednoduchý. Aplikácia zisti, koľko CPU mám v systéme a vytvorí rovnaký počet go rutín, ktoré bežia v slučke po dobu 60 sekúnd. Spustím si aplikáciu:

$ go run main.go
CPUs: 8

Aplikácia vypíše na výstup počet CPU a počas 60 sekúnd sa pokúsi vyťažiť systém. Na výstupe htop môžem vidieť ako aplikácia zapratá prácou všetky CPU:

part 1

Keďže mám 8 jadier, tento záškodník by mohol bežať len na jednom. K tomu mi poslúži práve Cgroups kontroler cpuset. V ňom vytvorím adresár zaskodnik. To bude Cgroup skupina.

err := os.Mkdir("/sys/fs/cgroup/cpuset/zaskodnik", os.ModePerm)
if err != nil {
	panic(err)
}

Adresár hneď po vytvorení bude obsahovať akési súbory. Práve zapísaním rôznych hodnôt do týchto súborov môžem nastavovať rôzne limity. Každý kontroler má svoje špecifické súbory. V mojom prípade do súborov cpuset.cpus a cpuset.mems zapíšem 0, čo znamená procesy v tejto skupine pobežia na prvom jadre v CPU.

writeToFile("/sys/fs/cgroup/cpuset/zaskodnik/cpuset.cpus", "0")
writeToFile("/sys/fs/cgroup/cpuset/zaskodnik/cpuset.mems", "0")

Nakoniec už len priradím proces do skupiny. To spravím zapísanim jeho ID do súboru tasks.

pid := strconv.Itoa(os.Getpid())
writeToFile("/sys/fs/cgroup/cpuset/zaskodnik/tasks", pid)

Nakoniec ešte dôležitá úprava kódu. Aby aplikácia zafungovala, budem musieť použiť re-exec. Túto techniku som už používal v článku Menné priestory.

A prečo? Totižto ak sa kód záškodníka spusti, začne okupovať hneď všetky jadra. Keď potom získam jeho PID a priradím ho do skupiny, k jeho obmedzeniu nenastane. Linux už bežiacu aplikáciu nebude obmedzovať. Tá už je inicializovaná s 8 jadrami. Práve preto potrebujem re-exec. Nastaviť skupinu, priradiť jej proces a spustiť záškodníka ako podproces. Totiž do skupiny automaticky budú patriť aj všetky podprocesy.

Celý kód tak bude vyzerať následovne:

package main

import (
   "fmt"
   "os"
   "os/exec"
   "runtime"
   "strconv"
   "time"
)

func main() {
   if len(os.Args) <= 1 {
      panic("use run argument")
   }

   switch os.Args[1] {
   case "reexec":
      zaskodnik()
   case "run":
      run()
   default:
      panic("use run argument")
   }
}
  
func run() {
   // najpr si vytvorim skupinu 'zaskodnik' pre
   // cpuset cgroup
   err := os.Mkdir("/sys/fs/cgroup/cpuset/zaskodnik", os.ModePerm)
   if err != nil {
      panic("error create stress cgroup")
   }

   // do spuset.cpus zapisem ze chcem pouzit prve CPU
   // (CPU su indexovane od 0). Alternativa k:
   //
   //    $ echo 0 > /sys/fs/cgroup/cpuset/zaskodnik/cpuset.cpus
   //    $ echo 0 > /sys/fs/cgroup/cpuset/zaskodnik/cpuset.mems
   writeToFile("/sys/fs/cgroup/cpuset/zaskodnik/cpuset.cpus", "0")
   writeToFile("/sys/fs/cgroup/cpuset/zaskodnik/cpuset.mems", "0")

   // priradime aktualny proces skupine `zaskodnik`
   pid := strconv.Itoa(os.Getpid())
   writeToFile("/sys/fs/cgroup/cpuset/zaskodnik/tasks", pid)

   // re-exec aplikacie - tu spustim vlastne kod
   // zaskodnika
   cmd := exec.Command("/proc/self/exe", "reexec")
   cmd.Stdin = os.Stdin
   cmd.Stdout = os.Stdout
   cmd.Stderr = os.Stderr
   cmd.Run()
}
  
// tu vytazime system. Tato funkcia je spustana ako 
// podproces, takze limity sa prejavia 
func zaskodnik() {
   n := runtime.NumCPU()
   runtime.GOMAXPROCS(n)
   fmt.Printf("num CPU:%d", n)

   end := make(chan bool)
   for i := 0; i < n; i++ {
      go func() {
         for {
            select {
            case <-end:
               return
            default:
            }
         }
      }()
   }

   time.Sleep(60 * time.Second)
   for i := 0; i < n; i++ {
      end <- true
   }
}

// pomocna funkcia len otvori subor pre 
// zapis (O_WRONLY) a zapise hodnotu
func writeToFile(path string, value string) {
   file, err := os.OpenFile(path, os.O_WRONLY, 0644)
   if err != nil {
      panic(err)
   }
   defer file.Close()

   _, err = file.WriteString(value)
   if err != nil {
      panic(err)
   }
}

Keď takto upravenú aplikáciu teraz spustím, na výstupe uvidím počet použitých CPU 1 a nie 8 ako na začiatku.

$ go run main.go run
CPUs: 1

Taktiež aj htop mi ukazuje, že len jedno CPU je 100% vyťažené.

part 2

Cgroups a Docker

Super, ale čo s tým má vlastne Docker? Podobne, ako si vieme vytvoriť virtuálnu mašinu s 2GB RAM, vieme povedať aj kontajneru, aby používal obmedzené množstvo pamäte alebo CPU. A to veľmi jednoducho. Stačí spustiť kontajner s prepínačmi --cpu a --memory:

> docker run -it --cpus=".5" --memory="200m" --name myubuntu ubuntu /bin/sh

Tieto parametre sa premietnu v konfigurácii runc. Najpv potrebujem ID kontajnera.

> docker ps --filter "name=myubuntu"
CONTAINER ID   IMAGE     COMMAND     CREATED          STATUS          PORTS     NAMES
328f787cbcb4   ubuntu    "/bin/sh"   26 seconds ago   Up 24 seconds             myubuntu

A teraz si pozriem config.json pre behové prostredie runc:

> cat /run/containerd/io.containerd.runtime.v2.task/moby/328f787cbcb4/config.json | jq .

V konfigurácii tak uvidím meno skupiny a hodnoty, ktoré som zadal.

	...
  "memory": {
    "limit": 209715200,
    ...
  },
  "cpu": {
    "quota": 50000,
    ...
  }
  ...
  "cgroupsPath": "/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13"

Behové prostredie tak vytvorí nielen proces s mennými priestormi, ale tiež priradí proces Cgroups skupine. Poďme si to overiť. Najprv potrebujeme ID procesu, ktorý beží v kontajneri.

> ps -fax | grep "328f787cbcb4" -A 3
 437496 ?        Sl     0:00 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13 -address /run/containerd/containerd.sock
 437531 pts/0    Ss     0:00  \_ /bin/sh

Takže proces má ID 437531. Pamätáte sa na čarovný adresár /proc? Ten mám veľmi rád. Obsahuje všetky možné informácie o bežiacom procese. Aj to, v akých Cgroups skupinách proces beží. Stači sa prozrieť do /proc/437531/cgroup.

> cat /proc/437531/cgroup

12:cpuset:/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13
11:freezer:/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13
10:devices:/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13
9:pids:/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13
8:net_cls,net_prio:/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13
7:cpu,cpuacct:/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13
6:perf_event:/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13
5:hugetlb:/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13
4:blkio:/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13
3:memory:/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13
2:rdma:/
1:name=systemd:/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13
0::/system.slice/containerd.service

Docker vytvoril pre každý kontrolér skupinu /docker/328f787cbcb46..... Zo zvedavosti sa pozriem na nastavenie limitu pamäte:

> cat /sys/fs/cgroup/memory/docker/328f787cbcb465ff930591cb57e6a86d795869f9f02393fe6c2ab74b5f9e8b13/memory.limit_in_bytes

209715200

Voala. Docker neuväznil proces len v menných priestoroch, ale vytvoril pre proces aj Cgroups skupinu, v ktorej nastavil limity zdrojov.

Cgroups v2

Vrátim sa trochu späť k štruktúre. Možno ste si všimli takú jednu nepríjemnosť. Navrhnutá štruktúra sa ukazuje trochu nepraktická. Každá skupina musí byť repetitívne vytvorená pre každý kontroler.

Nebolo by omnoho lepšie mať na najvyššej úrovni priamo skupiny? A v rámci každej skupiny by bolo možné nastavovať všetky podporované kontrolery?

A práve toto je najväčšia zmena, s ktorou prichádza Cgroups v2. Lepšia štruktúra však nieje jediná zmena. Cgroups v2 sú veľmi priateľské k tzv. rootless prístupu,a teda aj vďaka v2 vieme mať rootles kontajneri.

Adaptácia v2 však nešla úplne hladko. Hoci sa v jadre Linuxu nachádza už od roku 2016, pre svet kontajnerov začala mať zmysel až v posledných rokoch. Dlho chýbala podpora dvoch kontrolerov - device a freezer. Prvý kontroler kontajner používa na to, aby sa proces nedostal k zariadeniam a na freezer ste narazili ak ste už použili docker pause. Každopádne Cgroups v2 sú už tu.

Cgroups a Systemd

V príklade som si vytvoril nejakú skupinu zaskodnik, priradil jej proces. Všetko fajn. Ale čo ak dôjde k reštartu počítača? Toto nastavenie je preč.

Chcelo by to nejaký riadiaci prvok, nejaký manažér, ktorý by sa o to staral. A práve túto úlohu na seba prebral Systemd. Systemd je taký ten komponent Linuxu, ktorý sa stará o ostatné procesy a služby, ktoré bežia na pozadí. Stará sa o to, aby sa po naštartovaní systému spustili služby, sleduje či tieto služby bežia, ak nebežia tak ich opäť spúšťa. Rieši závislosti medzi službami a tiež obmedzuje zdroje týmto službám. A práve na to posledné Systemd používa Cgroups.

Docker vs Systemd

A tu nastáva zaujímavý konflikt. Ako som ukazoval na príklade, s Cgroups vieme pracovať skrz súborový systém. Lenže je tu zároveň aj Systemd, ktorý chce Cgroups riadiť. A tak sa vytvorili 2 tábory.

Jeden bol za to, aby Docker si riadil všetko sám, to znamená aj Cgroups priamo cez súborový systém. Tento tábor bol zastúpený prevažne ľuďmi z firmy modrej veľryby.

Druhý tábor sa snažil o hlbšiu integráciu so Systemd. Aby napríklad Docker pristupoval k Cgroups cez Systemd a neobchádzal ho. Obávali sa, že sa Docker stane druhým Systemd. Túto skupinu zase tvorili hlavne ľudia s červenými klobúkmi.

Konflikt priniesol rôzne momenty. Jezz Frazelle je inžinierka, ktorej svet kontajnerov a Kubernetes vďačí za veľa. Na DockerCon 2015 prišla s nápisom “I say no to to systemd specific PRs”. Iným momentom bola prezentácia Dana Walsha v Brne na DevConf.cz, kedy otvorene hovoril o tomto konflikte. Táto prezentácia nenechala chladného ani Solomona, v tej dobe CTO Docker Inc. Reakcia je stále dostupná na lwn.net.

Výsledkom tohto konfliktu je jemná schizofrénia v kóde a v prístupe k Cgroups. Behové prostredie dnes pristupuje k Cgroups v2 skrz Systemd. Behové prostredie však stále implementuje aj Cgroups v1, kde používa prístup cez súborový systém.

Preto je možné, že na mojom a vašom systéme sa kontajner z pohľadu Cgroups bude chovať inač. V mojom prípade Docker a behové prostredie stále používa Cgroups v1 a priamy prístup.

Záver

Hoci Cgroups je z pohľadu implementácie jednoduchý koncept, verzia 2 a rôzne konflitky spôsobili bolesť vo svete vývojárov kontajnerov. Situácia sa však za posledné 2 roky zlepšila. Znalosť ako kontajner manipuluje s Cgroups sa hodí vo viacerých prípadoch. Ja som si konečne spravil jasno v tom, ako Kubernetes nastavuje zdroje. Ujasnil som si, ktoré číslo čo presne znamená.

<