Dive: Как я нашел 800MB мусора в Docker образе и уменьшил его на 45%

Docker образы имеют свойство незаметно распухать. Сегодня расскажу про инструмент, который помог мне найти почти гигабайт лишних файлов в production образе, и покажу, как использовать Dive для оптимизации ваших контейнеров.

История началась с алерта

Утро понедельника началось с сообщения от команды платформы: “Ваши образы занимают 40% места в registry. Можете оптимизировать?”

Проверяю размеры:

docker images | grep my-app
my-app   latest   3d4f5g6h   2 hours ago   1.82GB
my-app   v1.2.3   1a2b3c4d   1 week ago    1.79GB
my-app   v1.2.2   9z8y7x6w   2 weeks ago   1.81GB

Node.js приложение весит почти 2GB? Что-то тут не так.

Первая попытка: docker history

Начал с базового инструмента:

docker history my-app:latest

IMAGE          CREATED        CREATED BY                                      SIZE
3d4f5g6h       2 hours ago    CMD ["node" "dist/index.js"]                   0B
<missing>      2 hours ago    EXPOSE 3000                                     0B
<missing>      2 hours ago    RUN npm run build                               823MB
<missing>      2 hours ago    RUN npm ci                                      645MB
<missing>      2 hours ago    COPY . .                                        12.4MB
<missing>      2 hours ago    WORKDIR /app                                    0B
<missing>      2 hours ago    FROM node:18-alpine                            352MB

Вижу, что npm ci и npm run build занимают много места, но что именно там происходит - непонятно.

Enter Dive

Dive - это инструмент для исследования Docker образов слой за слоем. Он показывает:

  • Какие файлы добавлены/изменены/удалены в каждом слое
  • Размер каждого файла и директории
  • Потраченное впустую место (wasted space)
  • Эффективность образа

Установка

# macOS
brew install dive

# Ubuntu/Debian
wget https://github.com/wagoodman/dive/releases/download/v0.12.0/dive_0.12.0_linux_amd64.deb
sudo dpkg -i dive_0.12.0_linux_amd64.deb

# Arch Linux
yay -S dive

# Через Docker (да, иронично)
alias dive="docker run -ti --rm -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive"

Первый запуск

dive my-app:latest

Интерфейс Dive

После запуска видим три основные области:

  1. Layers (слева сверху) - список всех слоев образа
  2. Current Layer Contents (справа) - файловая система текущего слоя
  3. Image Details (снизу) - статистика по образу

Навигация

  • Tab - переключение между панелями
  • Ctrl+U/D - прокрутка списка слоев
  • ←→ или h/l - навигация по дереву файлов
  • Space - развернуть/свернуть директорию
  • Ctrl+L - показать только измененные файлы в слое
  • Ctrl+A - показать добавленные файлы
  • Ctrl+R - показать удаленные файлы
  • Ctrl+M - показать модифицированные файлы

Находка #1: Кеш node-gyp

Переключаюсь на слой RUN npm ci и вижу:

Current Layer Contents:
├── [+] app/
│   └── [+] node_modules (445 MB)
├── [+] root/
│   ├── [+] .npm (312 MB)
│   └── [+] .node-gyp (156 MB)
└── [+] tmp/
    └── [+] node-gyp-1234 (89 MB)

Вот оно! node-gyp (используется для компиляции нативных модулей) оставляет после себя:

  • Кеш npm в домашней директории
  • Загруженные заголовочные файлы Node.js
  • Временные файлы компиляции

Находка #2: Артефакты сборки

В слое RUN npm run build:

Current Layer Contents:
├── [+] app/
│   ├── [+] dist/ (45 MB) ✓
│   ├── [+] src/ (12 MB) ✗
│   ├── [+] node_modules/
│   │   └── [+] .cache/ (234 MB) ✗
│   ├── [+] .next/ (156 MB) ✗
│   └── [+] coverage/ (78 MB) ✗

Обнаружил:

  • Исходники, которые не нужны в production
  • Кеш различных build-инструментов
  • Результаты тестового покрытия
  • Временные файлы Next.js (хотя мы используем обычный Node.js)

Анализ эффективности

Dive показывает метрики эффективности:

Efficiency Score: 42% (Poor)
Wasted Space: 823 MB (45% of image)
Image Size: 1.82 GB

Wasted Space - это файлы, которые были добавлены в одном слое и удалены в другом. Они все равно занимают место в образе.

Оптимизация: Round 1

На основе находок переписал Dockerfile:

FROM node:18-alpine AS builder

WORKDIR /app

# Копируем только необходимое для установки зависимостей
COPY package*.json ./

# Устанавливаем зависимости и сразу чистим кеши
RUN npm ci --only=production && \
    npm cache clean --force && \
    rm -rf ~/.npm ~/.node-gyp

# Копируем исходники
COPY . .

# Собираем приложение
RUN npm run build && \
    rm -rf src/ tests/ .next/ coverage/ && \
    find . -name "*.map" -delete && \
    find . -name "*.ts" -delete

# Production stage
FROM node:18-alpine

WORKDIR /app

# Копируем только необходимое
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

EXPOSE 3000

CMD ["node", "dist/index.js"]

Результат:

docker build -t my-app:optimized .
docker images | grep my-app
my-app   optimized   7h8i9j0k   1 minute ago   623MB

С 1.82GB до 623MB - неплохо!

Оптимизация: Round 2

Запускаю Dive на оптимизированном образе:

dive my-app:optimized

Нахожу еще проблемы:

  • В node_modules остались dev-зависимости
  • Есть README файлы и лицензии (еще 50MB)
  • TypeScript типы (.d.ts файлы)

Продвинутая очистка node_modules

Создал скрипт cleanup-modules.sh:

#!/bin/sh
# Удаляем dev-зависимости
npm prune --production

# Удаляем ненужные файлы
find node_modules -name "*.md" -delete
find node_modules -name "*.markdown" -delete
find node_modules -name "LICENSE*" -delete
find node_modules -name "*.d.ts" -delete
find node_modules -name "*.js.map" -delete
find node_modules -name ".npmignore" -delete
find node_modules -name ".gitignore" -delete
find node_modules -type d -name "__tests__" -exec rm -rf {} +
find node_modules -type d -name "test" -exec rm -rf {} +
find node_modules -type d -name "tests" -exec rm -rf {} +
find node_modules -type d -name "docs" -exec rm -rf {} +
find node_modules -type d -name ".github" -exec rm -rf {} +

Финальный Dockerfile

FROM node:18-alpine AS builder

WORKDIR /app

# Устанавливаем только production зависимости
COPY package*.json ./
RUN npm ci --only=production && \
    npm cache clean --force

# Копируем исходники для сборки
COPY . .
RUN npm run build

# Чистим node_modules
COPY cleanup-modules.sh .
RUN chmod +x cleanup-modules.sh && \
    ./cleanup-modules.sh && \
    rm cleanup-modules.sh

# Удаляем все лишнее
RUN rm -rf src/ tests/ .next/ coverage/ \
    package-lock.json tsconfig.json \
    .eslintrc.js jest.config.js

# Production stage с distroless
FROM gcr.io/distroless/nodejs18-debian11

WORKDIR /app

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

EXPOSE 3000

CMD ["dist/index.js"]

Финальный результат:

docker build -t my-app:final .
dive my-app:final

Efficiency Score: 94% (Excellent)
Wasted Space: 12 MB (3% of image)
Image Size: 387MB

С 1.82GB до 387MB - уменьшили в 4.7 раза!

CI/CD интеграция

Dive можно использовать в CI для контроля размера образов:

# .gitlab-ci.yml
docker-size-check:
  stage: test
  script:
    - docker build -t $CI_PROJECT_NAME:$CI_COMMIT_SHA .
    - |
      CI=true dive $CI_PROJECT_NAME:$CI_COMMIT_SHA --ci-config .dive-ci.yaml

Файл .dive-ci.yaml:

rules:
  # Образ не должен быть больше 500MB
  highestUserWastedPercent: 0.10
  highestWastedBytes: 50000000
  # Эффективность должна быть выше 90%
  lowestEfficiency: 0.90

Практические советы

1. Ищите типичные проблемы

# В Dive нажмите Ctrl+F для поиска
.git/          # Забыли добавить в .dockerignore
.env           # Секреты в образе!
node_modules/  # В multi-stage должны копироваться выборочно
*.log          # Логи не нужны в образе
.cache/        # Различные кеши

2. Проверяйте историю команд

Часто в образах остается:

/root/.bash_history
/root/.ash_history
/home/user/.zsh_history

3. Анализируйте крупные файлы

В Dive файлы сортируются по размеру. Ищите:

  • Дампы баз данных
  • Большие JSON/XML файлы
  • Архивы и бэкапы
  • Медиафайлы

4. Multi-stage паттерны

# Плохо - все зависимости в финальном образе
FROM node:18
RUN npm install
RUN npm run build

# Хорошо - только необходимое
FROM node:18 AS builder
RUN npm install
RUN npm run build

FROM node:18-alpine
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

Альтернативы и дополнения

container-diff

Google’овский инструмент для сравнения образов:

container-diff diff image1 image2 --type=file

docker-slim

Автоматическая оптимизация образов:

docker-slim build --target my-app:latest

hadolint

Линтер для Dockerfile:

hadolint Dockerfile

Чек-лист оптимизации

  • Используете multi-stage сборку?
  • Очищаете кеши пакетных менеджеров?
  • Удаляете временные файлы в том же слое?
  • Минимизируете количество слоев?
  • Используете .dockerignore?
  • Проверили на наличие секретов?
  • Удалили исходники после сборки?
  • Рассмотрели distroless/alpine образы?

Результаты в production

После оптимизации всех наших образов:

  • Registry освободилось на 65%
  • Деплой стал быстрее в 3 раза
  • Уменьшилось время холодного старта в k8s
  • Снизились расходы на трафик

Заключение

Dive - это must-have инструмент для работы с Docker. Он помогает понять, что происходит внутри образа и найти возможности для оптимизации.

В моем случае проблема была в непонимании того, как работает node-gyp и какие файлы он оставляет после себя. Dive сделал эту проблему видимой и помог уменьшить образ почти в 5 раз.

Рекомендую добавить проверку размера образов в ваш CI/CD pipeline и регулярно анализировать их с помощью Dive. Это поможет держать инфраструктуру в порядке и экономить ресурсы.

Полезные ссылки