Оптимизация доставки изображений до 200K фото/сек: кейс риелторской платформы с 5 миллионами фотографий

Проблема: 5 миллионов фотографий и счёт за трафик в 800 000 рублей

Риелторская платформа «НедвижПро» — 15 000 объектов недвижимости, в среднем 30-40 фото на объект, итого ~5 миллионов фотографий. Оригиналы — JPEG с телефона, средний размер 4-8 МБ, разрешение 4000×3000.

Текущая архитектура: Nginx раздаёт файлы напрямую из локального диска. Никакой обработки, никакого кеширования, никакого CDN. Каждый посетитель скачивает оригинал 6 МБ.

Цифры до оптимизации:

  • Средний размер страницы объекта — 85 МБ (15 фото × 5.7 МБ)
  • Время загрузки страницы — 12-18 секунд (3G/4G)
  • Трафик в месяц — 42 ТБ
  • Счёт за трафик (VPS) — 800 000 руб/мес
  • Bounce rate — 67% (пользователи уходят, не дождавшись загрузки)
  • Пиковая нагрузка — 15 000 запросов фото/сек, сервер не справлялся

Задача: обслуживать 200K фото/сек с минимальной латентностью, снизить трафик в 5-10 раз, уменьшить расходы.

Image processing pipeline: resize и конвертация

Первый шаг — не отдавать оригиналы. При загрузке фото агентом мы генерируем 5 вариантов каждого изображения:

# image_processor.py — генерация вариантов при загрузке
import subprocess
from pathlib import Path

VARIANTS = [
    {"name": "thumb",    "width": 320,  "quality": 75},
    {"name": "small",    "width": 640,  "quality": 80},
    {"name": "medium",   "width": 1024, "quality": 82},
    {"name": "large",    "width": 1920, "quality": 85},
    {"name": "original", "width": None, "quality": 90},  # сжатый оригинал
]

FORMATS = ["webp", "avif", "jpeg"]

def process_image(input_path: str, output_dir: str, image_id: str):
    """Генерирует все варианты изображения в трёх форматах."""
    for variant in VARIANTS:
        for fmt in FORMATS:
            output_path = f"{output_dir}/{image_id}/{variant['name']}.{fmt}"
            Path(output_path).parent.mkdir(parents=True, exist_ok=True)

            cmd = ["convert", input_path]

            # Resize с сохранением пропорций
            if variant["width"]:
                cmd += ["-resize", f"{variant['width']}x"]

            # Оптимизация в зависимости от формата
            if fmt == "webp":
                cmd += ["-quality", str(variant["quality"]),
                        "-define", "webp:method=6",
                        "-define", "webp:auto-filter=true"]
            elif fmt == "avif":
                cmd += ["-quality", str(variant["quality"]),
                        "-define", "heic:speed=4"]
            else:  # jpeg
                cmd += ["-quality", str(variant["quality"]),
                        "-sampling-factor", "4:2:0",
                        "-strip",  # удаляем EXIF
                        "-interlace", "Plane"]  # progressive JPEG

            cmd.append(output_path)
            subprocess.run(cmd, check=True)

    # Результат для одного фото 4000x3000 JPEG (6 MB):
    # thumb.webp   — 8 KB      thumb.avif   — 6 KB
    # small.webp   — 24 KB     small.avif   — 18 KB
    # medium.webp  — 62 KB     medium.avif  — 45 KB
    # large.webp   — 148 KB    large.avif   — 105 KB
    # original.webp — 420 KB   original.avif — 310 KB

Размер страницы с 15 фото: вместо 85 МБ (оригиналы) — 930 КБ (medium WebP) или 675 КБ (medium AVIF). Сжатие в 90-125 раз.

Nginx image_filter: динамический resize на лету

Для случаев, когда нужен нестандартный размер (например, виджет партнёра запрашивает 250×200), мы настроили Nginx image_filter как fallback:

# nginx.conf — динамический resize через image_filter
http {
    proxy_cache_path /var/cache/nginx/images
        levels=1:2
        keys_zone=images_cache:100m
        max_size=20g
        inactive=30d
        use_temp_path=off;

    server {
        listen 8081;  # внутренний сервер для resize

        location ~ ^/resize/(?P\d+)x(?P\d+)/(?P.+)$ {
            # Проксируем к S3 или локальному хранилищу
            proxy_pass http://s3-backend/$path;

            # Resize
            image_filter resize $width $height;
            image_filter_jpeg_quality 80;
            image_filter_webp_quality 75;
            image_filter_buffer 20M;
            image_filter_interlace on;

            # Кеширование результата
            proxy_cache images_cache;
            proxy_cache_key "resize_${width}x${height}_$path";
            proxy_cache_valid 200 30d;
            proxy_cache_valid 404 1m;

            # Заголовки кеширования для CDN
            add_header Cache-Control "public, max-age=2592000, immutable";
            add_header X-Cache-Status $upstream_cache_status;
        }
    }

    # Основной сервер — отдаёт предгенерированные варианты
    server {
        listen 80;

        # Content negotiation: WebP/AVIF по Accept заголовку
        location ~ ^/images/(?P.+)\.(?Pjpg|jpeg|png)$ {
            set $webp_suffix "";
            set $avif_suffix "";

            if ($http_accept ~* "image/avif") {
                set $avif_suffix ".avif";
            }
            if ($http_accept ~* "image/webp") {
                set $webp_suffix ".webp";
            }

            # Приоритет: AVIF > WebP > оригинал
            try_files
                /images/${image_path}${avif_suffix}
                /images/${image_path}${webp_suffix}
                /images/${image_path}.${ext}
                =404;

            add_header Cache-Control "public, max-age=2592000, immutable";
            add_header Vary Accept;
        }
    }
}

Content negotiation через заголовок Accept позволяет отдавать AVIF браузерам Chrome 100+, WebP для остальных современных браузеров, и JPEG для устаревших. Один URL — три формата.

S3 + CDN: архитектура хранения и доставки

Локальные диски не масштабируются. 5 миллионов фото × 5 вариантов × 3 формата = 75 миллионов файлов. Мы перенесли всё в S3-совместимое хранилище (Yandex Object Storage) с CDN впереди:

# Структура хранения в S3
# s3://nedvizhpro-images/
# ├── originals/          # оригиналы (бэкап, не раздаются)
# │   └── {image_id}.jpg
# └── processed/          # обработанные варианты
#     └── {image_id}/
#         ├── thumb.webp
#         ├── thumb.avif
#         ├── thumb.jpeg
#         ├── small.webp
#         ├── ... (15 файлов на изображение)
#         └── large.avif

# Загрузка в S3 с правильными заголовками
import boto3

s3 = boto3.client('s3',
    endpoint_url='https://storage.yandexcloud.net',
    aws_access_key_id='AKIA...',
    aws_secret_access_key='...',
    region_name='ru-central1'
)

def upload_variant(local_path: str, s3_key: str, content_type: str):
    s3.upload_file(
        local_path, 'nedvizhpro-images', s3_key,
        ExtraArgs={
            'ContentType': content_type,
            'CacheControl': 'public, max-age=31536000, immutable',
            'ACL': 'public-read'
        }
    )

# Пример:
upload_variant('output/img123/medium.webp',
               'processed/img123/medium.webp',
               'image/webp')

CDN конфигурация (Yandex CDN или CloudFront):

# CloudFront distribution config (Terraform)
resource "aws_cloudfront_distribution" "images" {
  origin {
    domain_name = "nedvizhpro-images.storage.yandexcloud.net"
    origin_id   = "s3-images"

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD", "OPTIONS"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "s3-images"
    viewer_protocol_policy = "redirect-to-https"
    compress               = true

    forwarded_values {
      query_string = false
      headers      = ["Accept"]  # для content negotiation
      cookies {
        forward = "none"
      }
    }

    min_ttl     = 86400      # 1 день
    default_ttl = 2592000    # 30 дней
    max_ttl     = 31536000   # 1 год
  }

  # Географическое распределение
  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  price_class = "PriceClass_100"  # только Европа + Россия
}

Cache hit ratio после настройки — 97.3%. Это значит, что 97% запросов обслуживаются с edge-серверов CDN и не доходят до origin (S3).

Cache invalidation: как обновлять фото без потери кеша

Агент обновил фото квартиры (сделал ремонт). Нужно, чтобы новая фотография появилась сразу, а не через 30 дней (TTL кеша). Мы используем content-hash в URL:

# URL содержит хеш содержимого файла
# Старое фото: /images/v_abc123/medium.webp
# Новое фото:  /images/v_def456/medium.webp
# Разный URL → кеш CDN не используется → новый файл загружается

import hashlib

def get_content_hash(file_path: str) -> str:
    """Генерирует короткий хеш содержимого файла."""
    with open(file_path, 'rb') as f:
        return hashlib.sha256(f.read()).hexdigest()[:12]

def get_image_url(image_id: str, variant: str, fmt: str, content_hash: str) -> str:
    """Формирует URL с content-hash для cache busting."""
    return f"https://img.nedvizhpro.ru/v_{content_hash}/{image_id}/{variant}.{fmt}"

# В базе данных храним mapping:
# image_id | content_hash | uploaded_at
# img123   | a3f8b2c1d4e5 | 2026-03-15
# img123   | 7k2m9n4p1q8r | 2026-04-01  (обновлённое фото)

Для экстренных случаев (юридическое требование удалить фото) — принудительный invalidation через API CDN:

# Принудительная инвалидация через CloudFront API
import boto3

cloudfront = boto3.client('cloudfront')

def invalidate_image(distribution_id: str, image_id: str):
    cloudfront.create_invalidation(
        DistributionId=distribution_id,
        InvalidationBatch={
            'Paths': {
                'Quantity': 1,
                'Items': [f'/processed/{image_id}/*']
            },
            'CallerReference': f'inv-{image_id}-{int(time.time())}'
        }
    )
    # Инвалидация занимает 5-15 минут на всех edge-серверах

Frontend: lazy loading и responsive images

Серверная оптимизация бесполезна, если фронтенд загружает все 40 фото сразу. Мы внедрили lazy loading и responsive images:

<!-- Responsive images с srcset и sizes -->
<picture>
  <!-- AVIF для поддерживающих браузеров -->
  <source
    type="image/avif"
    srcset="
      https://img.nedvizhpro.ru/v_a3f8/img123/thumb.avif 320w,
      https://img.nedvizhpro.ru/v_a3f8/img123/small.avif 640w,
      https://img.nedvizhpro.ru/v_a3f8/img123/medium.avif 1024w,
      https://img.nedvizhpro.ru/v_a3f8/img123/large.avif 1920w
    "
    sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
  />
  <!-- WebP fallback -->
  <source
    type="image/webp"
    srcset="
      https://img.nedvizhpro.ru/v_a3f8/img123/thumb.webp 320w,
      https://img.nedvizhpro.ru/v_a3f8/img123/small.webp 640w,
      https://img.nedvizhpro.ru/v_a3f8/img123/medium.webp 1024w,
      https://img.nedvizhpro.ru/v_a3f8/img123/large.webp 1920w
    "
    sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
  />
  <!-- JPEG для старых браузеров -->
  <img
    src="https://img.nedvizhpro.ru/v_a3f8/img123/medium.jpeg"
    alt="Квартира 2-комнатная, ул. Ленина 42"
    width="1024" height="768"
    loading="lazy"
    decoding="async"
  />
</picture>

Атрибут loading="lazy" — нативный lazy loading, поддерживается всеми современными браузерами. Фото загружается, только когда пользователь доскроллит до него. Атрибут decoding="async" — декодирование не блокирует основной поток рендеринга.

Для галереи объекта (30-40 фото) мы загружаем первые 3 фото сразу, остальные — по мере прокрутки.

Оптимизация хранения и стоимости

5 миллионов оригиналов × 6 МБ = 30 ТБ. Плюс 75 миллионов обработанных вариантов. Оптимизация хранения:

# S3 Lifecycle policy: перемещение оригиналов в холодное хранилище
# lifecycle.json
{
  "Rules": [
    {
      "ID": "ArchiveOriginals",
      "Filter": {"Prefix": "originals/"},
      "Status": "Enabled",
      "Transitions": [
        {
          "Days": 30,
          "StorageClass": "COLD"   # в 3 раза дешевле стандартного
        }
      ]
    },
    {
      "ID": "CleanupDeletedListings",
      "Filter": {"Prefix": "processed/"},
      "Status": "Enabled",
      "Expiration": {
        "Days": 90   # удаляем processed через 90 дней после удаления объявления
      },
      "NoncurrentVersionExpiration": {
        "NoncurrentDays": 7
      }
    }
  ]
}

# Применяем:
aws s3api put-bucket-lifecycle-configuration \
  --bucket nedvizhpro-images \
  --lifecycle-configuration file://lifecycle.json

Итоговая стоимость:

СтатьяДоПосле
Хранение (диски VPS)180 000 руб/мес
Хранение (S3 standard + cold)35 000 руб/мес
Трафик (VPS)800 000 руб/мес
Трафик (CDN)120 000 руб/мес
Compute (image processing)15 000 руб/мес
Итого980 000 руб/мес170 000 руб/мес

Экономия 810 000 рублей в месяц (83%). Время загрузки страницы объекта: с 12-18 сек до 1.2 сек. Bounce rate снизился с 67% до 23%. Если у вас проект с большим количеством изображений и дорогой трафик — обращайтесь к нам в itfresh.ru для оптимизации.

Часто задаваемые вопросы

AVIF даёт на 20-30% лучшее сжатие, чем WebP, но кодирование AVIF в 5-10 раз медленнее. Для предгенерированных вариантов используйте AVIF как приоритет (с WebP fallback). Для динамического resize на лету — WebP, потому что AVIF-кодирование слишком тяжёлое для on-the-fly генерации.
После оптимизации размера изображений (WebP/AVIF) трафик сократился с 42 ТБ до 4 ТБ. CDN при 4 ТБ стоит 100-150 тыс. руб/мес у российских провайдеров и $300-500 у CloudFront. Ключ к экономии — не в дешёвом CDN, а в уменьшении размера файлов до раздачи.
Браузер отправляет заголовок Accept: image/avif,image/webp,image/jpeg. Nginx (или CDN) смотрит на этот заголовок и отдаёт файл в лучшем поддерживаемом формате. Важно: добавляйте Vary: Accept в ответ, иначе CDN закеширует один формат для всех браузеров.
Image_filter нужен как fallback для нестандартных размеров (API-запросы от партнёров, кастомные виджеты). Для основного сайта хватает 4-5 предгенерированных размеров. Image_filter потребляет CPU и не подходит для высокой нагрузки — при 1000+ запросов/сек используйте imgproxy или thumbor как отдельный сервис.
Используйте content-hash в URL: /v_{hash}/image.webp. При обновлении фото хеш меняется, URL становится новым, CDN загружает свежий файл. Это надёжнее, чем cache invalidation, который может занимать 5-15 минут и стоит денег (CloudFront берёт за invalidation после первых 1000 путей в месяц).

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#image optimization#CDN#WebP#AVIF#S3#CloudFront#Nginx image_filter#lazy loading
Комментарии 0

Оставить комментарий

загрузка...