В современном мире информационных технологий у всех — и у крупных, и у небольших компаний — существует большое количество различных API. И отказоустойчивость, несмотря на многие best practices, чаще всего не позволяет гарантировать 100%-й возможности корректно обрабатывать запросы клиентов, а также восстанавливаться после сбоя и продолжать обработку запросов, утерянных из-за сбоя. Эта проблема возникает даже у больших игроков в интернете, не говоря уже о не очень крупных компаниях.
Я работаю в компании Calltouch, и наша основная цель — добиться отказоустойчивости сервисов и получить возможность управлять данными и запросами, которые клиенты совершали в API-сервис. Нам нужна возможность быстро восстанавливать сервис после сбоя и обрабатывать запросы к сервису, у которого возникли проблемы. Начинать обработку с момента отказа. Всё это позволит приблизиться к состоянию, когда почти невозможно потерять запросы клиентов на нашей стороне.
Разбирая предложенные на рынке решения, мы открыли для себя превосходную производительность и практически безграничные возможности по управлению данными и их обработке — с очень незначительными требованиями к техническим и финансовым ресурсам.
Предыстория
В Calltouch существует сервис API, куда поступают запросы клиентов с данными на построение отчётов в веб-интерфейсе. Эти данные очень важны: они используются в маркетинге, и их потеря может повлечь за собой непредвиденную работу сервиса. Как и у всех, иногда после выкладки или добавления новых возможностей у сервиса возникают проблемы, на некоторое время может произойти отказ в работе. Поэтому нужна возможность очень быстро взять и обработать те запросы с данными, что не были доставлены в сервис API в момент сбоя. Одной лишь балансировкой с бэкапом не обойтись по ряду причин:
- Объём памяти, необходимый для сервиса, может требовать нового оборудования.
- Стоимость оборудования сейчас высока.
- От ошибки с killer request никто не застрахован.
Довольно простая задача (хранение запросов и быстрый доступ к ним) порождает высокие издержки для бюджета. В связи с этим мы решили провести исследование, каким образом сейчас можно сохранить все входящие запросы с очень быстрым доступом к ним.
Исследование
Было несколько вариантов, как хранить входящие данные.
Первый вариант
Сохранять запросы с данными, используя логи nginx, и складывать в какое-то место. Если возникнут проблемы, сервис API обратится к данным, которые где-то сохранены, и после сделает необходимую обработку.
Второй вариант
Сделать дублирование запросов HTTP в несколько мест. Плюс написать дополнительный сервис, который будет куда-то складывать данные.
Конфигурирование веб-сервера для сохранения данных через логи для последующей обработки имеет свои минусы. Такое решение не очень дешёвое, а скорость доступа к данным будет крайне небыстрой. Понадобятся дополнительные сервисы для работы с файлами логов, агрегации и хранения данных. Плюс потребуется большой объём финансовых затрат — на внедрение новых сервисов, обучение сотрудников эксплуатации, вероятную закупку нового оборудования. И самое главное, если такого решения ранее не было, то придётся найти и время на внедрение. По этим причинам мы почти сразу отказались от первого варианта и начали исследовать возможности реализовать второй вариант.
Реализация
Мы выбирали между nginx, goreplay и lwan.
Первым отпал lwan, так как goreplay умеет сразу всё, что нам надо. Осталось лишь выбрать nginx с @post_action или goreplay. Goreplay был эталоном для этой схемы, но мы решили остановиться и поразмыслить о запросах: где и как их лучше хранить.
О хранении можно было особо не задумываться до определённого момента. Нам потребовалась обратная связь между уже обработанными и ещё не обработанными данными. В API, для которого мы делаем дублирование запросов, не предусматривались ID в запросах с клиентской стороны. И возникла такая ситуация: понадобилась возможность подставлять дополнительные данные во входящий запрос. Это позволило бы получить обратную связь между обработанными и необработанными данными, ведь в базу попадут все данные, а не только необработанные. Затем каким-то образом нужно разбираться со всеми входящими данными.
Чтобы разделаться с ID запросов, мы решили добавлять на стороне веб-сервера заголовок с UUID и проксировать такие запросы к API — чтобы сервис API после обработки изменял/удалял те запросы, которые мы дублируем в базе данных. На этом моменте мы отказываемся и от goreplay в пользу nginx, так как nginx поддерживает множество модулей, включая возможность записи в различные базы данных. Это позволит упростить схему обработки данных и уменьшить количество вспомогательных сервисов при решении данной технической задачи. Не придётся тратить время на изучение дополнительных языков и дорабатывать goreplay для соответствия требованиям.
Наиболее простым вариантом было бы взять модуль для nginx, который может писать в какую-нибудь базу данных всё содержимое входящих запросов. Дополнительного кода и программирования в конфигах не очень бы хотелось. Самым гибким и подходящим для нас оказался модуль для Tarantool, им можно проксировать все данные в Tarantool без каких-либо дополнительных действий.
В качестве примера приведём простейшую конфигурацию и небольшой скрипт на Lua для Tarantool, в котором будут логироваться все тела входящих запросов. Взаимодействие сервисов отображено на схеме ниже.
Для этого нам требуется nginx с набором модулей и Tarantool.
Дополнительные модули к nginx:
Пример конфигурации апстрима в nginx для работы с Tarantool:
upstream tnt {
server 127.0.0.1:3301 max_fails=1 fail_timeout=1s;
keepalive 10;
}
Конфигурация проксирования данных в Tarantool с использованием post_action:
location @send_to_tnt {
tnt_method http_handler;
tnt_http_rest_methods all;
tnt_pass_http_request on pass_body parse_args pass_headers_out;
tnt_pass tnt;
}
location / {
uuid4 $req_uuid;
proxy_set_header x-request-uuid $req_uuid;
add_header x-request-uuid $req_uuid always;
proxy_pass http://127.0.0.1:8080/;
post_action @send_to_tnt;
}
Пример процедуры в Tarantool, которая принимает входящие данные из nginx:
box.cfg {
log_level = 5;
listen = 3301;
}
log = require('log')
box.once('grant', function()
box.schema.user.grant('guest', 'read,write,execute', 'universe')
box.schema.create_space('example')
end)
function http_handler(req)
local headers = req.headers
local body = req.body
if not body then
log.error('no data')
return false
end
if not headers['x-request-uuid'] then
log.error('header x-request-uuid not found')
return false
end
local s, e = pcall(box.space.example.insert,
box.space.example, {headers['x-request-uuid'], body})
if not s then
log.error('can not insert error:\n%s', e)
return false
end
return true
end
Решением можно считать небольшой размер кода на Lua и довольно простую конфигурацию nginx. Часть с API здесь учитывать нет смысла, именно её необходимо делать в любом варианте реализации. Можно легко расширить эту схему master-master репликацией в Tarantool и сделать балансировку нагрузки на несколько нод, используя nginx или twemproxy.
Поскольку post_action отправляет данные в Tarantool на несколько миллисекунд позже, чем запрос приходит в API и обрабатывается, в схеме есть один нюанс. Если API работает так же быстро, как Calltouch, то придётся сделать несколько запросов на удаление или тайм-аут перед запросом в Tarantool. Мы выбрали несколько запросов вместо тайм-аутов, чтобы наши сервисы работали без задержек, так же быстро, как и ранее.
Заключение
В заключение можно добавить тот факт, что всего лишь nginx и модуль nginx_upstream_module совместно с Tarantool позволяют достичь невероятной гибкости и простоты в работе с http-запросами, высокой скорости доступа к данным без нарушения работы основных сервисов, значительных перемен при внедрении в существующую инфраструктуру. Охват задач — от создания сложных статистик до обычного сохранения запросов. Не говоря о том, что можно использовать как обычный веб-сервис и реализовать API на базе данного модуля для nginx и Tarantool.
Как развитие на будущее в Calltouch отмечу возможность создания интерфейса, в котором можно почти мгновенно получить доступ по каким-то фильтрам к различным данным. Использовать реальные запросы на тестах вместо синтетической нагрузки. Производить отладку приложений при возникновении проблем — и для повышения качества, и для устранения ошибок. При возможности иметь столь высокую доступность данных и гибкость в работе можно повышать количество сервисов и их качество незначительной суммой расходов на внедрение Tarantool в различных продуктах.
SergeAx
Только мне кажется, что для решения описанной задачи предназначены Message Queues??
dedokOne
С ними реализация будет в разы сложней (нужны будут дополнительные демоны и т.п.) плюс Message Queues это все же не БД.
SergeAx
В приведённом решении тоже использован дополнительный демон Tarantool, и складываются туда, как я понял из статьи, достаточно сырые данные. Свойства БД в этом решении нужны в момент удаления данных, чтобы, грубо говоря, DELETE WHEN guid=%1 выполнялся достаточно быстро.
dedokOne
Ну непропорционально — это мой тезис.
С очередью будет выглядит так: демоны который читает и пишет (возможно это сам nginx), отдельный демон очереди. Итого — большая система получается, плюс очередь не != БД, но это ты и сам заметил :)
SergeAx
См. моё описание похожего, кмк, кейса ниже.
danikin
Запилите то же самое через Message Queues, выложите в продакшн, пустите нагрузку от реальных пользователей. И дальше мы с удовольствием почитаем на Хабре про ваш опыт.
SergeAx
Я, может быть, не до конца понял суть задачи; статья всё же написана немного суховато. Я решал, как мне кажется, похожую задачу: надо было по https получать запрос к пикселю-счётчику, расположенному на страницах многих сайтов, и складывать в Cassandra (потом её поменяли на ClickHouse). Запросов могло быть много, данные было очень важно не терять, загрузке страниц этот пиксель должен был не мешать по минимуму. Похоже на ваш кейс?
В итоге была такая конфигурация: за nginx стоял php-fpm, под ним выполнялся скрипт из буквально 15 строк, который брал
$_REQUEST
, проверял что в cookie есть user-id, если нет — генерировал новый, устанавливал cookie, отправлял данные в RabbitMQ и завершался. Размер одного сообщения был типа 4 килобайт.По крону раз в минуту запускался скрипт уже под php-cli, забирал пачками данные из Rabbit и сливал в базу. Сам Rabbit крутился на той же виртуалке. Память fpm была ограничена каким-то смешным количеством, число процессов было типа 10 (как я понимаю сейчас — даже много). Умещалась вся конструкция в 200-рублёвую виртуалку на VScale, 100 rps из Танка выдерживала, 99% ответов умещались в 80ms. Вся работа уложилась в 2 человеко-дня, нагрузка от реальных пользователей, правда, выше 50 rps не поднималась. Собственно всё, на статью для Хабра не тянет, максимум на коммент)
dedokOne
Ну у тебя задача другая, а именно, хранить Хиты. А тут хранить консистентно запросы. У вас цели иные.
Твою задачу можно решить проксированием каждого хита в ClickHouse прям из nginx, я бы сделал так :)
SergeAx
А uuid кто будет генерировать?)
zloidemon
Данный вопрос возник, из-за того что Вы не читали данную статью. Но добавлю здесь описание, через nginx мы добавляем заголовок с UUID, а он в свою очередь создается используя этот модуль.
SergeAx
Я прочитал, но быстро забыл, виноват)
SergeAx
А нет, в смысле да) Прочитал, забыл, снова вспомнил: обработчик хита должен читать cookie и если в ней нет uuid — генерировать и выставлять новый, чтобы собирать хиты в сессии. nginx так умеет?
zloidemon
Из описания ClickHouse, в руководстве:
Задачи похожи, но в нашем случае мы действительно ничего не теряем. Так как есть и репликация, и транзакции и балансировка нагрузки.
zloidemon
Основная задача состоит не в использовании какого-то подхода, а решение задачи в наиболее короткие сроки, и менее затратно. Можно сделать множество различных реализаций, но не всегда время и срок решения проблемы будет очевиден.