Почему оптимизация изображений критична для DevOps
Прямой ответ: держите исходники в одном качественном формате, один раз генерируйте WebP и AVIF рядом с оригиналом, отдавайте клиенту тот вариант, который он поддерживает по заголовку Accept, и обязательно кэшируйте результат. Такая схема сокращает объём хранилища на 30-70% и снимает основную нагрузку с CPU, потому что один и тот же файл не конвертируется при каждом запросе.
Изображения занимают 50-70% веса типовой страницы. Каталог из 200 000 товарных фото по 400 КБ в JPEG занимает около 76 ГБ на диске, и примерно столько же уходит на резервные копии и на кэш в CDN. Те же файлы в WebP дают около 50 ГБ, в AVIF около 38 ГБ. Скидка 40% на бэкапах такого объёма освобождает десятки гигабайт без единой правки контента.
Трафик считается так же быстро. Показ изображения на 400 КБ миллион раз в месяц расходует 400 ГБ канала, версия в AVIF на 180 КБ укладывается в 180 ГБ. Плюс браузер быстрее отрисовывает первый экран, а сервер меньше времени тратит на передачу данных.
Ручная перегонка файлов в графическом редакторе не масштабируется на каталог в десятки тысяч позиций. Дальше речь о том, что настраивает DevOps: сравнение JPEG, WebP, AVIF, HEIC и PNG, подбор качества сжатия, раздача вариантов через Nginx, imgproxy и Thumbor, генерация превью и адаптивных версий, а также шесть ошибок, которые чаще всего ломают продакшен.
Выбор формата изображений: JPEG, WebP, AVIF, HEIC
JPEG остаётся форматом совместимости: его понимают все браузеры и почти все мобильные клиенты. WebP даёт выигрыш 25-35% при сопоставимом визуальном качестве и поддерживается всеми актуальными браузерами. AVIF экономит ещё больше, 40-50% относительно JPEG, но требует более свежих версий браузеров и заметно больше процессорного времени при кодировании. HEIC удобен для съёмки на iPhone, для веба он не подходит: вне Safari поддержки почти нет, файлы приходится конвертировать. PNG держит точную графику и прозрачность, зато на фотографиях проигрывает по размеру в 3-10 раз.
Сравнительная таблица форматов
| Формат | Тип сжатия | Прозрачность | Анимация | Поддержка браузерами | Экономия относительно JPEG |
|---|---|---|---|---|---|
| JPEG | с потерями | нет | нет | все | базовая линия |
| PNG | без потерь, есть квантование палитры | да, альфа-канал | да, APNG | все | для фото размер больше в 3-10 раз |
| WebP | с потерями и без потерь | да | да | Chrome 32+, Firefox 65+, Safari 14+ | 25-35% |
| AVIF | с потерями и без потерь | да | да | Chrome 85+, Firefox 93+, Safari 16.1+ | 40-50% |
| HEIC/HEIF | с потерями и без потерь | да | да, последовательность | Safari на платформах Apple, вне Apple почти нет | 30-50%, в вебе не используется |
| SVG | вектор, без потерь | да | да, SMIL и CSS | все | для иконок меньше в разы |
Версии в таблице минимальные для стабильной работы. Перед запуском посмотрите логи User-Agent: если 2-3% посетителей сидят на старых клиентах, им нужен fallback в JPEG, иначе часть каталога останется без картинок.
Когда какой формат выбирать
- Фотографии, баннеры, обложки: AVIF как основной вариант, WebP и JPEG в fallback. На каталоге из 200 000 файлов экономия доходит до 30 ГБ.
- Иконки, логотипы, простые схемы: SVG. Он масштабируется без потерь и весит единицы килобайт, растровые копии нужны только для писем и старых клиентов.
- Скриншоты интерфейса и изображения с текстом: PNG с квантованием палитры или WebP без потерь. Сжатие с потерями даёт мыло вокруг букв.
- Анимация: WebP или AVIF вместо GIF. Анимированный WebP легче GIF в 3-10 раз, но часть старых клиентов его не покажет.
- Архив исходников: PNG, TIFF или JPEG q95+. Из мастер-файла генерируются все производные.
При автоматической конвертации выбирать один формат не нужно. Держите исходник в PNG или JPEG, а сервис обработки либо nginx отдаст лучший из поддерживаемых клиентом вариантов.
Настройка сжатия без потери качества
Сжатие с потерями убирает детали, которые глаз почти не различает, и уменьшает файл в разы. Сжатие без потерь сохраняет пиксели бит в бит, но на фотографиях экономит 5-15%. Практическое правило: фотографии сжимайте с потерями, графику с текстом, скриншоты интерфейсов и логотипы - без потерь или с качеством выше 90.
Границы качества: JPEG 80-85, WebP 75-80, AVIF 60-70. У JPEG ниже 70 появляются блочные артефакты на градиентах неба и ореолы вокруг шрифта. У WebP критическая зона начинается около 60, у AVIF около 45, но AVIF кодируется медленно, поэтому в пайплайне генерации ставьте speed 4-6, а не максимальное качество любой ценой.
Практические рекомендации по параметрам
| Формат | Качество | Экономия против JPEG q90 | Инструмент |
|---|---|---|---|
| JPEG | 80-85, прогрессивный | 20-30% | mozjpeg (cjpeg), jpegoptim |
| WebP | 75-80 | 25-35% | cwebp |
| AVIF | 60-70, cq-level 20-30 | 40-50% | avifenc, libaom |
| PNG | без потерь или квантование | 40-70% на скриншотах | oxipng, pngquant |
cwebp -q 80 -m 6 -metadata icc input.jpg -o output.webp avifenc --min 10 --max 30 --speed 4 input.png output.avif cjpeg -quality 82 -progressive -optimize input.ppm > output.jpg jpegoptim --all-progressive -m85 *.jpg pngquant --quality=70-85 --speed 1 --strip input.png oxipng -o 4 --strip safe input.png
Проверяйте результат метриками, а не только глазами на одном файле. SSIM и PSNR считаются быстро и подходят для автоматического контроля в пайплайне: порог SSIM 0.98 к исходнику отсекает брак. Метрика Butteraugli ближе к восприятию, но дороже по времени. Возьмите 10-15 типовых изображений (портрет, пейзаж, текст, товар на белом фоне) и сравните их попарно в слайдере.
Два предупреждения. Не сжимайте повторно: перекодирование готового WebP в JPEG или генерация второго поколения AVIF копит артефакты, поэтому производные всегда строятся от мастер-файла. И не прогоняйте уже сжатые изображения через gzip или brotli, это тратит CPU без выигрыша; как проверить корректность сжатия текстовых ресурсов, разобрано в статье про анализ gzip-сжатия.
Автоматическая конвертация на лету: Nginx, imgproxy, Thumbor
Подходов два. Первый: сгенерировать WebP и AVIF заранее, при загрузке файла или на этапе сборки, и раздавать их как статику. Второй: конвертировать на лету отдельным сервисом. Для каталогов в сотни тысяч файлов первый дешевле по CPU, второй гибче: любой размер и кроп доступны по URL без хранения всех вариантов.
Nginx: конвертация в WebP и AVIF
Штатный модуль ngx_http_image_filter_module умеет ресайз, кроп и поворот JPEG, PNG и GIF, но не отдаёт WebP и AVIF. Для этих форматов нужны сторонние модули или внешний сервис, поэтому nginx обычно работает как раздающий слой с выбором варианта по заголовку Accept.
map $http_accept $img_suffix {
default "";
'~*image/avif' '.avif';
'~*image/webp' '.webp';
}
server {
location ~* ^/images/(.+)\.(jpe?g|png)$ {
add_header Vary Accept;
try_files /images/$1$img_suffix $uri =404;
}
}
Директиву map держат в контексте http. Порядок строк важен: map берёт первое совпадение, поэтому AVIF проверяется раньше WebP, ведь браузеры с поддержкой AVIF обычно перечисляют и image/webp.
Что проверить перед выкаткой. В mime.types должны быть строки image/webp и image/avif, иначе nginx отдаст файл как application/octet-stream. Заголовок Vary: Accept обязателен: без него промежуточный кэш или CDN однажды отдаст AVIF клиенту, который его не понимает. Учтите, что add_header не наследуется в location, где объявлен свой add_header, поэтому строки кэширования придётся продублировать. Готовые блоки location с Cache-Control, immutable и stale-while-revalidate для картинок собраны в статье про кеширование статических файлов в Nginx.
imgproxy: быстрый старт и настройка
imgproxy - отдельный сервис на Go, обработка идёт через libvips. Он читает файлы из S3-совместимого хранилища, по локальному пути или по внешнему URL, отдаёт JPEG, PNG, WebP, AVIF и GIF, умеет умный кроп и подпись ссылок.
docker run -d --name imgproxy \ -p 8080:8080 \ -e IMGPROXY_KEY=736563726574 \ -e IMGPROXY_SALT=686561727473 \ -e IMGPROXY_ENABLE_WEBP_DETECTION=true \ -e IMGPROXY_ENABLE_AVIF_DETECTION=true \ -e IMGPROXY_CACHE_SIZE=536870912 \ -e IMGPROXY_TTL=31536000 \ darthsim/imgproxy:v3
Адрес строится по схеме /{подпись}/{опции}/{гравитация}/{источник}@{расширение}. Режим /insecure/ разрешён по умолчанию и годится только для локальных тестов: он позволяет любому собрать ссылку на ресайз, поэтому в продакшене задайте IMGPROXY_KEY и IMGPROXY_SALT (шестнадцатеричные строки) и подписывайте URL.
# локальный тест, без подписи http://localhost:8080/insecure/rs:fill:600:400/plain/s3://bucket/photo.jpg@avif # продакшен, подпись в первом сегменте http://img.example.com/K1y2Abc.../rs:fill:600:400/plain/s3://bucket/photo.jpg@webp
Детекция формата включается переменными IMGPROXY_ENABLE_WEBP_DETECTION и IMGPROXY_ENABLE_AVIF_DETECTION в версиях 3.x: imgproxy сам читает Accept, подставляет формат и добавляет в ответ Vary: Accept. Кэш задаётся IMGPROXY_CACHE_SIZE (размер в байтах, 536870912 это 512 МБ) и IMGPROXY_TTL. Локальный кэш живёт в памяти одного процесса, поэтому при нескольких репликах выносите его на общий диск, в S3 или на CDN, иначе каждый под будет конвертировать одно и то же заново. Метрики доступны на /metrics в формате Prometheus, там видно время обработки по форматам.
Развернуть imgproxy можно в контейнере на VPS или в managed Kubernetes: например, в Timeweb Cloud есть серверы, Kubernetes и объектное хранилище под исходники, что закрывает всю схему без собственного железа.
Thumbor: конфигурация и особенности
Thumbor написан на Python и работает через Pillow или libvips. Его сильная сторона - фильтры и умный кроп на OpenCV, слабая - расход памяти и число зависимостей.
pip install thumbor thumbor-config > thumbor.conf thumbor --port=8888 --conf=thumbor.conf
QUALITY = 80 WEBP_QUALITY = 80 AUTO_WEBP = True # AUTO_AVIF = True # Thumbor 7 и новее при сборке с поддержкой AVIF SECURITY_KEY = 'длинная-случайная-строка' ALLOW_UNSAFE_URLS = False RESULT_STORAGE = 'thumbor.result_storages.file_storage' RESULT_STORAGE_FILE_STORAGE_ROOT_PATH = '/var/lib/thumbor/result_storage'
URL-схема: /{подпись}/{ширина}x{высота}/{режим}/{путь}. Подпись считается как HMAC-SHA1 от пути с SECURITY_KEY, поэтому в продакшене ALLOW_UNSAFE_URLS выключают, а /unsafe/ оставляют для локальных прогонов.
# локальный тест: умный кроп 300x300 http://localhost:8888/unsafe/300x300/smart/images/photo.jpg # продакшен: подпись и формат http://thumbor.example.com/AbCdEf.../600x0/filters:format(webp)/images/photo.jpg
Что учесть. AUTO_WEBP включает отдачу WebP по Accept. Поддержка AVIF появилась в Thumbor 7 и зависит от сборки: проверяйте свой стенд тестовым запросом с filters:format(avif), а не только номером версии. Результаты конвертации складывайте в RESULT_STORAGE (файлы, Redis, Memcached): без него каждый запрос уходит в Pillow и выедает ядро целиком. Планируйте лимиты памяти: Python-воркер с большим изображением легко занимает сотни мегабайт.
Генерация превью и адаптивных версий
Смысл превью в том, чтобы не гонять оригинал на 3000 пикселей на экран шириной 390. Рабочий минимум для фотографий: 320, 640, 960, 1280 и 1920 пикселей по ширине. Для карточек товара в сетке хватает двух размеров, 300 и 600.
Исходник храните в максимальном качестве, производные генерируйте один раз и раскладывайте рядом либо отдавайте через imgproxy с дисковым кэшем. Если превью собираются на этапе сборки статики, момент и место генерации зависят от выбранной модели рендеринга: разбор SSR, SSG и гибридных схем есть в материале про стратегии рендеринга и пайплайн данных. Ленивую загрузку картинок ниже первого экрана проще всего включить атрибутом loading="lazy", а для сценариев с подгрузкой по скроллу пригодится связка с Intersection Observer из статьи про динамическую загрузку контента.
Автоматический ресайз и кроп
# imgproxy: вписать в 600x400 без обрезки /rs:fit:600:400/plain/s3://bucket/photo.jpg@webp # imgproxy: заполнить 600x400 с обрезкой и умной гравитацией /rs:fill:600:400/gravity:sm/plain/s3://bucket/photo.jpg@avif # Thumbor: умный кроп 300x300 /unsafe/300x300/smart/images/photo.jpg # Thumbor: ограничение по высоте и формат /unsafe/0x400/filters:format(webp)/images/photo.jpg
Режим fill обрезает изображение до точных пропорций, fit вписывает целиком и оставляет поля. Умная гравитация (gravity:sm в imgproxy, режим smart у Thumbor) ищет лица и контрастные области. Работает это не всегда: на фото товара с мелкой деталью алгоритм срежет именно её, а на схеме выберет неверный фокус. Для критичных карточек задавайте точку фокуса вручную или храните координаты в базе.
Адаптивную выдачу делает разметка, а не сервер:
<picture>
<source type="image/avif" srcset="/img/photo-600.avif 600w, /img/photo-1200.avif 1200w"
sizes="(max-width: 600px) 100vw, 600px">
<source type="image/webp" srcset="/img/photo-600.webp 600w, /img/photo-1200.webp 1200w"
sizes="(max-width: 600px) 100vw, 600px">
<img src="/img/photo-600.jpg" srcset="/img/photo-600.jpg 600w, /img/photo-1200.jpg 1200w"
sizes="(max-width: 600px) 100vw, 600px" width="600" height="400"
alt="Товарное фото" loading="lazy" decoding="async">
</picture>
Атрибуты width и height заранее резервируют место под картинку и убирают сдвиг вёрстки, из-за которого растёт CLS. Без sizes браузер скачает самый широкий вариант или ошибётся с выбором, а srcset без дескрипторов ширины работает только с плотностью пикселей.
Типичные ошибки и как их избежать
- Двойное сжатие. Повторное кодирование WebP в JPEG или новый WebP из прежнего копит артефакты. Стройте производные от мастер-файла, а не от другой производной.
- Потеря EXIF и ориентации. Утилиты со --strip-all удаляют тег Orientation, и вертикальные снимки с телефона ложатся на бок. Перед сжатием применяйте авто-ориентацию (imgproxy делает это по умолчанию) или копируйте метаданные: jpegtran -copy all. Если EXIF нужен для аналитики, вырезайте только GPS, а не весь блок.
- Некорректный Content-Type. Файл .webp, отданный как application/octet-stream, часть браузеров скачает вместо показа. Проверьте mime.types и ответ командой curl -I.
- Отсутствие кэша. imgproxy и Thumbor без кэша и лимита воркеров кладут CPU на потоке однотипных запросов. Настройте кэш, TTL и ограничение параллельных задач.
- Прозрачность при конвертации в JPEG. Альфа-канал превращается в чёрный или белый фон. Накладывайте изображение на нужный цвет перед переводом в JPEG или отдавайте такие файлы в PNG и WebP.
- Игнорирование Accept и отсутствие Vary. Отдача AVIF всем подряд или кэширование без Vary: Accept ломает картинки у части клиентов. Решение: выбор формата по Accept, заголовок Vary: Accept и JPEG в fallback.
Сравнение инструментов: что выбрать для вашего стека
| Критерий | Nginx | imgproxy | Thumbor |
|---|---|---|---|
| Форматы на выходе | JPEG, PNG, GIF, WebP и AVIF только через сторонние модули | JPEG, PNG, WebP, AVIF, GIF | JPEG, PNG, WebP, AVIF в версии 7 |
| Обработка на лету | ресайз, кроп, поворот | полная, включая умный кроп | полная, богатый набор фильтров |
| Зависимости | нет | один статический бинарник или Docker | Python, Pillow или libvips, OpenCV |
| Нагрузка на CPU | минимальная, работает со статикой | средняя, libvips экономичен | выше, Python-воркеры |
| Масштабирование | вместе с nginx | stateless, легко | нужен общий кэш результатов |
| Подпись URL | нет | IMGPROXY_KEY и IMGPROXY_SALT | SECURITY_KEY |
| Сложность запуска | низкая | средняя | высокая |
Рекомендации по стеку. Если nginx уже раздаёт статику и нужен только WebP, хватит предварительной генерации вариантов и map по Accept. Если нужны AVIF, кроп и ресайз по требованию, ставьте imgproxy: он дешевле по ресурсам и проще масштабируется горизонтально. Если требуются фильтры, водяные знаки и умный кроп на OpenCV, а Python в инфраструктуре привычен, берите Thumbor.
Интеграция почти всегда одинаковая: сервис обработки живёт за reverse proxy, исходники лежат в S3-совместимом хранилище, готовые ответы кэширует CDN. В Docker Compose это два контейнера, в Kubernetes - Deployment плюс Service и HorizontalPodAutoscaler по CPU. Учитывайте два момента: кэш должен быть общим для всех реплик, а ключ подписи хранится в секретах, а не открытой переменной в манифесте.
Заключение
Соберите схему в таком порядке:
- Мастер-файлы в PNG или JPEG q95+ с сохранённой ориентацией, отдельно от производных.
- Форматы выдачи: AVIF первым, WebP вторым, JPEG как fallback для старых клиентов.
- Качество: JPEG 80-85, WebP 75-80, AVIF 60-70, проверка по SSIM и визуальному сравнению на 10-15 типах изображений.
- Раздача: nginx с map по Accept и заголовком Vary: Accept либо imgproxy с IMGPROXY_ENABLE_AVIF_DETECTION, подписью URL и общим кэшем.
- Превью: 320, 640, 960, 1280 и 1920 пикселей, srcset и sizes в разметке, width и height для стабильной вёрстки.
Проверьте результат в трёх браузерах (Chromium, Firefox, Safari), на мобильном экране и командой curl -I для контроля Content-Type, Vary и Cache-Control. Одна такая проверка до релиза дешевле, чем разбор жалоб на чёрные квадраты вместо фотографий.