10 min czytania

Dyski VHDX, które ciągle rosną: przyczyny, kompaktowanie i zapobieganie

Dynamiczny dysk VHDX rośnie przy każdym zapisie w nowe miejsce, a usunięcie plików w gościu go nie zmniejsza. Skąd bierze się rozrost, jak odzyskać miejsce przez TRIM i kompaktowanie oraz jak skonfigurować gościa, żeby problem nie wracał.

Rys. 1. Usunięcie plików w gościu nie zmniejsza dysku dynamicznego: bloki zostają w pliku VHDX, a kolejne zapisy zajmują nowe. Miejsce odzyskuje dopiero TRIM/UNMAP z gościa albo kompaktowanie przy wyłączonej maszynie.

Maszyna wirtualna z Linuksem, na której działają kontenery, ma dynamiczny dysk VHDX o pojemności 200 GB. W gościu df -h pokazuje 70 GB zajętości, a plik na hoście ma 190 GB i co tydzień jest większy. Administrator usuwa w gościu stare obrazy i logi, zwalnia 30 GB, ale plik VHDX nie zmniejsza się ani o bajt. Po kilku miesiącach na woluminie z maszynami kończy się miejsce, choć według gości dane zajmują mniej niż połowę dysków.

Dynamiczny dysk VHDX rośnie tylko w jedną stronę, dopóki gość nie poinformuje hosta, które bloki są wolne, albo dopóki ktoś nie skompaktuje pliku. Ten wpis pokazuje, skąd bierze się rozrost, jak odzyskać miejsce i jak skonfigurować gościa, żeby problem nie wracał.

Trzy rozmiary jednego dysku

Dysk stały (Fixed) od chwili utworzenia zajmuje na hoście całą swoją pojemność i nie rośnie. Dysk dynamiczny (Dynamic) startuje jako mały plik i przydziela miejsce blokami, gdy gość po raz pierwszy zapisze coś w danym obszarze.

Przy dysku dynamicznym liczą się trzy wielkości: pojemność widziana przez gościa, rozmiar pliku na hoście i zajętość danych w gościu. Dwie pierwsze pokazuje Get-VHD:

Get-VHD -Path 'D:\VM\srv-app-01\srv-app-01.vhdx' |
    Select-Object VhdFormat, VhdType, Size, FileSize, MinimumSize, BlockSize
PoleZnaczenieNa co uważać
Sizepojemność logiczna widziana przez gościanie mówi nic o miejscu na hoście
FileSizerozmiar pliku na woluminie hostaw dysku dynamicznym sam nie maleje
MinimumSizenajmniejsza pojemność, do której Resize-VHD może zmniejszyć dyskwynika z układu partycji, nie z ilości danych
BlockSizejednostka przydziału w pliku, domyślnie 32 MBdotyczy dysków dynamicznych i różnicowych
VhdTypeFixed, Dynamic albo DifferencingDifferencing to np. plik checkpointu

Trzecią wielkość pokazuje gość: df -h w Linuksie albo Get-Volume w Windows. Zdrowy dysk dynamiczny ma FileSize niewiele większy od zajętości w gościu. Plik bliski Size przy gościu zajętym w jednej trzeciej oznacza, że zwolnione miejsce nie wraca do hosta.

Dlaczego plik rośnie, a nie maleje

Hyper-V nie wie, co gość przechowuje w systemie plików, widzi tylko zapisy do sektorów. Pierwszy zapis w obszarze bez przydzielonego bloku powiększa plik o cały blok. Usunięcie pliku w gościu zmienia wyłącznie metadane systemu plików: host się o tym nie dowiaduje, więc blok w pliku VHDX zostaje.

Gdyby gość zapisywał zawsze w tych samych sektorach, plik zatrzymałby się na poziomie najwyższej zajętości. Systemy plików umieszczają jednak nowe dane w różnych miejscach dysku, a typowe obciążenia stale tworzą nowe pliki i kasują stare:

  • obrazy kontenerów: każda nowa wersja aplikacji to nowe warstwy, a stare usuwa dopiero sprzątanie obrazów;
  • logi: rotacja tworzy nowe pliki, kompresuje i usuwa stare;
  • pliki tymczasowe, pamięć podręczna menedżera pakietów, katalogi robocze buildów CI;
  • aktualizacje: pobrane pakiety, kolejne wersje jądra, w Windows pliki usługi aktualizacji.

Po kilku miesiącach gość zapisał coś w większości obszarów dysku i plik zbliża się do pełnej pojemności, choć zajętość w gościu jest stała.

Osobnym mechanizmem wzrostu są checkpointy. Po utworzeniu checkpointu zapisy trafiają do pliku różnicowego AVHDX, a miejsce odzyskuje się dopiero przez usunięcie checkpointu, czyli scalenie łańcucha. Szczegóły opisuje wpis Checkpoint to nie kopia zapasowa.

Rozmiar bloku: 1 MB dla gości Linux

Przy domyślnym bloku 32 MB zapis 4 KB w nowym obszarze dysku powiększa plik o 32 MB. W gościach Windows ten rozmiar się sprawdza. Systemy plików Linuksa rozkładają metadane i dane po całym dysku (grupy bloków w ext4, grupy alokacji w XFS), więc przy bloku 32 MB plik szybko rośnie mimo niewielkiej ilości danych. Microsoft w zaleceniach dla gości Linux na Hyper-V wskazuje dynamiczny VHDX z blokiem 1 MB:

New-VHD -Path 'D:\VM\srv-app-01\srv-app-01-data.vhdx' -SizeBytes 200GB -Dynamic -BlockSizeBytes 1MB

Bloku istniejącego dysku nie da się zmienić w miejscu. Dysk konwertuje się do nowego pliku i podłącza zamiast starego. Konwersja wymaga wyłączonej maszyny bez checkpointów i wolnego miejsca na nowy plik:

$old = 'D:\VM\srv-app-01\srv-app-01.vhdx'
$new = 'D:\VM\srv-app-01\srv-app-01-1mb.vhdx'

Stop-VM -Name srv-app-01
Convert-VHD -Path $old -DestinationPath $new -VHDType Dynamic -BlockSizeBytes 1MB

# Kontroler i pozycję dysku podaje Get-VMHardDiskDrive -VMName srv-app-01
Set-VMHardDiskDrive -VMName srv-app-01 -ControllerType SCSI -ControllerNumber 0 -ControllerLocation 0 -Path $new
Start-VM -Name srv-app-01

Stary plik usuwa się dopiero po sprawdzeniu, że maszyna startuje z nowego dysku i ma komplet danych.

TRIM i UNMAP: gość zgłasza wolne bloki

TRIM (ATA) i UNMAP (SCSI) to polecenia, którymi system informuje dysk, że zakres sektorów nie zawiera już danych. Hyper-V przyjmuje je od gościa, oznacza odpowiadające im bloki VHDX jako nieużywane i przekazuje informację do magazynu hosta. Warunki po stronie maszyny:

  • dysk w formacie VHDX; format VHD nie obsługuje UNMAP i trzeba go przekonwertować;
  • dysk na wirtualnym kontrolerze SCSI. Maszyny generacji 2 mają tylko SCSI, w generacji 1 dysk systemowy leży na kontrolerze IDE.

W gościu Linux TRIM wysyła fstrim. W wielu dystrybucjach fstrim.timer jest włączony domyślnie i działa raz w tygodniu, w innych trzeba go włączyć:

# TRIM wszystkich zamontowanych systemów plików, które go obsługują, z raportem
sudo fstrim -av
# Cotygodniowy TRIM uruchamiany przez systemd
sudo systemctl enable --now fstrim.timer

Gość Windows domyślnie wysyła powiadomienia o usuniętych danych, a zaplanowane zadanie optymalizacji dysków co tydzień ponawia TRIM. Ustawienie sprawdza fsutil behavior query DisableDeleteNotify (0 oznacza włączone powiadomienia), a ręcznie TRIM wywołuje:

Optimize-Volume -DriveLetter C -ReTrim -Verbose

TRIM zatrzymuje wzrost, bo zwolnione bloki mogą zostać użyte ponownie. Żeby zmniejszyć sam plik, trzeba go skompaktować.

Kompaktowanie: zmniejszenie pliku

Optimize-VHD zmniejsza plik dynamicznego lub różnicowego dysku, usuwając nieużywane bloki; na dyskach stałych nie działa. Dysk nie może być używany przez działającą maszynę, a w trybach Quick i Full musi być podłączony do hosta tylko do odczytu. Tryb Full dodatkowo wyszukuje bloki wypełnione zerami.

Skuteczność zależy od systemu plików w gościu. Gdy na dysku jest NTFS, host odczyta z niego informację o wolnym miejscu. Systemów ext4 i XFS Windows nie odczyta, więc przy gościu Linux odzyskuje się bloki oznaczone wcześniej przez TRIM i bloki wyzerowane. Dlatego tuż przed wyłączeniem maszyny z Linuksem wykonuje się sudo fstrim -av.

$vm   = 'srv-app-01'
$vhdx = 'D:\VM\srv-app-01\srv-app-01.vhdx'

Stop-VM -Name $vm                       # zamknięcie systemu gościa
$before = (Get-VHD -Path $vhdx).FileSize
Mount-VHD -Path $vhdx -ReadOnly         # tryb Full wymaga podłączenia tylko do odczytu
Optimize-VHD -Path $vhdx -Mode Full     # usuwa nieużywane i wyzerowane bloki
Dismount-VHD -Path $vhdx
Start-VM -Name $vm
'{0:N1} GB -> {1:N1} GB' -f ($before / 1GB), ((Get-VHD -Path $vhdx).FileSize / 1GB)

Zapobieganie

Kompaktowanie odzyskuje miejsce jednorazowo. Żeby plik nie wracał do poprzedniego rozmiaru, gość musi regularnie wysyłać TRIM i nie może bez ograniczeń gromadzić danych, które i tak zostaną usunięte.

Dziennik systemowy. journald domyślnie zajmuje do 10% systemu plików, najwyżej 4 GB. Niższy limit ustawia plik drop-in, zmiana działa po sudo systemctl restart systemd-journald, a obecny rozmiar pokazuje journalctl --disk-usage:

[Journal]
SystemMaxUse=1G

Logi aplikacji. Aplikacje piszące do własnych plików potrzebują reguły logrotate z limitem liczby i rozmiaru plików:

/var/log/aplikacja/*.log {
    daily
    rotate 14
    maxsize 200M
    compress
    delaycompress
    missingok
    notifempty
    # dla aplikacji, które nie potrafią ponownie otworzyć pliku logu
    copytruncate
}

Obrazy kontenerów. Kubelet usuwa nieużywane obrazy, gdy zajętość systemu plików z obrazami przekroczy imageGCHighThresholdPercent (domyślnie 85%), i sprząta do imageGCLowThresholdPercent (domyślnie 80%). Niższe progi zostawiają mniej starych warstw. W k3s ustawia się je argumentami kubeleta:

kubelet-arg:
  - "image-gc-high-threshold=75"
  - "image-gc-low-threshold=60"

Na hostach budujących obrazy nieużywane obrazy usuwa uruchamiane z harmonogramu podman image prune -a lub docker image prune -a, a na węźle Kubernetes jednorazowo crictl rmi --prune.

Monitorowanie. Minimum to alert na wolne miejsce na woluminie z maszynami, np. poniżej 15%. Wcześniejszy sygnał daje drugi skrypt z sekcji „Sprawdź u siebie”, uruchamiany z Harmonogramu zadań i wysyłający do monitoringu możliwy wzrost dysków na każdym woluminie. Plik, którego FileSize rośnie co tydzień przy stałej zajętości w gościu, wskazuje maszynę bez działającego TRIM.

Objaw, przyczyna, działanie

ObjawPrzyczynaCo zrobić
FileSize bliski Size, a df -h w gościu pokazuje 30–40%gość nie wysyła TRIMwłączyć fstrim.timer, wykonać fstrim -av, skompaktować
lsblk --discard pokazuje 0 w DISC-GRAN i DISC-MAXformat VHD albo kontroler IDEkonwersja do VHDX, dysk z danymi na kontroler SCSI
Nowy gość Linux z kilkoma GB danych ma wielokrotnie większy plikblok 32 MBnowy dysk z blokiem 1 MB albo Convert-VHD
Obok VHDX rosną pliki .avhdxcheckpoint lub pozostałość po zadaniu kopiiusunąć checkpoint przez Hyper-V, nie kasować plików ręcznie
Optimize-VHD przy gościu Linux prawie nic nie odzyskujehost nie odczytuje ext4 ani XFSprzed wyłączeniem maszyny fstrim -av, potem kompaktowanie

Sprawdź u siebie

Dyski maszyn na hoście z typem, kontrolerem, blokiem i stosunkiem rozmiaru pliku do pojemności:

Get-VM | Get-VMHardDiskDrive | ForEach-Object {
    $vhd = Get-VHD -Path $_.Path
    [pscustomobject]@{
        VM          = $_.VMName
        Kontroler   = $_.ControllerType
        Typ         = $vhd.VhdType
        BlokMB      = $vhd.BlockSize / 1MB
        PojemnoscGB = [math]::Round($vhd.Size / 1GB)
        PlikGB      = [math]::Round($vhd.FileSize / 1GB, 1)
        PlikProcent = [math]::Round(100 * $vhd.FileSize / $vhd.Size)
    }
} | Sort-Object PlikProcent -Descending | Format-Table -AutoSize

Dobry wynik: dyski gości Linux z blokiem 1 MB, na kontrolerze SCSI, z PlikProcent zbliżonym do zajętości w gościu. Zły wynik: PlikProcent powyżej 90 przy gościu zajętym w połowie, BlokMB równy 32 przy gościu Linux albo dysk z danymi na IDE. Wiersz Differencing to checkpoint, a jego PlikGB to rozmiar tylko ostatniego pliku łańcucha.

Wolne miejsce na woluminach i to, ile dyski dynamiczne mogą jeszcze urosnąć. Skrypt zakłada woluminy z literą dysku; dla woluminów CSV trzeba go dostosować:

Get-VM | Get-VMHardDiskDrive | ForEach-Object { Get-VHD -Path $_.Path } |
    Group-Object { (Split-Path $_.Path -Qualifier).TrimEnd(':') } |
    ForEach-Object {
        $vol    = Get-Volume -DriveLetter $_.Name
        $growth = ($_.Group | Where-Object VhdType -ne 'Fixed' |
                   ForEach-Object { $_.Size - $_.FileSize } | Measure-Object -Sum).Sum
        [pscustomobject]@{
            Wolumin         = $_.Name
            WolneGB         = [math]::Round($vol.SizeRemaining / 1GB)
            WolneProcent    = [math]::Round(100 * $vol.SizeRemaining / $vol.Size)
            MozliwyWzrostGB = [math]::Round($growth / 1GB)
        }
    } | Format-Table -AutoSize

Dobry wynik: WolneGB większe niż MozliwyWzrostGB albo świadomy nadprzydział z alertem. Zły wynik: WolneProcent poniżej 15 albo możliwy wzrost kilkukrotnie większy od wolnego miejsca bez alertu.

W gościu Linux: czy dyski przyjmują TRIM, czy timer działa i jaka jest zajętość:

lsblk --discard                    # DISC-GRAN i DISC-MAX większe od 0 = dysk przyjmuje TRIM
systemctl status fstrim.timer      # oczekiwany stan: active (waiting)
systemctl list-timers fstrim.timer # ostatnie i następne uruchomienie
df -h                              # zajętość systemów plików w gościu

Dobry wynik: niezerowe DISC-GRAN i DISC-MAX, timer active (waiting) z ostatnim uruchomieniem w ciągu tygodnia, zajętość z df -h zbliżona do PlikProcent. Zły wynik: zera w kolumnach DISC, timer nieaktywny lub nieznany, zajętość w gościu wyraźnie niższa niż PlikProcent.