MAATRIX / Блог / imgproxy снимает ресайз с приложения и добавляет новый узел в схему

imgproxy снимает ресайз с приложения и добавляет новый узел в схему

MAATRIX

Если приложение ресайзит картинки прямо в коде — через ImageMagick, Sharp или Pillow — рано или поздно CPU начинает упираться именно в это, а не в бизнес-логику. imgproxy решает конкретную задачу: обработку изображений «на лету» по URL-параметрам, вне процесса приложения. Но это не бесплатная магия — вы меняете нагрузку на бэкенд на новый сервис, который нужно разворачивать, кэшировать и защищать, и честно разобраться, когда этот обмен того стоит, важнее, чем просто установить пакет.

Что делает imgproxy и зачем разгружать приложение от ресайза

Типичная схема без imgproxy: пользователь загружает фото, бэкенд принимает файл, сразу или по требованию прогоняет его через sharp (Node.js) или Pillow/ImageMagick (Python/PHP), генерирует несколько размеров (thumbnail, preview, full), сохраняет на диск или в S3. Каждый ресайз — это декодирование картинки, изменение размера, повторное кодирование в JPEG/WebP, и всё это в том же процессе, что обслуживает HTTP-запросы. На малой нагрузке разницы не видно. На средней — становится заметно, что при массовой загрузке фотографий API начинает отвечать медленнее не из-за базы данных, а из-за того, что воркеры заняты пережатием JPEG.

imgproxy — открытый (есть и платная Pro-версия с дополнительными фичами) сервис на Go, который делает ровно одну вещь: принимает URL с параметрами обработки, скачивает исходное изображение (с диска, из S3-совместимого хранилища или по HTTP), применяет ресайз/кроп/конвертацию и отдаёт результат. Никакой базы данных, никакой бизнес-логики — просто HTTP-сервис для трансформации картинок. Логика в приложении сводится к формированию правильного URL вместо вызова библиотеки ресайза.

Ключевое отличие от «встроенного» ресайза:

  • Приложение больше не декодирует и не кодирует изображения само. Эта работа (CPU- и memory-intensive) уходит в отдельный процесс, который можно масштабировать независимо.
  • Варианты размеров не нужно хранить заранее. Вместо генерации thumbnail/preview/full при загрузке вы храните один оригинал, а нужный размер imgproxy строит по запросу и кэширует результат.
  • Формат подбирается под клиента. imgproxy умеет отдавать WebP или AVIF автоматически, если браузер их поддерживает (по заголовку Accept), без отдельной логики в приложении.

Это не единственный инструмент такого рода — есть Thumbor (Python, старше и тяжелее), публичный сервис wsrv.nl (бесплатный прокси-ресайзер без самостоятельного хостинга), платные Cloudinary и Cloudflare Images. imgproxy выделяется тем, что это компактный бинарник на Go без рантайм-зависимостей: разворачивается одним Docker-контейнером и не требует администрирования кроме конфигурации через переменные окружения.

URL-параметры вместо кода: как это выглядит на практике

Вместо того чтобы бэкенд вызывал sharp(input).resize(300, 300).toFormat('webp'), он просто формирует ссылку на imgproxy с нужными параметрами прямо в HTML или JSON-ответе API. Базовый формат URL:

http://imgproxy.internal:8080/{signature}/{processing_options}/{encoded_source_url}

{processing_options} — это цепочка модификаторов через слэш, например:

  • rs:fill:300:300 — resize с типом fill (обрезка под точный размер) до 300×300;
  • g:sm — гравитация smart (imgproxy сам ищет «интересную» область кадра для кропа);
  • q:80 — качество сжатия 80;
  • f:webp — принудительный формат WebP.

Пример полного пути обработки (в «незащищённом» режиме, без подписи, — только для локальной отладки, в проде так делать нельзя):

http://localhost:8080/insecure/rs:fill:300:300/g:sm/q:80/f:webp/plain/https://cdn.example.com/uploads/photo.jpg

imgproxy поддерживает и «plain»-формат исходного URL (как в примере выше — просто https://... после plain/), и классический вариант с base64url-кодированием ссылки — это удобно, когда в исходном URL есть символы, которые ломают путь. В своей реализации ориентируйтесь на актуальную документацию imgproxy на GitHub: набор опций обработки (кроп, водяные знаки, паддинг, повороты, размытие лиц) периодически расширяется.

На стороне бэкенда правки минимальны: там, где раньше был вызов библиотеки ресайза, теперь функция, которая собирает URL с нужными опциями и, если включена подпись, считает HMAC-подпись пути — небольшая прослойка кода, но в сотни раз дешевле по CPU, чем реальное декодирование JPEG.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Деплой imgproxy в docker-compose и базовая конфигурация

imgproxy официально распространяется как Docker-образ darthsim/imgproxy (это организация автора проекта). Минимальный docker-compose.yml для отдельного сервиса:

services:
  imgproxy:
    image: darthsim/imgproxy:latest
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      IMGPROXY_BIND: ":8080"
      IMGPROXY_KEY: "${IMGPROXY_KEY}"
      IMGPROXY_SALT: "${IMGPROXY_SALT}"
      IMGPROXY_TTL: "2592000"
      IMGPROXY_MAX_SRC_RESOLUTION: "20"
      IMGPROXY_MAX_SRC_FILE_SIZE: "20971520"
      IMGPROXY_ALLOWED_SOURCES: "https://cdn.example.com/,https://storage.example.com/"
    deploy:
      resources:
        limits:
          cpus: "2"
          memory: 1g

Пояснения по ключевым переменным (полный список — в документации imgproxy, он большой и меняется от релиза к релизу):

  • IMGPROXY_KEY и IMGPROXY_SALT — hex-строки для подписи URL (генерируются, например, openssl rand -hex 32). Без них сервис работает в «insecure»-режиме — это допустимо только за закрытым периметром.
  • IMGPROXY_TTL — время жизни Cache-Control в заголовке ответа, в секундах.
  • IMGPROXY_MAX_SRC_RESOLUTION — ограничение на разрешение исходного изображения в мегапикселях, чтобы кто-то не положил сервис, подсунув гигантский TIFF.
  • IMGPROXY_MAX_SRC_FILE_SIZE — лимит размера исходного файла в байтах.
  • IMGPROXY_ALLOWED_SOURCES — белый список источников, откуда imgproxy разрешено скачивать оригиналы (подробнее — в разделе про безопасность).

Если оригиналы лежат в S3-совместимом хранилище (MinIO, Selectel, Yandex Object Storage), можно подключить его напрямую через IMGPROXY_USE_S3 и IMGPROXY_S3_ENDPOINT вместо HTTP-скачивания — быстрее, потому что трафик идёт по внутренней сети. Разворачивание такого хранилища разбирали отдельно в статье про MinIO на VPS.

Проверка после запуска:

curl -I "http://localhost:8080/insecure/rs:fill:100:100/plain/https://cdn.example.com/test.jpg"

Ответ 200 с Content-Type: image/jpeg (или webp/avif, если добавить f:webp) означает, что сервис поднялся и видит источник.

Новый узел в схеме: что вы на самом деле добавляете в инфраструктуру

Здесь начинается честная часть. imgproxy решает проблему CPU в приложении, но не исчезает бесследно — он становится новым звеном в цепочке запроса.

Новая точка отказа. Раньше картинка отдавалась статикой (или из того же приложения). Теперь между пользователем и картинкой появляется ещё один процесс: если imgproxy упал, недоступен по сети или перегружен — картинки перестают открываться, даже если само приложение работает штатно. Нужен restart: unless-stopped (или systemd/supervisor-эквивалент), мониторинг доступности порта 8080 и план на случай деградации — например, отдавать оригинал напрямую, если imgproxy не отвечает.

CPU и память переезжают, а не исчезают. Ресайз JPEG 4000×3000 всё так же требует декодирования и кодирования — просто теперь этим занят контейнер imgproxy, а не воркер приложения. Если у вас один общий сервер, выигрыш будет только в изоляции («приложение отвечает быстро, картинки медленно», а не «всё тормозит одновременно»), но не в суммарной нагрузке. Реальную разгрузку CPU вы получаете, только если imgproxy стоит на отдельном сервере/контейнере с собственными ресурсами, либо если задача — снять пиковую нагрузку с процесса, который также держит соединения с базой и внешние API.

Диск или память под кэш. Без кэширования каждый уникальный набор параметров ресайза пересчитывается заново при каждом запросе — расточительно, если одну и ту же миниатюру запрашивают тысячи раз в день. imgproxy сам кэш не хранит (это не CDN), он лишь выставляет заголовки Cache-Control/Expires, а кэшировать должен слой перед ним — nginx с proxy_cache, внешний CDN или browser cache. То есть нужен ещё и дисковый кэш на прокси-слое, который тоже надо мониторить на заполнение диска.

Сетевой путь усложняется. Раньше: клиент → приложение → диск/S3. Теперь: клиент → nginx/CDN → imgproxy → S3/HTTP-источник. Каждое звено — дополнительная задержка на первый (некэшированный) запрос и точка, которую нужно логировать при разборе инцидентов «почему картинка не грузится».

Итого: imgproxy не убирает работу по ресайзу из системы — он локализует её в отдельный, специализированный, горизонтально масштабируемый сервис. Это оправдано, когда вам нужна независимая масштабируемость (реплики imgproxy за балансировщиком, без изменений в приложении) или изоляция нагрузки. Для одного маленького VPS и сотни картинок в день это не разгрузка, а усложнение ради усложнения.

nginx и CDN перед imgproxy: кто и что кэширует

В проде imgproxy почти никогда не выставляют напрямую в интернет — перед ним ставят nginx (для TLS-терминации, кэширования и ограничения доступа) и опционально внешний CDN. Пример конфигурации nginx с проксирующим кэшем:

proxy_cache_path /var/cache/nginx/imgproxy levels=1:2 keys_zone=imgproxy_cache:50m
                  max_size=5g inactive=30d use_temp_path=off;

server {
    listen 443 ssl;
    server_name img.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_cache imgproxy_cache;
        proxy_cache_valid 200 30d;
        proxy_cache_valid 404 5m;
        proxy_cache_key $uri;
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
        add_header X-Cache-Status $upstream_cache_status;
    }
}

proxy_cache_key $uri работает, потому что путь imgproxy уже содержит все параметры обработки (размер, качество, формат) — одинаковый набор параметров даёт одинаковый URL и одинаковый ключ кэша. Заголовок X-Cache-Status полезен на диагностике: HIT значит, что nginx отдал закэшированную версию, не дёргая imgproxy вообще, а MISS/EXPIRED — что запрос дошёл до сервиса обработки.

Если у вас уже есть внешний CDN перед сайтом, логичнее кэшировать результат imgproxy именно там — тогда повторные запросы вообще не доходят до вашего сервера. Общие принципы работы CDN разобраны в статье про CDN изнутри; настройку кэширования на nginx — в статье про кэширование nginx на VPS. Комбинация «CDN → nginx-кэш → imgproxy → хранилище» избыточна для маленького проекта, но оправдана, когда трафик на изображения ощутимо больше трафика на сам HTML/API.

Отдельный нюанс: TTL кэша на разных уровнях (CDN, nginx, IMGPROXY_TTL, браузер) должны быть согласованы. Если вы поменяли логику обработки (например, дефолтное качество), но старые URL с теми же параметрами ещё в кэше на недели вперёд — пользователи увидят старый результат, пока кэш не протухнет сам или вы не сделаете принудительную инвалидацию (ручной purge для CDN, rm в директории кэша или смена proxy_cache_key для nginx).

Безопасность: подпись URL, доверенные источники, лимиты

Три вещи, которые нельзя пропускать при выводе imgproxy за пределы локальной сети:

Подпись URL (IMGPROXY_KEY/IMGPROXY_SALT). Без неё любой человек, знающий адрес вашего imgproxy, может подставить произвольный URL как источник и заставить ваш сервер скачать и обработать что угодно — это путь к злоупотреблению вычислительными ресурсами и к SSRF, если параметр источника не ограничен.

IMGPROXY_ALLOWED_SOURCES. Даже с подписью URL стоит явно перечислить домены/префиксы, откуда imgproxy разрешено брать оригиналы. Без этого ограничения подписанный URL теоретически может указывать на http://169.254.169.254/... (метаданные облачного провайдера) или на внутренний IP вашей сети — сервис послушно сходит и туда, потому что для него это просто HTTP-запрос.

Лимиты на размер и разрешение источника (IMGPROXY_MAX_SRC_FILE_SIZE, IMGPROXY_MAX_SRC_RESOLUTION) защищают от простого DoS: скормить сервису картинку 30000×30000 пикселей и занять весь CPU и память на декодирование может кто угодно, если лимитов нет.

Дополнительно держите imgproxy не в публичном интернете напрямую, а за nginx/reverse-proxy с собственным TLS, слушая только на 127.0.0.1, как в примере docker-compose выше. Принцип работы reverse-proxy как прослойки между внешним миром и внутренним сервисом разобран в статье про то, как работает reverse proxy.

Когда это того стоит, а когда — лишняя сложность

imgproxy оправдан, если верно хотя бы одно из условий:

  • у вас много уникальных вариантов обработки одной картинки (разные размеры под экраны, разные форматы под браузеры, разные кропы под виджеты) — генерировать их все заранее при загрузке дорого и негибко;
  • CPU приложения ощутимо тратится на декодирование/кодирование изображений, и это видно в профилировании, а не предполагается «на глаз»;
  • нужно масштабировать обработку изображений независимо от остального приложения (реплики за балансировщиком, пока бэкенд остаётся на одном инстансе);
  • меняется дизайн и нужно быстро поменять политику ресайза для всего каталога без миграции уже загруженных файлов.

Не стоит вводить imgproxy, если у вас единичные, редко меняющиеся размеры картинок (например, только «оригинал» и «миниатюра 200×200») — сгенерировать оба варианта один раз при загрузке проще и надёжнее, чем держать отдельный сервис; если нагрузка небольшая и текущий ресайз в приложении не создаёт заметных задержек; или если у вас нет ресурсов сопровождать ещё один сервис (мониторинг, обновления образа, настройка кэша) — тогда лучше меньше движущихся частей.

Средний путь, который часто недооценивают: если картинок немного и трафик умеренный, можно обойтись без отдельного сервиса и просто генерировать 2–3 фиксированных размера при загрузке штатной библиотекой — imgproxy решает проблему масштаба и гибкости, а не заменяет здравый смысл там, где масштаба нет.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

imgproxy работает с S3 напрямую или только по HTTP?

Поддерживает оба варианта: можно указать HTTP(S)-URL источника, а можно настроить прямой доступ к S3-совместимому хранилищу через IMGPROXY_USE_S3 — тогда трафик идёт по внутренней сети.

Нужно ли хранить оригиналы отдельно от обработанных версий?

Да, imgproxy не заменяет хранилище — он читает оригинал из источника (диск, S3, HTTP) и отдаёт обработанную версию «на лету», ничего не сохраняя сам по себе (кэширование — задача слоя перед ним, как nginx или CDN).

Что будет, если imgproxy упадёт?

Все URL, которые на него ссылаются, перестанут отдавать изображения, пока сервис не поднимется снова. Поэтому в проде нужны политика restart, мониторинг и, желательно, план деградации.

Можно ли использовать imgproxy без подписи URL?

Технически да (/insecure/...), но это допустимо только за закрытым периметром — в публичном доступе без подписи сервис превращается в открытый ресайзер и вектор SSRF/DoS.

imgproxy заменяет CDN?

Нет: imgproxy обрабатывает изображение по параметрам, CDN раздаёт готовый результат географически близко к пользователю и кэширует его. Их часто используют вместе.

Сколько ресурсов закладывать под imgproxy?

Зависит от объёма и размера исходных изображений и от того, сколько запросов реально долетает мимо кэша до сервиса — универсальных цифр здесь нет, ориентируйтесь на профилирование своей нагрузки, а не на чужие бенчмарки.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →