Все статьи из цикла AWS

Прошлая заметка про AWS была затравкой. Теперь расскажу о случаях в Амазоне, от которых трещали мозги.

Первая история касается сервиса Lambda. Он выполняет произвольный код на любом языке и возвращает результат. Особенность Лямбды в том, что клиент платит только за те ресурсы, что потребил. Считаются время процессора, память и диск в секунду. Если не вызывать Лямбду, ее стоимость будет нулевой.

Это выгодно отличает Лямбду от EC2, за который клиент платит всегда. Наверное, все видели мемы про бомжа, который скатился в бедность, не выключив инстанс EC2.

Кто-то считает, что Лямбда — простой сервис, но это не так. Наоборот, он один из самых сложных в AWS. Он работает как огромная очередь задач. Лямбду можно скрестить с HTTP-сервером, чтобы принимать сообщения прямо из браузера. Лямбду можно прицепить к любому сервису AWS в качестве реакции на что угодно. Загрузили файл в S3 — вызвалась лямбда. Отправили сообщеньку в очередь — вызвалась лямбда и так далее.

Я работаю в проекте, в котором все на лямбдах. Раньше мне казалось это фантасмагорией, но со временем привыкаешь. В порядке вещей, когда лямбда дергает лямбду, которая дергает лямбду, которая дергает лямбду. Постепенно видишь в этом особый шарм.

У лямбды жесткие лимиты, которые нельзя нарушать. Время работы не может быть дольше 15 минут. Если его превысить, лямбда умирает и запускается опять. Максимальное число повторов — 4. Ответ не может быть больше 6 мегабайтов. В теле может быть только текст, бинарные данные запрещены.

Про размер тела я и хотел рассказать.

Одна из лямбд распухла и стала отдавать JSON, который не пролазил в 6 мегабайтов. Это нетрудно поправить. Нужно сжать тело Gzip-ом и обернуть в base64. Зачем? Как я сказал, в теле не может быть бинарь, потому что данные передаются в JSON. Такой вот костылик, но что поделаешь.

Не обошлось без приключений: клиенты лямбды использовали кривую библиотеку, которая не учитывала сжатие Gzip. Пришлось починить ее тоже: проверять Content- Encoding и оборачивать стрим, если там gzip. В общем, кое-как все подружились, и сообщения пошли как надо. Как-то раз я задался вопросом: какого размера был тот ответ, что не влез в лимит?

Добавил лог с размером JSON до сжатия. Оказалось, он был 5.2 мегабайта.

Может быть, не всем понятно это противоречие, но я впал в ступор. Сказано ясно: тело не больше 6 мегабайтов, а у нас 5. Почему сообщение не проходит? Откуда лишний мегабайт? Это страшно взволновало меня: происходит какая-то фигня, о которой я не догадываюсь.

Я полез в интернет и выяснил, что какой-то бедняга уже наступал на эти грабли. На StackOverflow нашелся вопрос, и автор долго искал ответа. Он даже писал сводки с апдейтами! Были разные предположения, в том числе такие, что AWS дописывает мету в сообщения. Но не мегабайт же! Они что, Анну Каренину в заголовках передают?

На сегодняшний день у вопроса 21 тысяча просмотров и ни одного верного ответа (за исключением моего). Этот вопрос был скопирован в сервис Repost.AWS — базу знаний AWS, и там тоже ничего не сказали по делу. Это доказывает: в Амазоне бывает нечто, что никто не может объяснить.

Мысль о лишнем мегабайте не отпускала меня. И вот однажды, прогуливаясь, я все понял.

Когда лямбда отдает HTTP-сообщение, она строит примерно такой ответ. В его теле — строка JSON с данными:

return HTTPResponse(
    200,
    body=json.encode(data),
    content_type="application/json"
)

На этом работа программы кончается. А что происходит с ответом? Он перестраивается в такой словарик:

{:statusCode 200,
 :body <JSON-string>,
 :headers {"content-type" "application/json"}}

Потом словарик кодируется в JSON и отправляется в дебри AWS, которые называются Lambda Runtime API. Это очередь, которая отвечает за прием и отдачу сообщений. Ваша лямбда — клиент, который забирает оттуда сообщеньки и рапортует об исполнении. Примерно как Consumer в Кафке.

Теперь про этот мегабайт. Напомню, что до кодирования наш словарик выглядел так (кложурный синтаксис):

{:statusCode 200,
 :body "{\"foo\":{\"bar\":[\"a\",\"b\",\"c\"]}}",
 :headers {"Content-Type" "application/json"}}

Далее он кодируется в JSON. А в поле :body уже закодированный JSON! Получается двойное кодирование: данные перегнали в JSON первый раз, чтобы получить текстовое поле body, а потом еще раз, чтобы закодировать верхний словарь! При кодировании JSON происходит экранирование некоторых символов. Например, если в строке двойная кавычка, перед ней будет обратный слэш. Покажу это на примере:

(pg.json/write-string "aaa \" bbb")
"\"aaa \\\" bbb\""

Интересно, сколько же будет этих слэшей? Примерно x2 от числа ключей в словарях и строк в значениях. Например, если в словаре два ключа и в значениях строки, то слэшей получится 8. Легко посчитать отношение длины JSON с одним и двойным кодированием. Оно получится примерно 1.4:

(-> {:foo {:bar [:a :b :c]}}
    pg.json/write-string
    count)
29

(-> {:foo {:bar [:a :b :c]}}
    pg.json/write-string
    pg.json/write-string count)
41

(/ 41.0 29)
1.4137931034482758

Проверим это на больших файлах. В интернете нашелся большой публичный JSON. Вот цифры для него:

(-> "https://github.com/seductiveapps/largeJSON/raw/master/100mb.json"
    java.net.URL.
    slurp
    pg.json/read-string
    pg.json/write-string
    count)
60129867

(-> "https://github.com/seductiveapps/largeJSON/raw/master/100mb.json"
    java.net.URL.
    slurp
    pg.json/read-string
    pg.json/write-string
    pg.json/write-string
    count)

70361681
(/ 70361681.0 60129867)

1.170161926351841

Коэффициент вышел 1.17, а размер вырос аж на 10 мегабайтов.

Резюмируя: двойное JSON-кодирование прибавляет от 5 до 17% к длине строки. Прибавка состоит из обратных слэшей из-за экранирования кавычек. В моем случае было примерно 800 Кб слешей. 5.3 + 0.8 = 6.1 мегабайтов. Все сходится. Напоминает шутку про украденный код на Lisp, где были одни закрывающие скобки. Тут то же самое, только слэши.

Вот такая штука. Когда все шаги пройдены, она не кажется загадкой, но как же напрягала тогда! Ее нельзя назвать багом, потому что документация не врет: шесть мегабайтов. Но дело в том, что эти шесть мегабайтов касаются финального сообщения, которое уходит в Runtime API. Это не длина данных, что возвращает ваш код, вот в чем дело. И конечно, документация ничего не знает о двойном кодировании и проблеме слэшей.

Важно понять: если вы отдаете HTTP-сообщеньки из Лямбды, и в теле JSON, вы кодируете его дважды. Это добавляет 5-17% процентов от исходной длины. Лучше сразу использовать Gzip+base64, чтобы не выстрелить в ногу.

Итак, с лямбдой разобрались. В следующей заметке будет кулстори про AWS Athena (читается “Афина”). Там тоже трещали мозги.