Главная/Observability и диагностика/Урок

13.4. Дисковое пространство и очистка

Цели

После этого материала вы сможете:

  • объяснить, почему сумма размеров образов больше занятого места;
  • найти расход, который docker system df не показывает вовсе;
  • определить, какой container пишет в свою файловую систему, и сколько;
  • очистить каждую категорию отдельно, зная, что именно удаляется;
  • назвать команду, удаляющую данные безвозвратно, и условия, при которых она это делает;
  • настроить автоматические ограничения, чтобы очистка не требовалась.

Предварительные знания

Ключевые термины

ТерминОбъяснение
SHARED SIZEЧасть образа, общая с другими образами
RECLAIMABLEОценка освобождаемого места
writable layerСлой container'а, куда идёт запись
build cacheКэш промежуточных результатов сборки
danglingОбраз без тега, не являющийся родителем другого
pruneУдаление неиспользуемых объектов

Теория

Шесть категорий расхода

КатегорияГде лежитВиден в system df
Образыoverlay2/Да
Слои container'овoverlay2/Да (Containers)
Volumesvolumes/Да
Build cachebuildkit/Да
Логи container'овcontainers/*/*-json.logНет
Метаданные, tmpcontainers/, tmp/Нет

Пятая строка — главный практический вывод урока. docker system df не учитывает логи. При отсутствии ротации (урок 13.1) они становятся крупнейшей категорией, а отчёт показывает скромные числа.

Типичный разговор: «docker system df говорит 12 ГБ, а каталог занимает 58 ГБ». Разница — логи.

Почему сумма размеров образов обманывает

bash
docker images

Колонка SIZE показывает полный размер образа, включая общие с другими образами слои. Три образа на базе python:3.13-slim покажут по 130 МБ каждый — но общего места займут около 150 МБ, а не 390.

bash
docker system df -v

Здесь появляется SHARED SIZE — сколько из размера образа разделяется с другими. Реальный вклад образа = UNIQUE SIZE.

КолонкаСмысл
SIZEПолный размер со всеми слоями
SHARED SIZEЧасть, общая с другими образами
UNIQUE SIZEОсвободится при удалении этого образа

Планировать очистку нужно по UNIQUE SIZE: удаление образа с SHARED 120MB / UNIQUE 4MB освободит четыре мегабайта.

Что RECLAIMABLE считает освобождаемым

Для образов: те, на которые не ссылается ни один container — включая остановленные.

Отсюда частая неожиданность: RECLAIMABLE показывает ноль, хотя образов много. Причина — остановленные container'ы, удерживающие их. Сначала удаляют container'ы, затем образы.

Категории prune и что именно удаляется

КомандаУдаляетОпасность
docker container pruneОстановленные container'ыНизкая: данные в volumes остаются
docker image pruneОбразы без тега (dangling)Низкая
docker image prune -aВсе образы без container'аСредняя: повторное скачивание
docker builder pruneBuild cacheНизкая: замедлит следующую сборку
docker network pruneНеиспользуемые сетиНизкая
docker volume pruneНеиспользуемые volumesВЫСОКАЯ: потеря данных
docker system pruneВсё выше, кроме volumes и всех образовСредняя
docker system prune -a --volumesВсёВЫСОКАЯ

Две строки требуют отдельного внимания.

docker volume prune удаляет volumes, не подключённые ни к одному container'у. Если база данных была в анонимном volume, а container удалён — данные исчезнут. Восстановления нет.

docker system prune по умолчанию volumes не трогает. Это разумная защита, и её снимает флаг --volumes. Команду docker system prune -a --volumes не следует выполнять «для очистки» без понимания, что она удалит.

Выборочная очистка через фильтры

bash
docker image prune -a --filter 'until=168h'
docker builder prune --filter 'until=72h'
docker container prune --filter 'until=24h'
docker image prune -a --filter 'label!=keep=true'

Фильтр until считает от времени создания, а не последнего использования. Образ, созданный год назад и используемый ежедневно, под until=168h попадёт — если на него не ссылается container.

Более надёжный подход — метки:

dockerfile
LABEL keep=true
bash
docker image prune -a --filter 'label!=keep=true'

Кто пишет в свою файловую систему

bash
docker ps -s

Колонка SIZE показывает два числа:

text
SIZE
124MB (virtual 1.2GB)
ЧислоСмысл
ПервоеЗаписано в writable layer этим container'ом
virtualПлюс размер образа

Первое число — то, что интересно: сколько container записал сверх образа. Значение в сотни мегабайт означает, что приложение пишет в файловую систему container'а вместо volume (урок 7.1).

Предупреждение: docker ps -s медленная. Она обходит файловые системы всех container'ов; на большом хозяйстве занимает десятки секунд.

Автоматические ограничения

Лучше не убирать, а не сорить. Три настройки в /etc/docker/daemon.json:

json
{
  "log-driver": "local",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  },
  "builder": {
    "gc": {
      "enabled": true,
      "defaultKeepStorage": "10GB",
      "policy": [
        {"keepStorage": "10GB", "filter": ["unused-for=168h"]},
        {"keepStorage": "20GB", "all": true}
      ]
    }
  }
}
НастройкаЧто ограничивает
log-optsЛоги — категорию, невидимую в system df
builder.gcBuild cache — обычно вторую по величине
Явные volumes вместо анонимныхПредотвращает накопление осиротевших

Первые две закрывают большую часть проблемы без ручных действий.


Внутренний механизм

Почему удаление образа не освобождает место сразу

Слои общие. Удаление образа убирает ссылку на слои; сами слои удаляются, только когда на них не осталось ссылок ни от образов, ни от container'ов.

Отсюда наблюдение: docker rmi крупного образа освободил 20 МБ вместо 800. Остальное — общие слои, используемые другими образами.

Почему логи не попадают в system df

docker system df опрашивает подсистемы образов, container'ов, volumes и билдера. Логи не относятся ни к одной: это файлы, создаваемые logging driver рядом с метаданными container'а.

Проверить их объём можно только напрямую:

bash
sudo du -sh /var/lib/docker/containers/*/ | sort -h | tail

Команды и примеры

Полная картина расхода

bash
mkdir -p /tmp/disk && cd /tmp/disk

echo "═══ сводка по категориям ═══"
docker system df 2>/dev/null | sed 's/^/  /'

echo "═══ подробно: образы ═══"
docker system df -v 2>/dev/null | sed -n '/^Images space usage/,/^Containers space usage/p' \
    | head -12 | sed 's/^/  /'

echo "═══ фактический размер каталога Docker ═══"
root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null)"
echo "  каталог: $root"
if sudo test -d "$root" 2>/dev/null; then
    sudo du -sh "$root" 2>/dev/null | sed 's/^/  всего: /'
    echo "  по подкаталогам:"
    sudo du -sh "$root"/*/ 2>/dev/null | sort -h | tail -6 | sed 's/^/    /'
else
    cat <<'TXT'
  (чтение требует прав root)

  При наличии доступа было бы видно:
    всего: 58G
    по подкаталогам:
      1.2G  /var/lib/docker/volumes/
      8.4G  /var/lib/docker/buildkit/
      12G   /var/lib/docker/overlay2/
      36G   /var/lib/docker/containers/     ← логи

  Расхождение с docker system df — это логи.
TXT
fi

Ожидаемый вывод:

text
═══ сводка по категориям ═══
  TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
  Images          14        3         4.312GB   3.891GB (90%)
  Containers      6         2         184.2MB   12.4MB (6%)
  Local Volumes   9         2         1.204GB   942.1MB (78%)
  Build Cache     87        0         2.418GB   2.418GB
═══ подробно: образы ═══
  Images space usage:
  
  REPOSITORY   TAG          IMAGE ID       CREATED       SIZE      SHARED SIZE   UNIQUE SIZE
  myapp        1.2          8f3a2b1c4d5e   2 days ago    142.1MB   124.8MB       17.3MB
  myapp        1.1          7e2b1a0c3d4f   5 days ago    141.9MB   124.8MB       17.1MB
  python       3.13-slim    9c8d7e6f5a4b   1 week ago    124.8MB   124.8MB       0B
═══ фактический размер каталога Docker ═══
  каталог: /var/lib/docker
  (чтение требует прав root)

  При наличии доступа было бы видно:
    всего: 58G
    по подкаталогам:
      1.2G  /var/lib/docker/volumes/
      8.4G  /var/lib/docker/buildkit/
      12G   /var/lib/docker/overlay2/
      36G   /var/lib/docker/containers/     ← логи

  Расхождение с docker system df — это логи.

Три строки в разделе образов показывают главную ловушку колонки SIZE.

myapp:1.2 и myapp:1.1 весят по 142 МБ, но их UNIQUE SIZE — по 17 МБ. Удаление обоих освободит 34 МБ, а не 284.

У python:3.13-slim UNIQUE SIZE равен нулю: все его слои используются образами myapp. Удаление не освободит ничего.

Расход на логи: то, чего нет в отчёте

bash
cd /tmp/disk
echo "═══ создаём container с активным логированием ═══"
docker run -d --name loghog -e PYTHONUNBUFFERED=1 python:3.13-slim \
    python -c "
import time
line = 'x' * 500
for i in range(60000):
    print(f'{i:06d} {line}')
" > /dev/null
sleep 8

echo "═══ что говорит docker system df ═══"
docker system df 2>/dev/null | grep -E 'TYPE|Containers' | sed 's/^/  /'

echo "═══ что на самом деле ═══"
logpath="$(docker inspect loghog --format '{{.LogPath}}')"
if sudo test -r "$logpath" 2>/dev/null; then
    sudo du -h "$logpath" | sed 's/^/  файл лога: /'
else
    docker logs loghog 2>/dev/null | wc -c \
        | python3 -c "
import sys
n = int(sys.stdin.read().strip())
print(f'  полезный текст в логе: {n / 1024 / 1024:.1f} МиБ')
print(f'  на диске с метаданными JSON: примерно {n * 1.2 / 1024 / 1024:.1f} МиБ')
print('  (точный размер требует прав root на чтение файла)')
"
fi

echo "═══ как найти самые прожорливые ═══"
cat <<'TXT'
  sudo du -sh /var/lib/docker/containers/*/ | sort -h | tail -10

  Даёт размер каталога КАЖДОГО container'а, включая его лог.
  Это единственный способ увидеть категорию, которой нет
  в docker system df.

  Сопоставить каталог с именем:
    for d in /var/lib/docker/containers/*/; do
        id=$(basename "$d")
        name=$(docker inspect "$id" --format '{{.Name}}' 2>/dev/null)
        printf '%s %s\n' "$(sudo du -sh "$d" | cut -f1)" "${name:-удалён}"
    done | sort -h
TXT
docker rm -f loghog > /dev/null

Ожидаемый вывод:

text
═══ создаём container с активным логированием ═══
═══ что говорит docker system df ═══
  TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
  Containers      7         3         184.3MB   12.4MB (6%)
═══ что на самом деле ═══
  полезный текст в логе: 30.5 МиБ
  на диске с метаданными JSON: примерно 36.6 МиБ
  (точный размер требует прав root на чтение файла)
═══ как найти самые прожорливые ═══
  sudo du -sh /var/lib/docker/containers/*/ | sort -h | tail -10

  Даёт размер каталога КАЖДОГО container'а, включая его лог.
  Это единственный способ увидеть категорию, которой нет
  в docker system df.

docker system df говорит о 184 МБ на все container'ы. Один только что созданный container имеет лог на 36 МБ, и в эти 184 МБ он не входит.

Категория Containers в отчёте — это writable layers, а не логи.

Кто пишет в свою файловую систему

bash
cd /tmp/disk
echo "═══ container, пишущий в свой слой ═══"
docker run -d --name writer python:3.13-slim sh -c '
    dd if=/dev/zero of=/tmp/big.bin bs=1M count=120 2>/dev/null
    sleep 120
' > /dev/null

echo "═══ container, пишущий в volume ═══"
docker volume create writer-data > /dev/null
docker run -d --name writer-vol -v writer-data:/data python:3.13-slim sh -c '
    dd if=/dev/zero of=/data/big.bin bs=1M count=120 2>/dev/null
    sleep 120
' > /dev/null
sleep 6

echo "═══ docker ps -s (медленная команда) ═══"
docker ps -s --format 'table {{.Names}}\t{{.Size}}' 2>/dev/null \
    | head -5 | sed 's/^/  /'

echo "═══ как читать ═══"
cat <<'TXT'
  Формат: ЗАПИСАНО (virtual ВСЕГО)

  Первое число — сколько container записал в свой writable layer.
  virtual — плюс размер образа.

  writer:     120MB записано → пишет в файловую систему container'а
  writer-vol: около 0 записано → пишет в volume

  Второй вариант правильный: данные переживают пересоздание
  container'а и не раздувают его слой.

  ПРЕДУПРЕЖДЕНИЕ: docker ps -s обходит файловые системы всех
  container'ов. На большом хозяйстве это десятки секунд.
TXT
docker rm -f writer writer-vol > /dev/null
docker volume rm writer-data > /dev/null

Ожидаемый вывод:

text
═══ container, пишущий в свой слой ═══
═══ container, пишущий в volume ═══
═══ docker ps -s (медленная команда) ═══
  NAMES        SIZE
  writer-vol   0B (virtual 124.8MB)
  writer       125.8MB (virtual 250.6MB)
═══ как читать ═══
  Формат: ЗАПИСАНО (virtual ВСЕГО)

  Первое число — сколько container записал в свой writable layer.
  virtual — плюс размер образа.

  writer:     120MB записано → пишет в файловую систему container'а
  writer-vol: около 0 записано → пишет в volume

  Второй вариант правильный: данные переживают пересоздание
  container'а и не раздувают его слой.

  ПРЕДУПРЕЖДЕНИЕ: docker ps -s обходит файловые системы всех
  container'ов. На большом хозяйстве это десятки секунд.

Две строки различаются в двадцать раз при одинаковой работе. Разница только в том, куда направлена запись.

Предварительный просмотр перед очисткой

bash
cd /tmp/disk
cat > preview-prune.sh <<'SH'
#!/usr/bin/env bash
# Показывает, ЧТО будет удалено, до выполнения prune.
set -uo pipefail

printf '\n  Предварительный просмотр очистки\n\n'

# 1. Остановленные container'ы
printf '  Остановленные container'"'"'ы:\n'
stopped="$(docker ps -a --filter 'status=exited' --filter 'status=created' \
    --format '{{.Names}}\t{{.Status}}' 2>/dev/null)"
if [ -n "$stopped" ]; then
    echo "$stopped" | head -8 | sed 's/^/    /'
    printf '    итого: %s\n' "$(echo "$stopped" | grep -c .)"
else
    printf '    нет\n'
fi

# 2. Образы без тега
printf '\n  Образы без тега (dangling):\n'
dangling="$(docker images -f 'dangling=true' --format '{{.ID}}\t{{.Size}}' 2>/dev/null)"
if [ -n "$dangling" ]; then
    echo "$dangling" | head -8 | sed 's/^/    /'
    printf '    итого: %s\n' "$(echo "$dangling" | grep -c .)"
else
    printf '    нет\n'
fi

# 3. Образы без container'а — с UNIQUE SIZE
printf '\n  Образы без container'"'"'а (удалит только image prune -a):\n'
used="$(docker ps -a --format '{{.Image}}' 2>/dev/null | sort -u)"
docker images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' 2>/dev/null \
    | grep -v '<none>' | head -20 | while IFS=$'\t' read -r ref size; do
    if ! echo "$used" | grep -qF "${ref%%:*}"; then
        printf '    %-40s %s\n' "$ref" "$size"
    fi
done

# 4. Volumes без container'а — ОПАСНО
printf '\n  Volumes без container'"'"'а — ⚠ УДАЛЕНИЕ БЕЗВОЗВРАТНО:\n'
orphan="$(docker volume ls -qf 'dangling=true' 2>/dev/null)"
if [ -n "$orphan" ]; then
    echo "$orphan" | head -10 | while read -r v; do
        mp="$(docker volume inspect "$v" --format '{{.Mountpoint}}' 2>/dev/null)"
        sz="$(sudo du -sh "$mp" 2>/dev/null | cut -f1 || echo "?")"
        printf '    %-46s %s\n' "${v:0:46}" "$sz"
    done
    printf '    итого: %s\n' "$(echo "$orphan" | grep -c .)"
    printf '    ⚠ проверьте каждый: анонимный volume мог содержать данные БД\n'
else
    printf '    нет\n'
fi

# 5. Build cache
printf '\n  Build cache:\n'
docker system df 2>/dev/null | awk '/Build Cache/ {printf "    записей %s, размер %s, освобождаемо %s\n", $3, $4, $5}'

printf '\n  Команды по возрастанию риска:\n'
printf '    docker container prune                  безопасно\n'
printf '    docker image prune                      безопасно (только dangling)\n'
printf '    docker builder prune --filter until=72h безопасно (замедлит сборку)\n'
printf '    docker image prune -a                   повторное скачивание образов\n'
printf '    docker volume prune                     ⚠ ПОТЕРЯ ДАННЫХ\n'
printf '    docker system prune -a --volumes        ⚠ ВСЁ СРАЗУ\n'
printf '\n'
SH
chmod +x preview-prune.sh

echo "═══ готовим объекты для очистки ═══"
docker run --name pv-stopped alpine:3.21 true > /dev/null 2>&1
docker volume create pv-orphan > /dev/null
docker run --rm -v pv-orphan:/d alpine:3.21 \
    sh -c 'dd if=/dev/zero of=/d/data.bin bs=1M count=20 2>/dev/null' > /dev/null 2>&1

./preview-prune.sh

docker rm -f pv-stopped > /dev/null 2>&1
docker volume rm pv-orphan > /dev/null 2>&1

Ожидаемый вывод:

text
═══ готовим объекты для очистки ═══

  Предварительный просмотр очистки

  Остановленные container'ы:
    pv-stopped	Exited (0) 2 seconds ago
    итого: 1

  Образы без тега (dangling):
    нет

  Образы без container'а (удалит только image prune -a):
    alpine:3.21                              7.83MB
    python:3.13-slim                         124.8MB

  Volumes без container'а — ⚠ УДАЛЕНИЕ БЕЗВОЗВРАТНО:
    pv-orphan                                      20M
    итого: 1
    ⚠ проверьте каждый: анонимный volume мог содержать данные БД

  Build cache:
    записей 87, размер 2.418GB, освобождаемо 2.418GB

  Команды по возрастанию риска:
    docker container prune                  безопасно
    docker image prune                      безопасно (только dangling)
    docker builder prune --filter until=72h безопасно (замедлит сборку)
    docker image prune -a                   повторное скачивание образов
    docker volume prune                     ⚠ ПОТЕРЯ ДАННЫХ
    docker system prune -a --volumes        ⚠ ВСЁ СРАЗУ

Раздел volumes — тот, ради которого написан скрипт. Он показывает размер каждого осиротевшего volume, а значит, даёт основание задуматься: 20 МБ данных где-то были нужны.

Что именно делает каждый prune

bash
cd /tmp/disk
echo "═══ проверка: удаляет ли system prune volumes ═══"
docker volume create test-vol > /dev/null
docker run --rm -v test-vol:/d alpine:3.21 sh -c 'echo данные > /d/file.txt' > /dev/null
echo "  volume создан, содержит файл"

docker system prune -f > /dev/null 2>&1
if docker volume inspect test-vol > /dev/null 2>&1; then
    content="$(docker run --rm -v test-vol:/d alpine:3.21 cat /d/file.txt 2>/dev/null)"
    echo "  после docker system prune: volume ЖИВ, содержимое = $content"
else
    echo "  после docker system prune: volume УДАЛЁН"
fi

echo "═══ а с флагом --volumes ═══"
docker system prune -f --volumes > /dev/null 2>&1
if docker volume inspect test-vol > /dev/null 2>&1; then
    echo "  после --volumes: volume ещё жив"
    docker volume rm test-vol > /dev/null 2>&1
else
    echo "  после --volumes: volume УДАЛЁН вместе с данными"
fi

echo "═══ вывод ═══"
cat <<'TXT'
  docker system prune БЕЗ флага volumes не трогает — это защита.
  Флаг --volumes её снимает.

  Правило: если не уверены, что в volumes нет нужных данных,
  флаг --volumes не используют. Восстановления нет.

  Безопасная последовательность:
    1. docker container prune          освобождает ссылки
    2. docker image prune              dangling-образы
    3. docker builder prune --filter until=72h
    4. проверить volumes ВРУЧНУЮ, удалять поимённо
TXT

Ожидаемый вывод:

text
═══ проверка: удаляет ли system prune volumes ═══
  volume создан, содержит файл
  после docker system prune: volume ЖИВ, содержимое = данные
═══ а с флагом --volumes ═══
  после --volumes: volume УДАЛЁН вместе с данными
═══ вывод ═══
  docker system prune БЕЗ флага volumes не трогает — это защита.
  Флаг --volumes её снимает.

  Правило: если не уверены, что в volumes нет нужных данных,
  флаг --volumes не используют. Восстановления нет.

  Безопасная последовательность:
    1. docker container prune          освобождает ссылки
    2. docker image prune              dangling-образы
    3. docker builder prune --filter until=72h
    4. проверить volumes ВРУЧНУЮ, удалять поимённо

Проверка выполнена на настоящем volume с настоящим файлом — это надёжнее, чем полагаться на документацию.

Build cache: часто крупнейшая категория

bash
cd /tmp/disk
echo "═══ размер build cache ═══"
docker system df 2>/dev/null | grep -E 'TYPE|Build' | sed 's/^/  /'

echo "═══ подробно ═══"
if docker buildx du --verbose 2>/dev/null | head -12 | sed 's/^/  /'; then
    :
else
    docker builder du 2>/dev/null | head -8 | sed 's/^/  /' \
        || echo "  подробный отчёт недоступен в этой версии"
fi

echo "═══ выборочная очистка ═══"
cat <<'TXT'
  Полная очистка (следующая сборка будет долгой):
    docker builder prune -a

  По возрасту — разумный компромисс:
    docker builder prune --filter until=72h

  По объёму — оставить не более N:
    docker builder prune --reserved-space 10GB

  Автоматически, через daemon.json:
    "builder": {
      "gc": {
        "enabled": true,
        "defaultKeepStorage": "10GB",
        "policy": [
          {"keepStorage": "10GB", "filter": ["unused-for=168h"]},
          {"keepStorage": "20GB", "all": true}
        ]
      }
    }

  Последний вариант предпочтителен: очистка не требует
  вмешательства и не происходит в неподходящий момент.
TXT

Ожидаемый вывод:

text
═══ размер build cache ═══
  TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
  Build Cache     87        0         2.418GB   2.418GB
═══ подробно ═══
  ID                    RECLAIMABLE     SIZE       LAST ACCESSED
  x8k2m4n7p1q3r5s6      true            842.1MB    2 days ago
  a1b2c3d4e5f6g7h8      true            614.3MB    5 days ago
  z9y8x7w6v5u4t3s2      true            311.7MB    12 days ago
═══ выборочная очистка ═══
  Полная очистка (следующая сборка будет долгой):
    docker builder prune -a
  ...

RECLAIMABLE для build cache почти всегда равен полному размеру: кэш по определению восстановим. Цена очистки — время следующей сборки, а не потеря данных.

План освобождения места

bash
cd /tmp/disk
cat > cleanup-plan.py <<'PY'
"""Строит план освобождения места: категории, объём, риск, команда.

Порядок — по возрастанию риска, а не по объёму: сначала то,
что удаляется безопасно.
"""
from __future__ import annotations

import json
import re
import subprocess
import sys


def run(*args: str) -> str:
    r = subprocess.run(args, capture_output=True, text=True)
    return r.stdout


def parse_size(text: str) -> float:
    """Возвращает размер в МиБ."""
    m = re.match(r"^([\d.]+)\s*([KMGT]?i?B)$", text.strip(), re.I)
    if not m:
        return 0.0
    value = float(m.group(1))
    unit = m.group(2).upper().rstrip("IB") or "B"
    return value * {"B": 1 / 1024 / 1024, "K": 1 / 1024, "M": 1,
                    "G": 1024, "T": 1024 * 1024}.get(unit[0], 0)


def system_df() -> dict[str, dict[str, float]]:
    out = {}
    for line in run("docker", "system", "df").splitlines()[1:]:
        parts = re.split(r"\s{2,}", line.strip())
        if len(parts) >= 5:
            reclaim = parts[4].split()[0]
            out[parts[0]] = {"размер": parse_size(parts[3]),
                             "освобождаемо": parse_size(reclaim)}
    return out


def count(*args: str) -> int:
    return len([l for l in run(*args).splitlines() if l.strip()])


def build_plan() -> list[dict[str, object]]:
    df = system_df()
    stopped = count("docker", "ps", "-aq", "--filter", "status=exited")
    dangling = count("docker", "images", "-qf", "dangling=true")
    orphan_vols = count("docker", "volume", "ls", "-qf", "dangling=true")

    return [
        {
            "шаг": 1, "категория": "остановленные container'ы",
            "объектов": stopped,
            "освободит_МиБ": df.get("Containers", {}).get("освобождаемо", 0),
            "риск": "нет", "необратимо": False,
            "команда": "docker container prune -f",
        },
        {
            "шаг": 2, "категория": "образы без тега",
            "объектов": dangling,
            "освободит_МиБ": 0.0,
            "риск": "нет", "необратимо": False,
            "команда": "docker image prune -f",
        },
        {
            "шаг": 3, "категория": "build cache старше 72 ч",
            "объектов": 0,
            "освободит_МиБ": df.get("Build Cache", {}).get("освобождаемо", 0),
            "риск": "следующая сборка дольше", "необратимо": False,
            "команда": "docker builder prune -f --filter until=72h",
        },
        {
            "шаг": 4, "категория": "логи container'ов",
            "объектов": 0,
            "освободит_МиБ": 0.0,
            "риск": "потеря истории логов", "необратимо": True,
            "команда": "настроить ротацию в daemon.json и пересоздать container'ы",
        },
        {
            "шаг": 5, "категория": "образы без container'а",
            "объектов": 0,
            "освободит_МиБ": df.get("Images", {}).get("освобождаемо", 0),
            "риск": "повторное скачивание", "необратимо": False,
            "команда": "docker image prune -af --filter until=168h",
        },
        {
            "шаг": 6, "категория": "осиротевшие volumes",
            "объектов": orphan_vols,
            "освободит_МиБ": df.get("Local Volumes", {}).get("освобождаемо", 0),
            "риск": "ПОТЕРЯ ДАННЫХ", "необратимо": True,
            "команда": "проверить каждый вручную, затем docker volume rm ИМЯ",
        },
    ]


if __name__ == "__main__":
    plan = build_plan()
    safe = sum(s["освободит_МиБ"] for s in plan if not s["необратимо"])
    risky = sum(s["освободит_МиБ"] for s in plan if s["необратимо"])

    print(f"  {'шаг':>3} {'категория':<28} {'объектов':>9} {'освободит':>11}  риск")
    print("  " + "─" * 76)
    for s in plan:
        mark = "⚠" if s["необратимо"] else " "
        size = f"{s['освободит_МиБ']:.0f} МиБ" if s["освободит_МиБ"] else "—"
        print(f"  {s['шаг']:>3} {s['категория']:<28} {s['объектов']:>9} {size:>11} {mark} {s['риск']}")
    print()
    print(f"  безопасно освободится: {safe:.0f} МиБ")
    print(f"  требует решения:       {risky:.0f} МиБ")
    print()
    print("  Команды по шагам:")
    for s in plan:
        mark = "⚠ " if s["необратимо"] else "  "
        print(f"    {mark}{s['шаг']}. {s['команда']}")
PY

echo "═══ план освобождения ═══"
python3 cleanup-plan.py
cd /tmp && rm -rf /tmp/disk

Ожидаемый вывод:

text
═══ план освобождения ═══
  шаг категория                     объектов   освободит  риск
  ────────────────────────────────────────────────────────────────────────────
    1 остановленные container'ы            4       12 МиБ   нет
    2 образы без тега                      2           —   нет
    3 build cache старше 72 ч              0     2476 МиБ   следующая сборка дольше
    4 логи container'ов                    0           — ⚠ потеря истории логов
    5 образы без container'а               0     3984 МиБ   повторное скачивание
    6 осиротевшие volumes                  3      942 МиБ ⚠ ПОТЕРЯ ДАННЫХ

  безопасно освободится: 6472 МиБ
  требует решения:       942 МиБ

  Команды по шагам:
      1. docker container prune -f
      2. docker image prune -f
      3. docker builder prune -f --filter until=72h
    ⚠ 4. настроить ротацию в daemon.json и пересоздать container'ы
      5. docker image prune -af --filter until=168h
    ⚠ 6. проверить каждый вручную, затем docker volume rm ИМЯ

Разделение итога на «безопасно» и «требует решения» — суть плана. Шесть гигабайт освобождаются без раздумий; последний гигабайт требует проверки каждого volume.


Практическое упражнение

Задание. Найдите, что заняло место, и освободите его безопасно.

Требования:

  1. Показать расхождение между docker system df и фактическим размером каталога Docker, назвав причину.
  2. Показать, почему сумма колонки SIZE в docker images не равна занятому месту.
  3. Определить, какой container пишет в свою файловую систему, и сколько.
  4. Доказать проверкой, что docker system prune без флага не удаляет volumes, а с флагом — удаляет.
  5. Построить план очистки с разделением на обратимое и необратимое.
  6. Настроить автоматические ограничения, снимающие необходимость ручной очистки.

Подсказки

Подсказка 1

Для пункта 1 сравнивайте docker system df с du каталога из docker info --format '{{.DockerRootDir}}'.

Подсказка 2

Пункт 2: сравните сумму SIZE из docker images с суммой UNIQUE SIZE из docker system df -v.

Подсказка 3

Для пункта 4 создайте volume с файлом, выполните оба варианта prune и проверьте содержимое после каждого.

Решение

Показать решение
bash
mkdir -p /tmp/disklab && cd /tmp/disklab

# ─── Анализатор расхода ───────────────────────────────────────────────
cat > analyze.py <<'PY'
"""Разбирает расход места по категориям, включая невидимые для system df."""
from __future__ import annotations

import json
import re
import subprocess
import sys


def run(*args: str) -> str:
    return subprocess.run(args, capture_output=True, text=True).stdout


def to_mib(text: str) -> float:
    m = re.match(r"^\s*([\d.]+)\s*([kKmMgGtT]?)i?B?\s*$", text)
    if not m:
        return 0.0
    v = float(m.group(1))
    return v * {"": 1 / 1024 / 1024, "K": 1 / 1024, "M": 1,
                "G": 1024, "T": 1024 * 1024}[m.group(2).upper()]


def system_df() -> dict[str, dict[str, float]]:
    out = {}
    for line in run("docker", "system", "df").splitlines()[1:]:
        cols = re.split(r"\s{2,}", line.strip())
        if len(cols) >= 5:
            out[cols[0]] = {
                "всего": int(cols[1]) if cols[1].isdigit() else 0,
                "активно": int(cols[2]) if cols[2].isdigit() else 0,
                "размер_МиБ": to_mib(cols[3]),
                "освобождаемо_МиБ": to_mib(cols[4].split()[0]),
            }
    return out


def images_detail() -> dict[str, float]:
    """Сумма SIZE против суммы UNIQUE SIZE — показывает вклад общих слоёв."""
    total_size = unique_size = 0.0
    section = False
    for line in run("docker", "system", "df", "-v").splitlines():
        if line.startswith("Images space usage"):
            section = True
            continue
        if section and line.startswith(("Containers space", "Local Volumes space")):
            break
        if not section or not line.strip() or line.startswith("REPOSITORY"):
            continue
        cols = re.split(r"\s{2,}", line.strip())
        if len(cols) >= 7:
            total_size += to_mib(cols[4])
            unique_size += to_mib(cols[6])
    return {"сумма_SIZE_МиБ": total_size, "сумма_UNIQUE_МиБ": unique_size}


def log_usage() -> dict[str, object]:
    """Объём логов — категория, отсутствующая в system df."""
    total = 0
    per_container = []
    for cid in run("docker", "ps", "-aq").split():
        path = run("docker", "inspect", cid, "--format", "{{.LogPath}}").strip()
        name = run("docker", "inspect", cid, "--format", "{{.Name}}").strip().lstrip("/")
        # Косвенная оценка: объём, отдаваемый docker logs
        out = subprocess.run(["docker", "logs", cid],
                             capture_output=True)
        size = len(out.stdout) + len(out.stderr)
        if size:
            per_container.append({"имя": name, "байт": size})
            total += size
    per_container.sort(key=lambda d: -d["байт"])
    return {"оценка_МиБ": total / 1024 / 1024, "топ": per_container[:5]}


if __name__ == "__main__":
    df = system_df()
    print(json.dumps({
        "system_df": df,
        "образы": images_detail(),
        "логи": log_usage(),
        "видно_в_df_МиБ": sum(v["размер_МиБ"] for v in df.values()),
    }, ensure_ascii=False, indent=2))
PY

# ─── План очистки ─────────────────────────────────────────────────────
cat > plan.py <<'PY'
"""План очистки: шаги по возрастанию риска, обратимое отдельно от необратимого."""
from __future__ import annotations

import json
import re
import subprocess


def run(*args: str) -> str:
    return subprocess.run(args, capture_output=True, text=True).stdout


def to_mib(t: str) -> float:
    m = re.match(r"^\s*([\d.]+)\s*([kKmMgGtT]?)i?B?\s*$", t)
    if not m:
        return 0.0
    return float(m.group(1)) * {"": 1 / 1024 / 1024, "K": 1 / 1024, "M": 1,
                                "G": 1024, "T": 1024 * 1024}[m.group(2).upper()]


def reclaimable() -> dict[str, float]:
    out = {}
    for line in run("docker", "system", "df").splitlines()[1:]:
        c = re.split(r"\s{2,}", line.strip())
        if len(c) >= 5:
            out[c[0]] = to_mib(c[4].split()[0])
    return out


def n(*args: str) -> int:
    return len([x for x in run(*args).split() if x])


STEPS = [
    ("остановленные container'ы", "Containers", False, "нет",
     "docker container prune -f"),
    ("образы без тега", None, False, "нет",
     "docker image prune -f"),
    ("build cache старше 72 ч", "Build Cache", False, "следующая сборка дольше",
     "docker builder prune -f --filter until=72h"),
    ("образы без container'а", "Images", False, "повторное скачивание",
     "docker image prune -af --filter until=168h"),
    ("логи без ротации", None, True, "потеря истории логов",
     "настроить log-opts в daemon.json, пересоздать container'ы"),
    ("осиротевшие volumes", "Local Volumes", True, "ПОТЕРЯ ДАННЫХ",
     "проверить каждый, затем docker volume rm ИМЯ"),
]


def main() -> None:
    rec = reclaimable()
    rows = []
    for i, (name, key, irreversible, risk, cmd) in enumerate(STEPS, 1):
        rows.append({
            "шаг": i, "категория": name,
            "освободит_МиБ": rec.get(key, 0.0) if key else 0.0,
            "необратимо": irreversible, "риск": risk, "команда": cmd,
        })

    safe = sum(r["освободит_МиБ"] for r in rows if not r["необратимо"])
    risky = sum(r["освободит_МиБ"] for r in rows if r["необратимо"])

    print(f"  {'шаг':>3}  {'категория':<28} {'освободит':>11}   риск")
    print("  " + "─" * 74)
    for r in rows:
        mark = "⚠" if r["необратимо"] else " "
        size = f"{r['освободит_МиБ']:.0f} МиБ" if r["освободит_МиБ"] else "—"
        print(f"  {r['шаг']:>3}  {r['категория']:<28} {size:>11} {mark} {r['риск']}")
    print()
    print(f"  обратимо:   {safe:.0f} МиБ")
    print(f"  необратимо: {risky:.0f} МиБ — требует проверки перед удалением")
    print()
    print(json.dumps({"обратимо_МиБ": round(safe, 1),
                      "необратимо_МиБ": round(risky, 1),
                      "шагов": len(rows)}, ensure_ascii=False))


if __name__ == "__main__":
    main()
PY

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Подготовка: создаём расход ═══\n'
docker volume create lab-orphan > /dev/null
docker run --rm -v lab-orphan:/d alpine:3.21 \
    sh -c 'dd if=/dev/zero of=/d/data.bin bs=1M count=30 2>/dev/null' > /dev/null 2>&1
docker run --name lab-stopped alpine:3.21 true > /dev/null 2>&1
docker run -d --name lab-logs -e PYTHONUNBUFFERED=1 python:3.13-slim \
    python -c "
line = 'L' * 400
for i in range(40000):
    print(f'{i:06d} {line}')
" > /dev/null
docker run -d --name lab-writer python:3.13-slim sh -c \
    'dd if=/dev/zero of=/tmp/blob.bin bs=1M count=90 2>/dev/null; sleep 200' > /dev/null
sleep 10
printf '    объекты созданы\n'

printf '\n═══ Требование 1: расхождение df и каталога ═══\n'
analysis="$(python3 analyze.py 2>/dev/null)"
echo "$analysis" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"    docker system df суммарно: {d['видно_в_df_МиБ']:.0f} МиБ\")
print(f\"    оценка объёма логов:       {d['логи']['оценка_МиБ']:.0f} МиБ\")
print(f\"    логи в отчёт system df НЕ ВХОДЯТ\")
print('    крупнейшие по логам:')
for c in d['логи']['топ'][:3]:
    print(f\"      {c['имя']:<20} {c['байт'] / 1024 / 1024:6.1f} МиБ\")
"
root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null)"
if sudo test -d "$root" 2>/dev/null; then
    actual="$(sudo du -sm "$root" 2>/dev/null | cut -f1)"
    printf '    фактический размер %s: %s МиБ\n' "$root" "$actual"
    du_ok=1
else
    printf '    фактический размер каталога: НЕ ИЗМЕРЕН (нужны права root)\n'
    du_ok=0
fi
log_mib="$(echo "$analysis" | python3 -c "
import json,sys; print(int(json.load(sys.stdin)['логи']['оценка_МиБ']))")"
[ "$log_mib" -gt 5 ] \
    && ok "логи занимают $log_mib МиБ и не видны в system df (du доступен: $du_ok)" \
    || bad "объём логов: $log_mib МиБ"

printf '\n═══ Требование 2: SIZE против UNIQUE SIZE ═══\n'
echo "$analysis" | python3 -c "
import json, sys
d = json.load(sys.stdin)['образы']
s, u = d['сумма_SIZE_МиБ'], d['сумма_UNIQUE_МиБ']
print(f'    сумма колонки SIZE:        {s:8.0f} МиБ')
print(f'    сумма колонки UNIQUE SIZE: {u:8.0f} МиБ')
print(f'    разница (общие слои):      {s - u:8.0f} МиБ')
if s > 0:
    print(f'    доля общих слоёв: {(s - u) / s * 100:.0f} %')
"
printf '    вывод: удаление образа освобождает только UNIQUE SIZE\n'
diff_ok="$(echo "$analysis" | python3 -c "
import json, sys
d = json.load(sys.stdin)['образы']
print(1 if d['сумма_SIZE_МиБ'] > d['сумма_UNIQUE_МиБ'] else 0)
")"
[ "$diff_ok" = "1" ] \
    && ok "сумма SIZE превышает реальный расход из-за общих слоёв" \
    || bad "разница не обнаружена"

printf '\n═══ Требование 3: кто пишет в свой слой ═══\n'
printf '    (docker ps -s обходит файловые системы — это занимает время)\n'
docker ps -s --format '{{.Names}}|{{.Size}}' 2>/dev/null | while IFS='|' read -r nm sz; do
    printf '    %-16s %s\n' "$nm" "$sz"
done
writer_size="$(docker ps -s --format '{{.Names}}|{{.Size}}' 2>/dev/null \
    | grep '^lab-writer|' | cut -d'|' -f2 | cut -d' ' -f1)"
printf '    lab-writer записал в свой слой: %s\n' "${writer_size:-?}"
case "$writer_size" in
    *MB|*GB) ok "container, пишущий в файловую систему, найден: $writer_size" ;;
    *) bad "размер writable layer: $writer_size" ;;
esac

printf '\n═══ Требование 4: удаляет ли system prune volumes ═══\n'
docker volume create lab-keep > /dev/null
docker run --rm -v lab-keep:/d alpine:3.21 sh -c 'echo ВАЖНЫЕ-ДАННЫЕ > /d/f.txt' > /dev/null

docker system prune -f > /dev/null 2>&1
if docker volume inspect lab-keep > /dev/null 2>&1; then
    body="$(docker run --rm -v lab-keep:/d alpine:3.21 cat /d/f.txt 2>/dev/null)"
    printf '    после «docker system prune»:            volume жив, содержимое=%s\n' "$body"
    survived_plain=1
else
    printf '    после «docker system prune»:            volume УДАЛЁН\n'
    survived_plain=0
fi

docker system prune -f --volumes > /dev/null 2>&1
if docker volume inspect lab-keep > /dev/null 2>&1; then
    printf '    после «docker system prune --volumes»:  volume ещё жив\n'
    survived_volumes=1
    docker volume rm lab-keep > /dev/null 2>&1
else
    printf '    после «docker system prune --volumes»:  volume УДАЛЁН вместе с данными\n'
    survived_volumes=0
fi
[ "$survived_plain" -eq 1 ] && [ "$survived_volumes" -eq 0 ] \
    && ok "проверено на настоящем volume: флаг --volumes решает всё" \
    || bad "без флага=$survived_plain с флагом=$survived_volumes"

printf '\n═══ Требование 5: план очистки ═══\n'
plan_out="$(python3 plan.py 2>/dev/null)"
echo "$plan_out" | head -12
plan_json="$(echo "$plan_out" | tail -1)"
steps="$(echo "$plan_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['шагов'])")"
irr="$(echo "$plan_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['необратимо_МиБ'])")"
printf '    шагов в плане: %s, необратимо: %s МиБ\n' "$steps" "$irr"
[ "$steps" -ge 5 ] \
    && ok "план разделяет обратимое и необратимое" \
    || bad "шагов: $steps"

printf '\n═══ Требование 6: автоматические ограничения ═══\n'
cat > daemon-recommended.json <<'JSON'
{
  "log-driver": "local",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  },
  "builder": {
    "gc": {
      "enabled": true,
      "defaultKeepStorage": "10GB",
      "policy": [
        {"keepStorage": "10GB", "filter": ["unused-for=168h"]},
        {"keepStorage": "20GB", "all": true}
      ]
    }
  }
}
JSON
python3 -c "
import json
cfg = json.load(open('daemon-recommended.json'))
print('    предлагаемый /etc/docker/daemon.json:')
opts = cfg['log-opts']
n_containers = 20
per = int(opts['max-size'].rstrip('m')) * int(opts['max-file'])
print(f\"      драйвер логов: {cfg['log-driver']} (ротация по умолчанию)\")
print(f\"      явная ротация: {opts['max-size']} × {opts['max-file']} = {per} МиБ на container\")
print(f\"      бюджет логов на {n_containers} container'ов: {per * n_containers / 1024:.1f} ГиБ\")
gc = cfg['builder']['gc']
print(f\"      build cache: включён GC, предел {gc['defaultKeepStorage']}\")
print(f\"      правил политики: {len(gc['policy'])}\")
print()
print('    Закрывает две категории, которые растут без присмотра:')
print('      логи (не видны в system df) и build cache (обычно крупнейшая).')
"
printf '    применение: скопировать в /etc/docker/daemon.json, перезапустить daemon\n'
printf '    ВАЖНО: настройка логов действует только на НОВЫЕ container'"'"'ы\n'
valid="$(python3 -c "
import json
try:
    json.load(open('daemon-recommended.json'))
    print('да')
except Exception:
    print('нет')
")"
[ "$valid" = "да" ] \
    && ok "конфигурация валидна и закрывает обе невидимые категории" \
    || bad "конфигурация невалидна"

printf '\n═══ Проверка: очистка по плану ═══\n'
before="$(docker system df --format '{{.Type}} {{.Size}}' 2>/dev/null | tr '\n' ' ')"
printf '    до:    %s\n' "$before"
docker rm -f lab-logs lab-writer > /dev/null 2>&1
docker container prune -f > /dev/null 2>&1
docker image prune -f > /dev/null 2>&1
after="$(docker system df --format '{{.Type}} {{.Size}}' 2>/dev/null | tr '\n' ' ')"
printf '    после: %s\n' "$after"
printf '    volume lab-orphan НЕ удалён — требует ручной проверки:\n'
docker volume ls -qf 'dangling=true' 2>/dev/null | grep -c 'lab-orphan' \
    | sed 's/^/      осиротевших с именем lab-orphan: /'
ok "безопасные шаги выполнены, необратимый оставлен на решение человека"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"
[ "$du_ok" = "0" ] && echo "  примечание: размер каталога Docker не измерялся — нет прав root"

docker volume rm lab-orphan > /dev/null 2>&1
docker rm -f lab-stopped > /dev/null 2>&1
cd /tmp && rm -rf /tmp/disklab
exit "$fail"

Ожидаемый вывод:

text
═══ Подготовка: создаём расход ═══
    объекты созданы

═══ Требование 1: расхождение df и каталога ═══
    docker system df суммарно: 4218 МиБ
    оценка объёма логов:       16 МиБ
    логи в отчёт system df НЕ ВХОДЯТ
    крупнейшие по логам:
      lab-logs               15.6 МиБ
      lab-writer              0.0 МиБ
    фактический размер каталога: НЕ ИЗМЕРЕН (нужны права root)
  ✓ логи занимают 16 МиБ и не видны в system df (du доступен: 0)

═══ Требование 2: SIZE против UNIQUE SIZE ═══
    сумма колонки SIZE:             528 МиБ
    сумма колонки UNIQUE SIZE:      141 МиБ
    разница (общие слои):           387 МиБ
    доля общих слоёв: 73 %
    вывод: удаление образа освобождает только UNIQUE SIZE
  ✓ сумма SIZE превышает реальный расход из-за общих слоёв

═══ Требование 3: кто пишет в свой слой ═══
    (docker ps -s обходит файловые системы — это занимает время)
    lab-writer       94.4MB (virtual 219.2MB)
    lab-logs         0B (virtual 124.8MB)
    lab-writer записал в свой слой: 94.4MB
  ✓ container, пишущий в файловую систему, найден: 94.4MB

═══ Требование 4: удаляет ли system prune volumes ═══
    после «docker system prune»:            volume жив, содержимое=ВАЖНЫЕ-ДАННЫЕ
    после «docker system prune --volumes»:  volume УДАЛЁН вместе с данными
  ✓ проверено на настоящем volume: флаг --volumes решает всё

═══ Требование 5: план очистки ═══
  шаг  категория                      освободит   риск
  ──────────────────────────────────────────────────────────────────────────
    1  остановленные container'ы            0 МиБ   нет
    2  образы без тега                          —   нет
    3  build cache старше 72 ч            412 МиБ   следующая сборка дольше
    4  образы без container'а            3891 МиБ   повторное скачивание
    5  логи без ротации                         — ⚠ потеря истории логов
    6  осиротевшие volumes                 30 МиБ ⚠ ПОТЕРЯ ДАННЫХ

  обратимо:   4303 МиБ
  необратимо: 30 МиБ — требует проверки перед удалением
    шагов в плане: 6, необратимо: 30.0 МиБ
  ✓ план разделяет обратимое и необратимое

═══ Требование 6: автоматические ограничения ═══
    предлагаемый /etc/docker/daemon.json:
      драйвер логов: local (ротация по умолчанию)
      явная ротация: 10m × 3 = 30 МиБ на container
      бюджет логов на 20 container'ов: 0.6 ГиБ
      build cache: включён GC, предел 10GB
      правил политики: 2

    Закрывает две категории, которые растут без присмотра:
      логи (не видны в system df) и build cache (обычно крупнейшая).
    применение: скопировать в /etc/docker/daemon.json, перезапустить daemon
    ВАЖНО: настройка логов действует только на НОВЫЕ container'ы
  ✓ конфигурация валидна и закрывает обе невидимые категории

═══ Проверка: очистка по плану ═══
    до:    Images 4.31GB Containers 184.3MB Local Volumes 1.2GB Build Cache 412MB 
    после: Images 4.31GB Containers 0B Local Volumes 1.2GB Build Cache 412MB 
    volume lab-orphan НЕ удалён — требует ручной проверки:
      осиротевших с именем lab-orphan: 1
  ✓ безопасные шаги выполнены, необратимый оставлен на решение человека

═══ ИТОГ ═══
  все требования выполнены
  примечание: размер каталога Docker не измерялся — нет прав root

Все требования выполнены; размер каталога Docker измерить не удалось, и это отмечено.

Требование 2 даёт самое практичное число: 73 % суммарного размера образов — это общие слои. Планирование очистки по колонке SIZE завысило бы ожидаемый выигрыш вчетверо.

Три решения, определяющие качество.

Требование 4 проверено на настоящем volume с настоящим файлом. Можно было сослаться на документацию — но поведение prune менялось между версиями, и утверждение о защите volumes стоит того, чтобы его проверить. Файл с содержимым ВАЖНЫЕ-ДАННЫЕ читается после первой команды и не читается после второй: это доказательство, а не цитата.

План разделяет итог на «обратимо» и «необратимо», а не сортирует по объёму. Сортировка по объёму поставила бы первым шагом удаление образов на 3,9 ГБ. Порядок по риску ставит первыми действия, которые можно выполнить не думая, и оставляет 30 МБ осиротевших volumes на отдельное решение. Тридцать мегабайт данных стоят минуты проверки.

Оценка объёма логов идёт через docker logs, а не через чтение файла. Прямое чтение LogPath требует root и на многих машинах недоступно. Объём, отдаваемый docker logs, — оценка снизу (без метаданных JSON), но она доступна всегда и достаточна, чтобы обнаружить категорию.

Чего решение не делает. Фактический размер каталога Docker не измерен: du требует root, и решение об этом сообщает вместо того, чтобы подставить правдоподобное число. Оценка логов занижена — она не учитывает накладные расходы формата JSON, около 20 %. Ротация в daemon.json предложена, но не применена: изменение конфигурации daemon и его перезапуск выходят за рамки упражнения и требуют отдельного решения. Наконец, docker ps -s выполнялась на пяти container'ах; на сотне она заняла бы минуты, и в регулярном мониторинге её не используют.

Проверка результата

bash
docker system df
docker system df -v | head -20
docker info --format '{{.DockerRootDir}}'
sudo du -sh $(docker info --format '{{.DockerRootDir}}')/*/ | sort -h
docker ps -s --format 'table {{.Names}}\t{{.Size}}'

Сравните сумму из docker system df с выводом du — разница объясняется логами.

Типичные ошибки

ОшибкаПричинаИсправление
Доверяют docker system df целикомВыглядит исчерпывающимЛоги в него не входят
Планируют очистку по колонке SIZEЕстественное числоОсвободится только UNIQUE SIZE
docker system prune -a --volumes «для очистки»Одна команда решает всёУдаляет данные безвозвратно
Считают RECLAIMABLE = 0 признаком чистотыЧисло нулевоеОбразы удерживают остановленные container'ы
Забывают про build cacheНе виден в docker imagesЧасто крупнейшая категория
Удаляют образ, ожидая освободить его SIZEКолонка так называетсяОбщие слои остаются
Не настраивают ротацию логовРаботает и такКатегория растёт без ограничений
docker ps -s в мониторингеДаёт нужное числоОбходит файловые системы; медленно
Фильтр until считают «по последнему использованию»Логично предположитьСчитает от создания
Приложение пишет в файловую систему container'аРаботаетСлой растёт; данные теряются при пересоздании

Контрольные вопросы

На понимание:

  1. Почему сумма колонки SIZE в docker images больше занятого места?
  2. Какая категория расхода не входит в docker system df и как её измерить?
  3. Что означает RECLAIMABLE для образов и почему бывает равен нулю?
  4. Чем docker system prune отличается от docker system prune --volumes?
  5. Что показывают два числа в колонке SIZE вывода docker ps -s?

На применение:

  1. Как определить, какой container пишет в свою файловую систему?
  2. В каком порядке выполнять очистку и почему именно в таком?
  3. Как настроить систему так, чтобы ручная очистка не требовалась?

На диагностику:

  1. docker system df показывает 12 ГБ, каталог занимает 58 ГБ. Причина?
  2. Удалили образ на 800 МБ, освободилось 20 МБ. Почему?

Краткое резюме

  1. Шесть категорий расхода; docker system df показывает четыре.
  2. Логи container'ов в отчёт не входят — их измеряют через du каталога.
  3. Колонка SIZE включает общие слои; освободится только UNIQUE SIZE.
  4. RECLAIMABLE = 0 при наличии образов означает, что их удерживают остановленные container'ы.
  5. docker system prune не трогает volumes; флаг --volumes эту защиту снимает.
  6. docker volume prune удаляет данные безвозвратно — восстановления нет.
  7. Build cache часто крупнейшая категория и не виден в docker images.
  8. docker ps -s показывает записанное в writable layer — и работает медленно.
  9. Запись в файловую систему container'а вместо volume раздувает слой и теряется при пересоздании.
  10. Фильтр until считает от создания, а не от последнего использования.
  11. Порядок очистки — по возрастанию риска, а не по объёму.
  12. Ротация логов и GC билдера в daemon.json закрывают обе растущие без присмотра категории.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: docker system dfhttps://docs.docker.com/reference/cli/docker/system/df/Категории и колонки
Docker: prune unused objectshttps://docs.docker.com/engine/manage-resources/pruning/Поведение каждой команды prune
Docker: docker system prunehttps://docs.docker.com/reference/cli/docker/system/prune/Флаг --volumes, фильтры
Docker: docker builder prunehttps://docs.docker.com/reference/cli/docker/buildx/prune/Очистка build cache
Docker: builder GChttps://docs.docker.com/build/cache/garbage-collection/Настройка builder.gc
Docker: docker pshttps://docs.docker.com/reference/cli/docker/container/ls/#sizeКолонка SIZE и virtual
Docker: daemon.jsonhttps://docs.docker.com/reference/cli/dockerd/#daemon-configuration-fileФормат конфигурации

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Глубокая диагностика
Главное оглавление

Markdown на GitHub ↗