AWS S3 через IPv6 прокси: парсинг публичных бакетов и метаданных
Содержание
- Почему S3 и IPv6 — это не всегда дружба
- Как выглядит IPv6-эндпоинт S3
- Парсинг публичных бакетов: где ломается IPv6
- Метаданные: что реально отдаёт S3
- Прокси-слой: зачем он нужен
- nginx.conf — reverse proxy для S3
- Кейс: парсинг 200k объектов с ротацией IPv6
- Кейс: CloudFront перед бакетом ломает IPv6
- пусто
- Кейс: MTU 1500 против 1280
- Сравнение подходов
- Что в итоге
Почему 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.