Наш опыт создания приложения на микросервисах в сфере рекламных технологий

    В августе 2015 года мы запустили новый adtech-проект — Atuko.
    Это система управления мобильной рекламой, ориентированная на профессионалов.


    В Atuko мы сфокусировались на управлении одним каналом трафика — myTarget, основной рекламной системой Mail.ru, объединившей в себе рекламу на Одноклассниках, мобильном VK и некоторых других ресурсах Mail.ru, и охватывающей >90% аудитории Рунета. И естественно рекламодателям, нужны инструменты создания кампаний, анализа результатов и управления.


    Хотим рассказать, как именно мы подошли к созданию этих инструментов и архитектуре системы.

    Для нашей команды это не первый проект в области рекламных технологий. Мы занимались разработкой систем управления рекламой с 2009 года, создавая инструменты для Яндекс.Директ, Google Adwords, Google Analytics, VK, Target@mail.ru и других каналов. Застали даже Begun и времена, когда он был актуален :)




    За это время мы столкнулись с множеством подводных камней и неожиданностей, связанных с особеностью работы рекламных площадок, их API, да и необычных задач самих рекламодателей — и успели накопить немало опыта! Рассказать обо всем в одной статье не получится, так что, если будет интерес, мы напишем серию статей, в которых постараюсь поделиться полученными знаниями.

    В этой статье я хочу рассказать ключевые вещи про архитектуру и инфраструктуру Atuko — и почему мы сделали именно так, а не иначе.

    Прошлый опыт и важные уроки


    Из нашего прошлого опыта мы, среди прочего, вынесли следующие важные уроки:
    • Необходимо гибкое масштабирование во всех узлах системы. Нельзя заранее предсказать, на какую часть системы вырастет нагрузка: анализ, создание, просмотр и т.д.
      Приведу небольшой пример. При управлении контекстной рекламой (Яндекс.Директ и Google AdWords) сначала все шло хорошо, рост был плавным. В какой-то момент появляется действительно крупный клиент — и для реализации его задач требуется управлять 9 млн ключевых слов — и это в разы больше, чем другие клиенты, вместе взятые. Некоторые части системы (например, получение конверсий из Google Analytics) спокойно справились с увеличением нагрузки, но другие (например, получение статистики по всем ключевым словам) потребовал сильной оптимизации. А самое неприятное, что объемы этого клиента сказались на работе всей системы в целом — и, соответственно, на других клиентах.
      Это научило нас изолированию и гибкости отдельных частей системы, и возможности изолировать клиентов друг от друга.
    • Необходима настройка функциональности под конкретного клиента.
      Часто у отдельных клиентов есть свои уникальные требования, и совместить задачи разных клиентов в одном универсальном решении бывает трудно или невозможно — и более эффективным решением оказывается кастомизация функционала для конкретного клиента. При этом, разумеется, эта кастомизация не должна коснуться других пользователей.
      Таким образом, нужна возможность запускать доработанную функциональность для отдельных клиентов, и при этом — не поднимать отдельную копию всей системы для каждого клиента.
    • Минимизация зависимости от фреймворков и языков программирования.
      Например, один из созданных нами проектов существует дольше, чем фреймворк, на котором он построен — поддержка и развитие фреймворка остановились. Кроме того, завязка всего проекта на один язык программирования снижает эффективность — нет возможности использовать оптимальный язык для каждой задачи, и нет возможности использовать новые языки программирования.

    Теперь я расскажу, как мы постарались предусмотреть эти моменты в архитектуре Atuko.

    Общую схему проекта можно изобразить так:



    Микросервисы


    Прежде всего, мы решили все строить на микросервисах. Каждый микросервис предоставляет HTTP API и может быть реализован на любом стеке техологий. Это дает нам возможность масштабировать каждый сервис незаметно для других, так как за HTTP API может скрываться как одна копия сервиса, так и целый кластер со своим балансировщиком.

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

    Один из частых вопросов, возникающих при использовании микросервисов — как делить функциональность на микросервисы. Для себя мы решили, что мы выделяем в отдельные микросервисы некоторый функциональный блок, который делить на более мелкие части не имеет смысла.
    Приведу пример: в Atuko есть возможность создать на рекламной площадке большое количество рекламных кампаний и объявлений, загрузив специальным образом сформированный Excel-файл. Кроме интерфейса, в этом процессе задействованы три микросервиса:
    • разбор Excel файла и создание набора данных для загрузки в myTarget
    • проверка набора данных на соответствие правилам рекламной площадки
    • отправка данных в myTarget



    При этом последние два сервиса — проверяющий корректность данных и отправляющий данные на рекламную площадку — также используются в других сценариях работы с Atuko (например, объявления можно создавать не только через Excel, но и через браузер). Сервисам при этом все равно, откуда они получают данные — они просто выполняют свою работу и передают результат дальше.

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

    Тестирование тоже несколько меняется и сильно повышается значимость интеграционного тестирования, т.к. “в вакууме” сервисы могут работать, а вот вместе уже давать сбой.

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


    Диспетчер


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

    Каждый микросервис по результатам своей работы отправляет событие в диспетчер с результатами, а диспетчер, в свою очередь, рассылает событие в подписавшиеся сервисы. Таким образом, каждый микросервис не нуждается в знании о внешней среде. Вместо этого ему достаточно подписаться на определенные события в диспетчере, а по результатам работы — отправлять события в тот же диспетчер.


    Юниты


    Введение диспетчера решает и еще одну нашу задачу — создание кастомизированной функциональности под конкретного клиента. Это возможно за счет внедрения юнитов — каждый юнит является сочетанием диспетчера и работающих с ним микросервисов.
    Рассмотрим случай, когда одному из клиентов нужно загружать конверсии из своей собственной CRM-системы в уникальном формате, требующем уникальной обработки.
    Эту задачу можно решить, если разных пользователей будут “обслуживать” разные микросервисы. Мы запускаем 2 юнита: в каждом из них свой диспетчер, и свой набор сервисов. При этом в одном из юнитов сервисы работают по обычной схеме, а в другом они заменены на сервисы, обрабатывающие конверсии из CRM клиента.

    При этом некоторые вещи никогда не будут продублированы в различных юнитах — например, взаимодействие с внешними системами. В случае Atuko, например, есть лимиты по использованию API myTarget — и поэтому внешняя коммуникация идет через один микросервис, контролирующий частоту запросов.

    Кстати, внутри юнита мы можем менять не только работу отдельных сервисов, но и количество сервисов — например, добавляя новые шаги в процедуру обработки или проверки данных.

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


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


    Docker



    В целом описанный выше подход позволил нам подготовиться к возможным трудностям. Но возникла и другая задача, связанная не столько с процессом разработки, сколько с процессом эксплуатации. Как всем этим хозяйством управлять? Каждый микросервис может быть реализован с использованием любых технологий, на любом фреймворке и иметь свои зависимости от различных библиотек. Например, на данный момент у нас есть микросервисы и на golang, и на python, и на php.

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

    Reverse proxy


    Все обращения идут через reverse proxy. Это позволяет при поднятии еще одного контейнера с сервисом просто добавить нужную запись в нужный upstream, и reverse proxy сам распределит трафик.

    В качестве reverse proxy мы сейчас используем nginx — но продолжаем рассматривать и иные варианты.

    Кроме того, для деплоя мы используем технику Blue-Green Deployment — это означает, что одновременно могут работать сервисы как с новой, так и со старой функциональностью. И в этом случае reverse proxy опять же выручает, предоставляя возможность распределять трафик в нужных пропорциях между двумя версиями, и окончательно переходить на новую, только убедившись в ее полной работоспособности.


    DNS


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

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

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


    Заключение


    Хочу сказать о результатах применения такого подхода.

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

    И конечно же, нам сильно облегчает жизнь Docker — для нас это отличный инструмент компоновки и доставки. Я смело могу рекомендовать его всем. И объединение подхода базирующегося на микросервисах с Docker дает целый ряд преимуществ в разработке, тестировании и тем более — в эксплуатации.

    Наблюдая сейчас за бурным ростом числа статей, докладов и видео на тему микросервисов и Docker, я понимаю, что в свое время мы сделали верный выбор — хоть на тот момент это и казалось новым и непроверенным подходом. Поэтому рекомендую всем, кто начинает новый проект или хочет модифицировать старый, подумать над использованием микросервисов, Docker и деления на юниты.

    Мы же, если будет интерес, готовы и дальше делиться теми знаниями которые приобрели за это время: о микросервисах на golang, мониторинге и тестировании, интерфейсе на базе ReactJS + Flux и многом другом.

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 20

      0
      Интересна конкретная реалзиация «диспечера».
        0
        Диспетчер построен на базе RabbitMQ плюс некоторые свои обвязки позволяющие либо объединять некоторые потоки событий, либо наоборот дробить в зависимости от нагружености и потребностей. События диспетчер рассылает используя push подход, то есть сервису не нужно держать коннект с диспетчером.
        0
        А можно поподробней про docker-контейнеры на нескольких серверах? Запускаете ли несколько одинаковых микросервисов на одном хосте? Как мониторите? Как логируете?
          0
          Ну на самом деле почти каждый из этих вопросов тянет на отдельную статью. Но если коротко, то:

          1. docker-контейнеры на нескольких серверах — это вообще отдельная большая история в том числе и для нас, есть куда развиваться и что еще попробовать, но в общих чертах все запросы проходят через reverse proxy то есть поднимая еще одну копию сервиса в докер контейнере мы просто дописываем нужную запись в upstream у nginx, при этом физически два контейнера могут быть запущены на разных машинах

          2. запускаем ли одинаковые микросервисы на одном хосте? Да, как минимум при деплое, я упоминал, что мы используем BGD технику, которая подразумевает в какой то момент работу параллельно двух контейнеров одного микросервиса.

          3. Для мониторинга у нас используется DataDog, influxdb + grafana, но мы продолжаем экспериментировать в этом направлении, потому как мониторинг при нашем подходе очень важная составляющая, собираемся попробовать prometheus

          4. Логируем logstash + Elastic но тоже продолжаем экспериментировать в этом направлении, эта связка не очень нравится своей громоздкостью, но пока не нашли достаточно надежной, обкатанной альтернативы
            0
            1. Т.е. на каждом хосте есть nginx, который проксирует запросы контейнерам, а в апстрим добавляются по DNS-имени. Как вы всё это админите? Руками?
            2. Опять-же — как это всё оркестрируется — именно процесс деплоя по BGD
            3. Но для мониторинга подразумевается наличие отдельного процесса (преимущественно в том же контейнере), что противоречит идеалогии docker
            4. По логированию я так понял используете встроенный механизм логирования в докере?
          0
          1. нет nginx не на каждом хосте, то есть возможны например варианты поднимаем инстанс на амазоне, запускаем в нем один контейнер, добавляем запись в апстрим, то есть по сути nginx может быть вообще один, который проксирует запросы контейнерам в том числе и на разных машинах, но на деле получается nginx не один. Админим не руками, ansible'ом, пока мы небольшие справляемся.
          2. И тут ansible и опять же пока мы небольшие этого достаточно, но в целом экспериментируем и ищем пути большей автоматизации с целью большего исключения человеческого фактора. Переключаем тоже используя ansible, в идеале конечно настроить автоматическое переключение и откат в случае неудачи. То есть например при запуске нового контейнера (green) автоматом часть трафика заруливается на него, в течении какого то времени собираются метрики и если заданные пороговые значения не превышены, то старый контейнер (blue) выключается, а весь трафик заруливается на green, если же пороговые значения превышены, то весь трафик возвращается на blue. Но пока делаем это руками используя ansible
          3. Мониторинг, тут есть какбы две стороны медали, первая сервис сам о себе шлет некоторый набор метрик — это первая сторона и вторая — это действительно нужен отдельный процесс, но не нужен в том же контейнере, у нас запущен в отдельном контейнере c -v /var/run/docker.sock:/var/run/docker.sock --pid=host чего в большинстве случаев достаточно для мониторинга, по крайней мере нам пока хватает.
          А вот на счет идеологии докера, к сожалению мы иногда ее нарушаем, хотя стараемся этого не делать. Приведу пример плохой практики случавшейся у нас: — микросервис на php, который также требует запуска некоторой команды по расписанию, выполнено в виде контейнера с php-fpm и кроном. Сейчас наученные опытом мы избегаем таких решений, но тем не менее это случалось в нашей практике и до сих пор остались микросервисы работающие в таком исполнении.
          4. и да и нет, сервисы свои логи пишут в логстеш, есть еще datadog, который имеет готовые решения для логирования некоторых событий докера
            0
            1. Если Nginx может быть один, значит Вы используете проброс портов от docker-а и они все слушают на внешнем интерфейсе железки, что в некоторых случаях неудобно.

            Не смотрели в сторону Kubernetes или им подобных?
              0
              Да, в некоторых случаях контейнеры слушают внешние интерфейсы железки и да это неудобно, согласен.
              Когда начинали небыло столь широкого выбора инструментов готовых для запуска в проде, поэтому сделали так. Сейчас собираемся менять схему, пока не решили, что имено будем использовать, смотрим в сторону docker swarm и kubernets, так что этот выбор нам еще предстоит сделать. Ну а когда решим готовы поделиться опытом, ну и чужой опыт всегда ценим, лучше учиться на чужих ошибках, чем на своих
                0
                Если вы будете использовать встроенные возможности Docker swarm для multihost network, то до выхода новой версии 1.12 (обещают 14.07 выпустить), вы столкнетесь с ограничениями системы внутри docker контейнера на одновременные соединения /proc/net/core/somaxconn (по умолчанию 128).
                +1
                «значит Вы используете проброс портов от docker-а и они все слушают на внешнем интерфейсе железки» — не обязательно так. Можно использовать внутреннею сеть докера и dns записи привязанные к контейнеру и его внутреннему IP. Docker поддерживает multihost networking, но в таком варианте каждый микросервис состоит не из 1, а из 2х и более контейнеров: 1 — nginx к которому идет обращение по внутреннему dns имени и внутри которого upstream (внутрениие IP адреса) на контейнеры с phpfpm\go\python, таким образом достигается гибкое горизонтальное маштабирование.
                  0
                  А чем реализована внутренняя сеть докера и dns? Если на всех хостах сеть докера будет 172.17.0.0/16, то он сам разрулит какой IP какому контейнеру и они не будут пересекаться?
                    0
                    Если в кратце, то создается сеть внутри докера с признаком overlay, создается 172.18.0.0/16 и все контейнеры которые запускатся в этой сети используют этот диапозон, и да, докер сам разрулит.
                    Есть есть время прочитайте https://docs.docker.com/engine/userguide/networking/dockernetworks/ и https://docs.docker.com/engine/userguide/networking/get-started-overlay/
                  +1

                  Ещё варианты решения проблемы с пробросом портов:


                  • использовать -p server_int_ip:server_port:container_port;
                  • запускать docker -d/docker daemon с --iptables=false и управлять пробрасыванием портов самостоятельно.

                  Второй вариант мы используем на системах с centos 7 и firewalld, соответственно для моста докера создаётся отдельная зона, на ней открываются нужные порты, используется docker-proxy, который слушает 0.0.0.0. По умолчанию, доступа снаружи к этим портам нет, т. к. оно закрыто firewall'ом. ICC работает из коробки при использовании опции --link, а при использовании внешних/vpn-адресов для зоны docker разрешается доступ к нужным портам (или ко всем сразу, как вариант).


                  firewall-cmd --zone=docker --list-all
                  docker (active)
                    interfaces: docker0
                    sources: 
                    services: 
                    ports: 1024-65535/tcp
                    masquerade: no
                    forward-ports: 
                    icmp-blocks: 
                    rich rules:
                    0

                    Добавлю, что при этом у нас используется tinc (p2p vpn), что даёт прозрачную сеть всему кластеру.

                0
                scriptuoz, не опишете поподробней устройство именно микросервисов? Хотелось бы копнуть поглубже, увидеть их изнутри. Т.е. они у вас синхронные или асинхронные, с внутренней очередью задач и т.д. Насколько для асинхронных задач хорошо подошёл REST, не применяли ли вебсокеты?
                  +1
                  claygod извиняюсь за задержку.
                  в одном комментарии пожалуй все сразу не расскажешь. У нас есть некоторые требовавния к микросервисам, они должны предоставлять HTTP API, они должны слать метрики и должны слать логи. Эти требования обязательные. Все остальное в общем достаточно гибко, каждый микросервис может быть реализован на каком-то своем технологическом стеке, в том числе и каждый сервис сам решает нужна ли ему внутри очередь или нет. В какой то мере диспетчер выполняет роль очереди на уровне приложения, тем не менее каждый микросервис может использовать свои очереди, есть доступные инструменты, которые уже настроены и работают и которыми может воспользоваться любой сервис, например RabbitMQ.
                  Микросервисы у нас по своей природе асинхронные, более того все общение происходит через диспетчер, куда каждый микросервис посылает событие при изменившемся состоянии, на это событие могут реагировать другие сервисы в результате чего также изменят свое состояние и кинут свое событие в диспетчер.
                  на счет REST не могу сказать, что мы во всех микросервисах остались в рамках идеально чистого REST, но в целом это работает, что касается асинхронных задач, то в целом наш подход похож на описываемый здесь http://restcookbook.com/Resources/asynchroneous-operations/
                  вебсокеты не применяли, пока. В ближайшее время выкатим решение на SSE
                    0
                    Спасибо, очень интересный ответ!

                    Как я понял, микросервис получив запрос отдаёт 202 (принял), и потом в диспетчер кидает подготовленный ответ. Если микросервис быстрый, вы оставляете диспетчера ждать ответа, или он в любом случае неблокирующийся? А есть блокирующиеся варианты, т.е. где вы в угоду скорости отказались от асинхронности?
                      +1
                      Диспетчер от сервиса должен дождаться только подтверждения, что сообщение получено, задерживать диспетчер не хорошо, у него работы много =)
                      Да, есть блокирующиеся вызовы, это обращения напрямую от сервиса к сервису, такие у нас тоже есть, но их очень мало, как правило это получение каких-то данных, которые есть в другом сервисе. Мы иногда думаем над тем, чтобы заменить и такие вызовы, чем то вроде:
                      сервис А кидает сообщение в диспетчер что ему нужны данные Y (событие: «нужны данные Y»)
                      диспетчер посылает уведомление в сервисы/сервис которые обладают этими данными, на самом деле диспетчер не знает, кто обладает этими данными, диспетчер просто знает, что сервис B попросил уведомлять его о событиях «нужны данные Y»
                      сервис или сервисы B получив такое уведомление, формируют данные и в событии отправляют их в диспетчер, кто быстрее тот и молодец, диспетчер перенаправляет событие с данными в запросивший сервис А. Это позволит избежать ожиданий, когда один сервис висит ожидая данных от другого, к тому же есть возможность получить данные от того сервиса кто сделает это быстрее. Но пока мы в случае когда необходимо просто получить данные другого сервиса у нас выполняется прямой запрос из одного сервиса в другой, таких вызовов очень немного и м не хотим чтобы их кол-во росло, потому что сложнее за всем этим потом следить.
                        0
                        Да, есть блокирующиеся вызовы, это обращения напрямую от сервиса к сервису, такие у нас тоже есть, но их очень мало, как правило это получение каких-то данных, которые есть в другом сервисе.

                        Я понял, что это небольшое отступление от правил во имя производительности ))

                        По поводу блокировок я вот почему спросил:
                        Есть комплекс юнитов, брокеров и т.д. который можно назвать приложением. Снаружи к приложению не приходят запросы, на которые надо дать мгновенный ответ? Как решается такого рода проблема?

                        Ещё интересно, нет ли у вас какой-то «песочницы» для микросервисов, в которой они обкатываются? Какое-то тренировочное место… И есть ли дефолтное ТЗ для разработки микросервиса? Любопытно было бы посмотреть.
                          0
                          На счет запросов снаружи не совсем понял суть проблемы. Если имеется в виду как организовано общение с браузером клиента, то это примерно так: есть приложение SPA на Reactjs, которое общается с нашим API, которое доступно снаружи, API в свою очередь посылает события в диспетчер и точно также получает события из диспетчера по результатам, которых меняет свое состояние. Сейчас идем к тому, что API будет получая определенные события, отправлять сообщения на клиент посредством SSE.

                          В качестве песочницу у нас stage сервер, который максимально приближен по состоянию к проду, там можно погонять свои сервисы посмотреть как они себя поведут в реальной жизни.

                          Дефолтного ТЗ нет, мы стараемся не пользоваться шаблонами в таких вопросах как постановка задач, каждый раз это написание с нуля и продумывание деталей, зато с каждым разом мы открываем все новые и новые детали =)

                Only users with full accounts can post comments. Log in, please.