Год с Erlang

3 июля 2013

Прошло чуть больше года с тех пор, как я стал Erlang-разработчиком. Конечно, весь этот год я занимался не только Erlang. Попутно я освоил iOS-разработку и делал клиентские приложения для iPhone/iPad. Но при этом считался разработчиком серверной части, и делал оную для 3-х проектов. Так что спустя год могу поделиться своими впечатлениями об Erlang.

Я делился впечатлениями и раньше: раз, два, три, четыре. Но тогда это были в основном теоретические знания, реального опыта было не много. Сейчас, когда один мой проект таки вышел в продакшен, есть чем поделиться из практики.

И надо сказать, что 3 месяца в продакшен дают больше опыта, чем 9 месяцев до него :) Этап от нуля до первого релиза тоже интересный. Творишь, что хочешь, и ничто не ограничивает фантазию. Но вот после релиза, когда появляются реальные пользователи, и сервер живет реальной жизнью, вот тогда-то и начинается самая мякотка :)

Устойчивость к ошибкам

И начнем мы с отказоустойчивости, этой знаменитой фичи Erlang :)

Трудно ли завалить сервер? Стресс тесты у меня упирались в производительность базы данных (PostgreSQL), а Erlang-сервер не падал, и даже не нагружался особо. Но стресс тесты -- вещь малоинформативная. А реальные пользователи у нас пока не создают заметную нагрузку. Поэтому об устойчивости к нагрузкам я пока не могу ничего сказать. А об устойчивости к ошибкам могу.

За 3 месяца у меня было 2 серьезных косяка, приведших к отказу в обслуживании для многих клиентов. Оба раза это были ошибки в логике программы. Да, процесс падал, перезапускался супервайзером и снова работал, но при этом клиенты не работали вообще никак.

Так что ошибки бывают двух видов -- те, при которых сервер продолжает обслуживать клиентов, и те, при которых не обслуживает.

Ошибки первого типа, это какое-нибудь редкое стечение обстоятельств, не предусмотренное разработчиком. При этом падает какой-то процесс и клиент не получает ответа на какой-то запрос. Но процесс перезапускается, и следующий запрос обрабатывается. В особо сложных случаях, когда теряется важное состояние на клиенте или на сервере, клиент реконнектится и начинает сессию сначала. Все другие клиенты работают нормально и ничего не замечают. В логах сервера остается информация о данной проблеме. Я могу ее проанализировать, найти баг и пофиксить. Баг заметил (или даже не заметил), один из сотен клиентов. Остальные остались счастливы, Erlang рулит.

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

Так что супервайзеры полезны тем, что не всякая проблема требует немедленного реагирования в любое врeмя суток, а часть проблем можно спокойно фиксить в рабочее время :)

Тестирование

Увы или к счастью, но я не фанат юнит-тестов. Какие-то части проекта покрываются ими легко. Особенно те, которые суть функции без побочных эффектов. Но в основном все трудно.

Вот взять, например, работу с базой данных. Да, можно сделать моки. Но я мало вижу проку в тестировании моков, я хочу тестировать работу с базой данных :) И это реально -- создаем отдельную, тестовую БД. При каждом запуске тестов создаем в ней пустые таблицы (и дропаем предыдущие таблицы), наполняем их тестовыми данными, и гоняем тесты. Формально это будут уже не юнит тесты, хотя их можно создавать с помощью инфраструктуры для юнит тестов (например, EUnit, если речь идет об Erlang).

Или взять, например, взаимодействие нескольких ген-серверов, обменивающихся асинхронными сообщениями. Это тоже можно тестировать, но для тестов уже нужно подымать иерархию супервайзеров и вокеров. Ну и асинхронные запросы получают ответ не сразу, тесту нужно посидеть, подождать. Обязательно вылезут какие-нибудь побочные эффекты, тесты будут мешать друг другу. Опять все не просто.

А есть еще замечательный вариант: пишется тестовый клиент, который запускается отдельно от сервера, и посылает реальные запросы на сервер. Они проходят всю цепочку: передача данных, десериализация, вызов АПИ, бизнес-логика, работа с БД (тестовой). Клиент получает ответ и сравнивает с эталоном, то ли пришло от сервера, что нужно.

Все это я пробовал, все это прекрасно, все это позволяет в какой-то мере протестировать проект автоматически. Но, увы, не полностью. Специфика многопользовательских игр такова, что одновременно происходит много всего. Много клиентов взаимодействуют друг с другом через сервер, в самом сервере много процессов, посылающих друг другу сообщения. Сама суть системы в том, что она событийная и асинхронная. Большая часть кода на сервере -- это генерация событий и реакция на них. Это совсем не то, что можно протестировать юнит-тестами.

В итоге полноценное автоматическое тестирование потребует создание довольно сложного тестового стенда, имитирующего клиентов и все игровые процессы, и гоняющего внутри сервера сотни и тысячи игр. Такой стенд сложно создать, и еще сложнее поддерживать в актуальном состоянии. Добавление новых фич, переделка старых, рефакторинг -- все это сильно усложняется. Этим можно заняться, когда проект более-менее стабилизировался и приносит доход. На ранних же стадиях дохода нет, а проект все время меняется: подстраивается под пользователей игровая логика, ищется эффективная монетизация, идеи рождаются и умирают ежедневно.

Поэтому, как это ни печально, но рассчитывать приходится в основном на ручное тестирование. И это первая причина, почему на сервере в продакшене могут быть серьезные ошибки.

Динамическая типизация

Ох да, знаю, холиварная тема :) Вы хотите поговорить об этом? Я хочу :)

Вообще, я сторонник статической типизации. Но не такой многословной и назойливой, как в Java, а лаконичной, с выводом типов, как в Scala или Haskell. Где-то я прочитал, что программист, когда пишет код, в любом случае сам занимается проверкой типов в уме. Но если ему в этом поможет компилятор, то это только плюс. Согласен с этой мыслью.

Однако, Erlang, как мы знаем, язык с динамической типизацией. И жить с этим можно, и кода нужно писать меньше, и все комфортно, пока дело не доходит до первого серьезного рефакторинга. Вот тут-то и начинаются проблемы :)

Да, я читал Мартина Фаулера, знаю, как теоретически нужно делать рефакторинг: покрыть все юнит-тестами и двигаться маленькими шагами, после каждого шага запуская тесты.

Вот-вот, тесты :) Про них я написал выше :) Если нету полного покрытия тестами, то нету и правильного рефакторинга по Фаулеру. Увы, при рефакторинге тоже приходится опираться в основном на ручное тестирование. И поэтому статическая типизация -- отнюдь не лишняя помощь.

Отсутствие статической типизации отчасти компенсируется тулом Dialyzer. Это статический анализатор кода, которы не только проверяет типы, но находит и другие проблемы в коде.

Крайне желательно продумать систему типов в масштабах всего проекта. Erlang поддерживает описания типов, наподобие алгебраических типов данных в Haskell. Компилятор не сильно обращает внимания на эту систему, а вот Dialyzer -- да. Затем нужно написать -spec для каждой функции, где аккуратно указать типы аргументов и возвращаемого значения. И вот, ура-ура, Dialyzer находит нам косяки в самых неожиданных местах, где мы думали, что у нас все ок.

В идеальном мире у нас работал бы continuous integration, и dialyzer запускался бы после каждого комита. В реальном мире подымать continuous integration для команды из двух программистов, и кодовой базы, покрытой небольшим количеством тестов, избыточно.

Dialyzer можно запустить вручную, когда надо. Обычно лень. А раз он не запускается, но и spec писать лень. И так оно живет, до поры до времени, до следующего серьезного рефакторинга. Перед которым все приводится в порядок -- spec пишутся, код исправляется до тех пор, пока не понравится dialyzer, и тогда можно рефакторить. Результат рефакторинга тестируется вручную. И это вторая причина, почему на сервере в продакшене могут быть серьезные ошибки.

Так проект и живет: фаза клепания фич, фаза приведения в порядок, и по кругу.

Обратная совместимость

Головная боль и главный источник проблем :) Как я уже говорил, клиент у нас -- iOS приложение. Пользователь устанавливает приложение на свой iPhone, и потом отнюдь не всегда хочет его обновлять. Но при этом хочет, чтобы оно работало :) Но и те пользователи, которые установят обновление, получат его не быстро. Нужна неделя, чтобы обновление получило апрув у Эппл. Поэтому мы имеет примерно такую картину:

И со всеми этими клиентами сервер должен работать. Я могу добавить в АПИ что-то, но не могу ничего убрать.

Помните, я говорил про ограниченность автоматического тестирования, и приоритет ручного? Так вот, после каждого изменения на сервере, нужно вручную тестировать клиентов всех версий :trollface

Я не всегда это делал, и именно с этим были связаны два моих серьезных косяка, когда клиенты получали отказ в обслуживании. Я тестировал с более свежим клиентом, и все работало ок. А со старыми клиентами оно работало не ок.

Обратная совместимость -- третья причина, почему на сервере в продакшене могут быть серьезные ошибки.

Да, у нас предусмотрен на крайний случай принудительный апгрейд. Мы сообщаем пользователю, что не будем его обслуживать, если он не обновится. К такому крайнему случаю мы прибегать не хотим, и пока серьезной необходимости в этом не возникало. Понятно, что при этом какую-то часть пользователей мы потеряем. А их пока мало, и все нам нужны :)

Горячее обновление кода

Я где-то читал мнение, что это штука не нужная, и без нее вполне можно жить. Да, без нее вполне можно жить. Но, черт побери, вещь удобная, приятная, и зачем же от нее отказываться, если она есть?

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

Во-вторых, в нашем случае это удобно и при обновлении продакшн сервера. Не нужно прерывать игровые сессии, заботиться о сохранении и восстановлении их состояния. Пользователи играют себе и играют, а я обновляю себе сервер и обновляю, и мы друг другу не мешаем.

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

Я обновляю сервер практически ежедневно. А перегружаю довольно редко. Может, раз в 2 недели, где-то так. Не любое обновление можно загрузить по-горячему. Если были изменения в структуре супервизоров, или в рекордах, то проще не маяться, а перегрузить сервер. При этом у пользователей прервутся и не восстановятся текущие игры. Увы. Пока что мы просто возвращаем им ставки. Позже сделаем восстановление игр.

Простой проект

Я думал, что это будет простой проект. Ну что может быть сложного в русском лото? Собрал 6 юзеров в одной комнате, дал им карточки, генерируй бочонки, да проверяй, как они закрывают клетки. Фигня же. Да тут работы на месяц, не больше :)

Да, прототип я сделал за месяц, и клиент, и сервер. До релиза понадобилась еще 2 месяца. Эх, сколько времени нужно на создание дизайна и реализации его в клиенте! А, казалось бы, 3 экрана, 2 попапа.

И вот 3 месяца после релиза. Куча изменений и на клиенте, и на сервере. Версия 1.5, ждущая сейчас апрува от Эппл, далеко ушла от версии 1.0. Один я уже не справляюсь, по клиенту работает второй разработчик.

Не бывает простых проектов :)

comments powered by Disqus