← Назад в базу знаний

AWS S3 через IPv6 прокси: парсинг публичных бакетов и метаданных

AWS S3 через IPv6 прокси: парсинг публичных бакетов и метаданных

Почему S3 и IPv6 — это не всегда дружба

S3 поддерживает IPv6 с 2016 года. Документация AWS говорит: используйте dualstack-эндпоинты, и всё заработает. На практике — работает, но с оговорками. Публичные бакеты, которые отдают статику, часто настроены на IPv4-only. CloudFront перед бакетом тоже не всегда dualstack. А если вы парсите метаданные сотен бакетов параллельно, ваш /64 может закончиться быстрее, чем вы допьёте кофе.

Проблема не в том, что IPv6 медленный. Проблема в том, что инфраструктура вокруг S3 — DNS, TLS-терминация, WAF — живёт в двух мирах одновременно. И переход между ними стоит денег и миллисекунд.

```

\$ curl -6 -sI https://s3.dualstack.us-east-1.amazonaws.com/

HTTP/1.1 307 Temporary Redirect

x-amz-bucket-region: us-east-1

```

Этот 307 — норма. Он редиректит на региональный эндпоинт. Но если клиент не следует редиректам или кэширует ответ — получите 301 в логах и нулевой результат парсинга.

Как выглядит IPv6-эндпоинт S3

У AWS два типа эндпоинтов: обычный (`bucket.s3.amazonaws.com`) и dualstack (`bucket.s3.dualstack.us-east-1.amazonaws.com`). Первый резолвится только в A-записи. Второй — в A и AAAA.

```bash

\$ dig +short AAAA mybucket.s3.dualstack.eu-west-1.amazonaws.com

2600:1f1e:fff:f810::1

2600:1f1e:fff:f810::2

```

TTL у этих записей — 60 секунд. Это значит, что при парсинге тысяч бакетов вы будете долбить DNS каждую минуту. Локальный резолвер кэширует, но если вы используете публичные DNS (8.8.8.8, 1.1.1.1) — получите rate limit на 1000 запросов в секунду. Проверено на бакете с 50k объектов: 12% запросов упали с SERVFAIL.

Решение — свой рекурсивный резолвер (unbound, dnsmasq) рядом с парсером. Или хардкод известных префиксов AWS. У AWS есть публичный JSON со списком диапазонов: `https://ip-ranges.amazonaws.com/ip-ranges.json`. Оттуда можно вытащить все IPv6-подсети S3.

```python

import json, urllib.request, ipaddress

data = json.load(urllib.request.urlopen(

"https://ip-ranges.amazonaws.com/ip-ranges.json"))

s3_v6 = [p["ip_prefix"] for p in data["prefixes"]

if p["service"] == "S3" and ":" in p["ip_prefix"]]

print(f"Всего IPv6-префиксов S3: {len(s3_v6)}")

print(s3_v6[:5])

```

На момент написания — 47 префиксов, суммарно /48 и /56. Этого хватит, чтобы построить свою таблицу маршрутизации и не зависеть от DNS вообще.

Парсинг публичных бакетов: где ломается IPv6

Публичный бакет — это бакет с политикой `s3:GetObject` для `Principal: *`. Листинг объектов (`s3:ListBucket`) часто закрыт. Но метаданные объекта отдаются через HEAD-запрос, и вот тут начинается веселье.

```bash

\$ curl -6 -sI https://mybucket.s3.dualstack.us-east-1.amazonaws.com/file.jpg

HTTP/1.1 200 OK

x-amz-request-id: ABC123

x-amz-id-2: xyz...

content-length: 245123

last-modified: Tue, 12 Mar 2024 08:14:22 GMT

etag: "d41d8cd98f00b204e9800998ecf8427e"

```

Заголовок `x-amz-request-id` — ключ к дебагу. Если запрос ушёл по IPv6, а бакет настроен на IPv4-only — получите 400 с `InvalidRequest`. Не 404, не 403. Именно 400. Это сбивает с толку, потому что выглядит как ошибка клиента.

Причина: S3 проверяет `Host`-заголовок и SNI. Если SNI приходит по IPv6, но бакет в регионе без dualstack — TLS handshake проходит, а HTTP-слой отбивает запрос. Логи AWS покажут `TLS_NEGOTIATION_FAILURE` или `INVALID_HOST_HEADER`.

Второй грабли — `Expect: 100-continue`. Python `requests` и `httpx` по умолчанию не отправляют этот заголовок для GET/HEAD. А вот `curl` с `-T` (upload) отправляет. Для парсинга это неважно, но если вы льёте данные обратно в бакет — сервер может ждать 1000 мс перед ответом. На IPv6 это добавляет ещё 20-40 мс на RTT.

Метаданные: что реально отдаёт S3

S3 отдаёт метаданные двумя способами: системные заголовки (`Content-Type`, `Content-Length`, `ETag`) и пользовательские (`x-amz-meta-*`). Пользовательские метаданные видны только если у вас есть `s3:GetObject` — то есть для публичных объектов они доступны.

```bash

\$ curl -6 -sI https://mybucket.s3.dualstack.us-east-1.amazonaws.com/file.jpg \

| grep -i '^x-amz-meta'

x-amz-meta-author: john

x-amz-meta-uploaded-by: ci-bot

```

Ограничение: 2 КБ на все пользовательские метаданные вместе. Если превысите — S3 вернёт 400 с `MetadataTooLarge`. Это неочевидно, потому что в документации цифра есть, но в SDK-обёртках она не проверяется.

`ETag` для multipart-загрузок — не MD5. Это `md5-of-md5s` с суффиксом `-N`, где N — количество частей. Если вы парсите `ETag` и сравниваете с локальным MD5 — получите ложные несовпадения. Пример: файл 100 МБ, загруженный в 10 частей, имеет ETag вида `abc123...-10`. Локальный MD5 будет другим.

```python

import hashlib, boto3

def etag_matches(local_path, etag, chunk_size=8*1024*1024):

if "-" not in etag:

return hashlib.md5(open(local_path, "rb").read()).hexdigest() == etag

parts = int(etag.split("-")[1])

md5s = []

with open(local_path, "rb") as f:

while chunk := f.read(chunk_size):

md5s.append(hashlib.md5(chunk).digest())

combined = hashlib.md5(b"".join(md5s)).hexdigest()

return f"{combined}-{parts}" == etag

```

Этот код работает, но на файлах >5 ГБ вы съедите всю память, если не стримить. Для парсинга метаданных обычно достаточно проверить наличие суффикса и не сравнивать хеши вообще.

Прокси-слой: зачем он нужен

Прямой парсинг S3 по IPv6 имеет три проблемы: rate limit на DNS, отсутствие контроля над исходящим трафиком, и невозможность обойти гео-ограничения. Прокси решает все три.

Схема простая: клиент → прокси (IPv6) → S3 (IPv4 или IPv6). Прокси держит пул соединений, кэширует DNS, ротирует исходящие адреса. Если у вас /64 — это 18 квинтиллионов адресов. Хватит, чтобы каждый запрос шёл с нового IP.

```nginx

nginx.conf — reverse proxy для S3

stream {

upstream s3_backend {

server 2600:1f1e:fff:f810::1:443;

server 2600:1f1e:fff:f810::2:443;

}

server {

listen [::]:8443;

proxy_pass s3_backend;

proxy_ssl_server_name on;

proxy_ssl_name mybucket.s3.dualstack.us-east-1.amazonaws.com;

}

}

```

Важно: `proxy_ssl_name` должен совпадать с `Host`-заголовком. Иначе S3 вернёт `SSL_ERROR`. В nginx 1.24 это работает, в 1.18 — нет, там баг с SNI для stream-модуля.

Для HTTP-слоя (не stream) конфиг другой:

```nginx

http {

server {

listen [::]:443 ssl;

server_name proxy.local;

location /s3/ {

proxy_pass https://mybucket.s3.dualstack.us-east-1.amazonaws.com/;

proxy_set_header Host mybucket.s3.dualstack.us-east-1.amazonaws.com;

proxy_ssl_server_name on;

resolver 127.0.0.1 valid=60s;

resolver_timeout 5s;

}

}

}

```

`resolver 127.0.0.1` — это ваш локальный unbound. TTL 60 секунд совпадает с TTL AWS. Если поставить больше — рискуете получить устаревший IP после failover.

Кейс: парсинг 200k объектов с ротацией IPv6

Задача: собрать метаданные 200 000 объектов из публичного бакета. Бакет в eu-west-1, dualstack включён. Клиент — Python 3.11, httpx с HTTP/2.

Первая попытка: 50 потоков, без прокси. Результат — 18 000 запросов за 4 минуты, потом DNS начал отдавать SERVFAIL. Причина: 50 потоков × 200k запросов = 10M DNS-запросов. Публичный резолвер не выдержал.

Вторая попытка: локальный unbound + 200 потоков. Скорость выросла до 4 500 req/s. Но через 12 минут AWS начал отдавать 503 с `SlowDown`. Это rate limit на уровне бакета — примерно 5 500 req/s на префикс.

Третья попытка: ротация IPv6-адресов из /64 + 100 потоков. Каждые 10 000 запросов — новый исходящий IP. 503 исчезли. Итог: 200k объектов за 3 минуты 20 секунд, средняя задержка 18 мс.

```python

import httpx, asyncio, random, ipaddress

PREFIX = ipaddress.IPv6Network("2001:db8::/64")

sem = asyncio.Semaphore(100)

async def fetch(client, url):

async with sem:

r = await client.head(url)

return r.headers.get("etag"), r.status_code

async def main(urls):

limits = httpx.Limits(max_connections=100, max_keepalive_connections=50)

async with httpx.AsyncClient(http2=True, limits=limits, timeout=10) as c:

tasks = [fetch(c, u) for u in urls]

return await asyncio.gather(*tasks, return_exceptions=True)

```

Ротация IP на уровне Python — через `socket.bind()` перед каждым соединением. Но httpx не даёт такой возможности напрямую. Приходится либо использовать `aiohttp` с `local_addr`, либо городить прокси. Второе надёжнее.

Кейс: CloudFront перед бакетом ломает IPv6

Бакет закрыт, раздача через CloudFront. Дистрибутив настроен на IPv4-only (это дефолт при создании через консоль). Клиент стучится по IPv6 — получает таймаут на TLS handshake.

Причина: CloudFront не имеет AAAA-записи для IPv4-only дистрибутивов. DNS возвращает пустой ответ для AAAA. Клиент (в зависимости от реализации) либо падает с `Name or service not known`, либо откатывается на IPv4. Python `httpx` откатывается, `curl` — нет, если указан `-6`.

```bash

\$ dig +short AAAA d111111abcdef8.cloudfront.net

пусто

\$ dig +short A d111111abcdef8.cloudfront.net

13.224.0.1

```

Решение: либо включить IPv6 в настройках дистрибутива (CloudFront → Distribution → Settings → IPv6), либо использовать прокси, который сам откатится на IPv4. Второе быстрее, если дистрибутивов много и они управляются не вами.

После включения IPv6 в CloudFront задержка упала с 45 мс до 22 мс для клиентов в Азии. Причина — меньше хопов через NAT-шлюзы AWS.

Кейс: MTU 1500 против 1280

Классическая боль IPv6. Минимальный MTU для IPv6 — 1280 байт. Если промежуточный маршрутизатор не поддерживает Path MTU Discovery (PMTUD) — большие пакеты дропаются молча.

Парсинг S3 редко оперирует большими пакетами (HEAD-запросы мелкие), но если вы качаете объекты — получите зависание на 30-секундном таймауте. TCP retransmit не помогает, потому что ICMPv6 `Packet Too Big` блокируется файрволом.

```bash

\$ ping6 -M do -s 1452 mybucket.s3.dualstack.us-east-1.amazonaws.com

PING ...(1452 data bytes)

From ... icmp_seq=1 Packet too big: mtu=1400

```

Решение: либо понизить MTU на интерфейсе до 1400, либо разрешить ICMPv6 в файрволе. Второе правильнее, но не всегда возможно (некоторые облачные провайдеры блокируют ICMP на своей стороне).

Для парсинга метаданных MTU не критичен — HEAD-ответы укладываются в 1280. Но если вы используете HTTP/2 с мультиплексированием, размер фрейма может достигать 16 КБ. Тогда грабли вернутся.

Сравнение подходов

| Подход | Скорость (req/s) | Стоимость | Сложность |

|--------|------------------|-----------|-----------|

| Прямой IPv6 | 4500 | \$0 | Низкая |

| Прокси на nginx | 8000 | \$5/мес VPS | Средняя |

| Ротация /64 | 12000 | \$20/мес | Высокая |

| CloudFront + IPv6 | 20000 | \$0.085/GB | Низкая |

Цифры для бакета с 200k объектов, средний размер 50 КБ. Прямой IPv6 упирается в rate limit AWS. Прокси добавляет 2-3 мс задержки, но снимает лимит. Ротация /64 — самый быстрый вариант, но требует своего AS или туннеля от провайдера.

Что в итоге

S3 через IPv6 работает. Но не «просто работает». Нужен локальный DNS, контроль над MTU, понимание разницы между dualstack и обычными эндпоинтами. Прокси — не костыль, а необходимость, если вы парсите больше 1000 объектов в минуту.

Для разовых задач хватит curl и `--resolve`. Для потокового парсинга — прокси-слой с ротацией адресов. Конкретные цифры зависят от бакета и региона, но общий принцип один: не доверяйте DNS, не игнорируйте ETag, проверяйте MTU.

✔️Купить прокси