Эта статья выросла из практической задачи на самом khorost.tech. PageSpeed на мобильных упирался в вес картинок: обложки статей лежали в S3 как PNG по 0.5–3 МБ, и каждая новая публикация добавляла к странице мегабайты. Готовить вручную уменьшенные превью (_pre.png рядом с оригиналом) — это два файла на картинку и ручная дисциплина, которая рано или поздно ломается.
Альтернатива — вынести resize, конвертацию форматов и оптимизацию из процесса публикации в отдельный HTTP-сервис. Им стал imgproxy: он получает оригинал, обрабатывает его по параметрам из URL и отдаёт результат. В контенте остаётся ссылка на один оригинал, а нужный размер и формат (WebP/AVIF) выбираются на лету.
Ниже — как это устроено в production: архитектура, подпись URL, Docker Compose, кеширование, безопасность и грабли, на которые мы наступили.
В статье
- Когда imgproxy оправдан, а когда нет
- Архитектура
- Анатомия URL обработки
- Подпись URL: ключ и соль
- Docker Compose
- Кеширование результатов
- Получение оригиналов из 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
Ключевой момент: 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_AVIFimgproxy сам выбирает 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. Решение — локальный том (в k8semptyDir): он и быстрее сетевого для кеша, ценой эфемерности (после рестарта кеш прогревается заново — для картинок это дёшево). 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 делает из него и карточку, и картинку в тексте, и крупный вариант для лайтбокса.
Комментарии