imgproxy в production: обработка изображений на лету, S3, кеширование и подпись URL

Как вынести resize и конвертацию картинок в WebP/AVIF в отдельный сервис: подписанные URL, кеш перед imgproxy, грабли деплоя и реальные цифры — на примере khorost.tech

Эта статья выросла из практической задачи на самом khorost.tech. PageSpeed на мобильных упирался в вес картинок: обложки статей лежали в S3 как PNG по 0.5–3 МБ, и каждая новая публикация добавляла к странице мегабайты. Готовить вручную уменьшенные превью (_pre.png рядом с оригиналом) — это два файла на картинку и ручная дисциплина, которая рано или поздно ломается.

Альтернатива — вынести resize, конвертацию форматов и оптимизацию из процесса публикации в отдельный HTTP-сервис. Им стал imgproxy: он получает оригинал, обрабатывает его по параметрам из URL и отдаёт результат. В контенте остаётся ссылка на один оригинал, а нужный размер и формат (WebP/AVIF) выбираются на лету.

Ниже — как это устроено в production: архитектура, подпись URL, Docker Compose, кеширование, безопасность и грабли, на которые мы наступили.

imgproxy между браузером, кеширующим прокси и S3

В статье

Когда imgproxy оправдан, а когда нет

Отдельный сервис обработки изображений имеет смысл, когда:

  • нужны динамические размеры и разные соотношения сторон;
  • нужны несколько форматов (WebP/AVIF/JPEG) и плотностей пикселей;
  • оригиналов много, и готовить варианты заранее дорого;
  • обработку хочется масштабировать независимо от приложения.

И наоборот — отдельный CPU-intensive сервис избыточен, когда:

  • все ассеты известны на этапе сборки и их немного (проще обработать их в CI);
  • нет ни динамических размеров, ни пользовательских загрузок;
  • CDN уже умеет нужную трансформацию по запросу;
  • команда не готова эксплуатировать ещё один сервис.

Честно говоря, блог — пограничный случай: главный выигрыш у нас не в «динамических размерах», а в конвертации формата на лету (PNG → WebP/AVIF) и в том, что публикация перестала требовать ручной подготовки превью.

Архитектура

flowchart LR G["Генератор сайта / приложение"] -->|"подписанный URL"| B["Браузер"] B -->|"GET варианта"| A["Angie / nginx (кеш)"] A -->|"cache hit"| B A -->|"cache miss"| I["imgproxy"] I -->|"оригинал"| S["S3 / CDN"] S --> I I -->|"WebP / AVIF / ..."| A

flowchart LR
    G["Генератор сайта / приложение"] -->|"подписанный URL"| B["Браузер"]
    B -->|"GET варианта"| A["Angie / nginx (кеш)"]
    A -->|"cache hit"| B
    A -->|"cache miss"| I["imgproxy"]
    I -->|"оригинал"| S["S3 / CDN"]
    S --> I
    I -->|"WebP / AVIF / ..."| A
Поток обработки изображения через imgproxy

Ключевой момент: imgproxy сам ничего не кеширует — он stateless и на каждый запрос заново тянет оригинал и пересчитывает результат. Поэтому перед ним обязателен кеширующий слой (Angie, nginx, CDN). Один расчёт на уникальный вариант (оригинал + размер + формат) — дальше всё отдаётся из кеша, и нагрузка на imgproxy равна числу вариантов, а не числу просмотров.

Генерирует подписанный URL тот, кто рендерит страницу. У нас это статический генератор (Hugo) на этапе сборки; в обычном приложении — backend или helper-функция.

Кеширующий слой здесь — Angie (форк nginx) из нашего реального прода. Директивы proxy_cache* идентичны nginx, поэтому пример переносится на nginx один-в-один (с поправкой на пути: /etc/nginx/conf.d и /var/cache/nginx).

Анатомия URL обработки

URL imgproxy состоит из подписи, опций обработки и закодированного источника:

/{signature}/{processing_options}/{base64url(source_url)}
  • processing_options — например rs:fit:1600:0 (resize в режиме fit, ширина 1600, высота авто). Можно добавить q:80 (качество), gravity, crop и т.д.
  • источник — URL оригинала, закодированный в base64url (надёжнее, чем «plain», — нет проблем с экранированием : и /).
  • формат не указываем явно: при включённых IMGPROXY_AUTO_WEBP/IMGPROXY_AUTO_AVIF imgproxy сам выбирает WebP или AVIF по заголовку Accept браузера. Если браузер не поддерживает ни того, ни другого — вернётся исходный (или явно запрошенный) формат.

Чтобы кеш не разорвало бесконечными комбинациями, держите конечный набор размеров (у нас три: rs:fit:800 для карточек, 1600 для картинок в тексте, 2400 для лайтбокса) — это и есть аналог «presets».

Подпись URL: ключ и соль

Без подписи любой желающий может попросить у вашего imgproxy произвольный размер и формат любой разрешённой картинки — это раздувает кеш и CPU, а при открытом источнике превращается в SSRF-вектор. Поэтому путь подписывается HMAC-SHA256:

signature = base64url( HMAC_SHA256(key, salt + path) )

где path — всё после подписи (опции + источник). Важная деталь, на которой легко ошибиться: imgproxy читает IMGPROXY_KEY и IMGPROXY_SALT как hex и декодирует их в байты. Значит подписывать нужно теми же байтами — то есть в генераторе ключ/соль тоже надо раскодировать из hex, а не использовать строку напрямую:

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
)

// keyHex/saltHex — те же значения, что в IMGPROXY_KEY / IMGPROXY_SALT (hex).
func signedURL(base, keyHex, saltHex, opts, src string) (string, error) {
	key, err := hex.DecodeString(keyHex)
	if err != nil {
		return "", err
	}
	salt, err := hex.DecodeString(saltHex)
	if err != nil {
		return "", err
	}
	b64 := base64.RawURLEncoding.EncodeToString([]byte(src))
	path := "/" + opts + "/" + b64

	mac := hmac.New(sha256.New, key)
	mac.Write(salt)
	mac.Write([]byte(path))
	sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))

	return base + "/" + sig + path, nil
}

Ключ и salt держим только в env, не в коде. Подпись не заменяет сетевые ограничения и allowlist источников — безопасность строится слоями (см. ниже).

Нюанс для шаблонизаторов без hex-декодирования (например Hugo): можно хранить секрет как обычную ASCII-строку и класть в IMGPROXY_KEY её hex-кодировку — тогда байты совпадут, и подпись из шаблона совпадёт с проверкой imgproxy, при этом в шаблоне достаточно уметь HMAC-SHA256 и base64url. Сама ASCII-строка при этом должна быть случайной и достаточно длинной (например, вывод openssl rand -hex 32), а не человекочитаемым паролем — это полноценный секретный ключ.

Docker Compose

Минимальный production-подобный контур: imgproxy за кеширующим nginx, наружу торчит только nginx.

services:
  cache:
    image: docker.angie.software/angie:1.10.1   # реальный prod-образ; Angie = форк nginx
    depends_on: [imgproxy]
    ports:
      - "8080:80"
    volumes:
      - ./angie.conf:/etc/angie/http.d/default.conf:ro
      - imgproxy-cache:/var/cache/angie
    restart: unless-stopped

  imgproxy:
    image: ghcr.io/imgproxy/imgproxy:v4.0.5   # фиксируем версию, не latest
    environment:
      IMGPROXY_KEY: ${IMGPROXY_KEY}            # hex
      IMGPROXY_SALT: ${IMGPROXY_SALT}          # hex
      IMGPROXY_ALLOWED_SOURCES: "https://cdn.example.com/"
      IMGPROXY_AUTO_WEBP: "true"
      IMGPROXY_AUTO_AVIF: "true"
      IMGPROXY_TTL: "2592000"                  # 30 дней; строкой — см. грабли
      # Лимиты против image bombs и тяжёлых ответов:
      IMGPROXY_MAX_SRC_RESOLUTION: "50"        # мегапиксели входного изображения
      IMGPROXY_MAX_SRC_FILE_SIZE: "20971520"   # 20 МБ на исходный файл
      IMGPROXY_MAX_RESULT_DIMENSION: "5000"    # макс. сторона результата, px
    read_only: true
    tmpfs:
      - /tmp                                   # libvips может писать во временные файлы
    restart: unless-stopped
    # порт imgproxy (8080) НЕ публикуем — он доступен только nginx по сети compose
    healthcheck:
      test: ["CMD", "imgproxy", "health"]
      interval: 30s
      timeout: 5s
      retries: 3
    cpus: 1.0          # лимиты уровня сервиса — работают с `docker compose up`
    mem_limit: 512m    # (deploy.resources — для Swarm/compose-spec, поддержан не везде одинаково)

volumes:
  imgproxy-cache:

Принципы: закреплённая версия образа, конфигурация только через env, read-only root (с tmpfs: /tmp под временные файлы libvips), лимиты CPU/памяти, и imgproxy не выставлен наружу — единственная точка входа это nginx.

Кеширование результатов

Кеш перед imgproxy — это не «приятно иметь», а обязательная часть. Формат зависит от Accept, поэтому imgproxy ставит Vary: Accept, и заголовок Accept обязан входить в ключ кеша — иначе один формат (например AVIF от Chrome) перезатрёт вариант для браузера без AVIF.

Но класть полный Accept в ключ — плохо: строки различаются между браузерами и версиями, кеш дробится в пыль. Нормализуем Accept до трёх корзин (avif / webp / fallback) через map. И тут подвох: при наличии upstream-заголовка Vary: Accept (а imgproxy его ставит) nginx/Angie сам строит вторичный ключ кеша по сырому Accept — поэтому одной подстановки $img_accept в proxy_cache_key мало, дробление останется. Нужно отключить автоматическую обработку Vary (proxy_ignore_headers Vary), убрать upstream-заголовок и отдать клиенту корректный Vary вручную:

# Нормализация Accept до трёх корзин, чтобы не дробить кеш
map $http_accept $img_accept {
    default   "";
    "~*avif"  "avif";
    "~*webp"  "webp";
}

proxy_cache_path /var/cache/angie/imgproxy levels=1:2 keys_zone=img:20m
    max_size=3g inactive=30d use_temp_path=off;

upstream imgproxy { server imgproxy:8080; keepalive 16; }

server {
    listen 80;

    location = /healthz { return 200 "ok"; access_log off; }

    location / {
        proxy_pass http://imgproxy;
        proxy_http_version 1.1;
        proxy_set_header Connection "";

        # imgproxy отдаёт Vary: Accept → иначе nginx/Angie построит вторичный
        # variant-ключ по СЫРОМУ Accept и кеш всё равно раздробится.
        # Игнорируем upstream-Vary, а клиенту отдаём корректный Vary сами.
        proxy_ignore_headers Vary;
        proxy_hide_header Vary;
        add_header Vary Accept always;

        proxy_cache img;
        # нормализованный формат с разделителями (защита от коллизий границ)
        proxy_cache_key "$scheme|$request_method|$host|$request_uri|fmt=$img_accept";
        proxy_cache_valid 200 30d;
        proxy_cache_valid 404 1m;
        proxy_cache_use_stale error timeout updating;
        proxy_cache_lock on;
        add_header X-Cache-Status $upstream_cache_status always;
    }
}

X-Cache-Status в ответе (MISS/HIT) — простейший способ глазами увидеть, что кеш работает: первый запрос варианта — MISS (imgproxy считает), повторный — HIT.

Про размер кеша честно: он растёт с числом уникальных вариантов (оригинал × размер × нормализованный формат), а не с трафиком. На нашем сайте после миграции это du -sh около 2.6 МБ на ~40 объектов при max_size=3g — то есть до вытеснения по размеру далеко. Но арифметика «сотни оригиналов × несколько размеров × 2–3 формата» легко даёт сотни мегабайт, поэтому max_size стоит закладывать с запасом и реально измерять du -sh каталога кеша, а не прикидывать на глаз.

Получение оригиналов из S3

Два рабочих варианта:

  • публичный CDN-URL — imgproxy тянет https://cdn.example.com/..., никаких credentials. Просто и хорошо декаплится; источник ограничивается через IMGPROXY_ALLOWED_SOURCES. Мы используем этот вариант.
  • приватный S3 — нативный доступ по схеме s3://bucket/key. Включается IMGPROXY_USE_S3=true, credentials берутся из стандартных AWS-переменных:
IMGPROXY_USE_S3=true
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
IMGPROXY_S3_REGION=us-east-1
# Ограничить доступ конкретными бакетами, а не всем аккаунтом:
IMGPROXY_S3_ALLOWED_BUCKETS=my-media-bucket
# Для MinIO и совместимых — кастомный endpoint и path-style адресация:
IMGPROXY_S3_ENDPOINT=https://minio.example.com
IMGPROXY_S3_ENDPOINT_USE_PATH_STYLE=true
# источник в URL тогда задаётся как s3://bucket/path/to/original.png

Для AWS S3 endpoint/path-style не нужны (virtual-hosted style по умолчанию); они требуются именно для MinIO и других S3-совместимых хранилищ. В любом случае оригиналы стоит держать неизменяемыми: новая версия картинки = новый ключ. Тогда и кеш imgproxy, и браузерный кеш можно делать «вечными» без риска отдать устаревшее.

Безопасность

Подпись — только один слой. Полезный набор ограничений:

  • IMGPROXY_ALLOWED_SOURCES — allowlist хостов/схем источников. Закрывает SSRF и обработку чужих картинок.
  • IMGPROXY_MAX_SRC_RESOLUTION — лимит мегапикселей входного изображения (защита от «image bombs»: маленький файл, разворачивающийся в гигантское изображение).
  • IMGPROXY_MAX_SRC_FILE_SIZE — лимит размера исходного файла в байтах. Дополняет лимит мегапикселей: маленькое по пикселям, но огромное по весу изображение тоже стоит отсечь.
  • IMGPROXY_MAX_RESULT_DIMENSION — ограничение стороны результата, чтобы нельзя было заказать гигантский апскейл.
  • лимиты CPU/памяти на контейнере — обработка дорогая, без лимитов один тяжёлый запрос ударит по соседям.
  • imgproxy не выставлен наружу напрямую — только через кеширующий прокси.
  • секреты (KEY/SALT, S3-credentials) — только через env, не в образе и не в compose-файле в репозитории.

Адаптивная выдача и форматы

С AUTO_WEBP/AUTO_AVIF одна ссылка отдаёт нужный формат по Accept: AVIF современному браузеру, WebP — если есть только он, и исходный (или явно запрошенный) формат остальным. Поэтому <picture> с отдельными <source> для форматов не нужен. Для srcset достаточно сгенерировать тот же URL с разными rs:fit:<width> — но держите ширины в коротком фиксированном наборе, иначе число вариантов (а с ним storage и cache hit ratio) расползётся.

Грабли, на которые мы наступили

Несколько вещей, которые стоили времени при выкатке в Kubernetes:

  • IMGPROXY_TTL как число даёт scientific notation. Сам Helm не обязан ломать целые. Но в нашем chart значение пришло из values.yaml (ttl: 2592000), где YAML загрузил его в float64, и при подстановке value: {{ .Values.imgproxy.ttl }} большой float отрендерился как 2.592e+06 — imgproxy упал с «invalid TTL». Лечится хранением значения строкой (ttl: "2592000"), чтобы оно не уходило во float.
  • Кеш на сетевом хранилище и chown. nginx при старте делает chown каталога кеша на свой uid. На NFS с root_squash это запрещено — контейнер уходит в CrashLoopBackOff. Решение — локальный том (в k8s emptyDir): он и быстрее сетевого для кеша, ценой эфемерности (после рестарта кеш прогревается заново — для картинок это дёшево).
  • preconnect не туда. После переноса картинок на отдельный домен imgproxy не забудьте preconnect к нему — браузер ходит за картинками уже не на исходный CDN.
  • Vary: Accept и кеш. Коварная: nginx/Angie по upstream-Vary сам строит вторичный ключ по сырому Accept, поэтому без proxy_ignore_headers Vary + ручного Vary нормализация формата в proxy_cache_key не работает — кеш либо дробится, либо «залипает» по первому формату (см. раздел про кеширование).

Результат

Методика замеров: production-эндпойнт (Angie-кеш → imgproxy → CDN), запрос curl -H 'Accept: ...', тёплый кеш (X-Cache-Status: HIT), размер — по Content-Length, latency — по time_starttransfer, процент — относительно исходного PNG. На реальной обложке (исходный PNG ≈ 3.6 МБ) после rs:fit:800:

  • PNG → AVIF: ≈ 188 КБ (около −95 %);
  • PNG → WebP: ≈ 260 КБ (около −93 %);
  • ответ из тёплого кеша (X-Cache-Status: HIT): time_starttransfer ≈ 27–40 мс (6 запросов).

И, что не менее важно, публикация новой статьи больше не требует ручной подготовки превью: в контенте лежит один оригинал, а imgproxy делает из него и карточку, и картинку в тексте, и крупный вариант для лайтбокса.

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

Обсуждение в Telegram

Присоединиться →

Комментарии