Как стать автором
Обновить

Кейт Матсудейра: Масштабируемая веб-архитектура и распределенные системы

Время на прочтение 32 мин
Количество просмотров 83K
Автор оригинала: Кейт Мэтсудейра
Шесть месяцев назад ребром встал вопрос о тексте для моего дипломного перевода. Результатом помощи коллективного разума стало решение переводить главу Scalable Web Architecture and Distributed Systems за авторством Kate Matsudaira. Нужно отметить, что это мой первый перевод такого объема и сложности. Текст, был мною относительно успешно переведен, хотя по качеству перевода я поставил бы себе 6-7 из 10. Дабы мои усилия не пропали втуне, публикую результат своих трудов.

По просьбам читателей Хабра, теперь полная версия в виде топика.

The Architecture of Open Source Applications (Volume 2)

Масштабируемая веб-архитектура и распределенные системы


Кейт Матсудейра

Перевод: jedi-to-be.
Коррекция: Anastasiaf15, sunshine_lass, Amaliya, fireball, Goudron.




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



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



1.1 Принципы построения распределенных веб-систем



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



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



  • Доступность: длительность работоспособного состояния веб-сайта критически важна по отношению к репутации и функциональности многих компаний. Для некоторых более крупных онлайновых розничных магазинов, недоступность даже в течение нескольких минут может привести к тысячам или миллионам долларов потерянного дохода. Таким образом, разработка их постоянно доступных и эластичных к отказу систем и является и фундаментальным деловым и технологическим требованием. Высокая доступность в распределенных системах требует внимательного рассмотрения избыточности для ключевых компонентов, быстрого восстановления после частичных системных отказов и сглаженного сокращения возможностей при возникновении проблем.
  • Производительность: Производительность веб-сайта стала важным показателем для большинства сайтов. Скорость веб-сайта влияет на работу и удовлетворенность пользователей, а также ранжирование поисковыми системами — фактор, который непосредственно влияет на удержание аудитории и доход. В результате, ключом является создание системы, которая оптимизирована для быстрых ответов и низких задержек.
  • Надежность: система должна быть надежной, таким образом, чтобы определенный запрос на получение данных единообразно возвращал определенные данные. В случае изменения данных или обновления, то тот же запрос должен возвращать новые данные. Пользователи должны знать, если что-то записано в систему или храниться в ней, то можно быть уверенным, что оно будет оставаться на своем месте для возможности извлечения данных впоследствии.
  • Масштабируемость: Когда дело доходит до любой крупной распределенной системы, размер оказывается всего лишь одним пунктом из целого списка, который необходимо учитывать. Не менее важным являются усилия, направленные на увеличение пропускной способности для обработки больших объемов нагрузки, которая обычно и именуется масштабируемость системы. Масштабируемость может относиться к различным параметрам системы: количество дополнительного трафика, с которым она может справиться, насколько легко нарастить ёмкость запоминающего устройства, или насколько больше других транзакций может быть обработано.
  • Управляемость: проектирование системы, которая проста в эксплуатации еще один важный фактор. Управляемость системы приравнивается к масштабируемости операций «обслуживание" и «обновления». Для обеспечения управляемости необходимо рассмотреть вопросы простоты диагностики и понимания возникающих проблем, легкости проведения обновлений или модификации, прихотливости системы в эксплуатации. (То есть, работает ли она как положено без отказов или исключений?)
  • Стоимость: Стоимость является важным фактором. Она, очевидно, может включать в себя расходы на аппаратное и программное обеспечение, однако важно также рассматривать другие аспекты, необходимые для развертывания и поддержания системы. Количество времени разработчиков, требуемое для построения системы, объем оперативных усилий, необходимые для запуска системы, и даже достаточный уровень обучения — все должно быть предусмотрено. Стоимость представляет собой общую стоимость владения.


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



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





1.2 Основы



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



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



Пример: Приложение хостинга изображений



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



Вообразите систему, где пользователи имеют возможность загрузить свои изображения на центральный сервер, и при этом изображения могут запрашиваться через ссылку на сайт или API, аналогично Flickr или Picasa. Для упрощения описания давайте предположим, что у этого приложения есть две основные задачи: возможность загружать (записывать) изображения на сервер и запрашивать изображения. Безусловно, эффективная загрузка является важным критерием, однако приоритетом будет быстрая доставка по запросу пользователей (например, изображения могут быть запрошены для отображения на веб-странице или другим приложением). Эта функциональность аналогична той, которую может обеспечить веб-сервер или граничный сервер Сети доставки контента (Content Delivery Network, CDN). Сервер CDN обычно хранит объекты данных во многих расположениях, таким образом, их географическое/физическое размещение оказывается ближе к пользователям, что приводит к росту производительности.



Другие важные аспекты системы:



  • Количество хранимых изображений может быть безгранично, таким образом, масштабируемость хранения необходимо рассматривать именно с этой точки зрения.
  • Должна быть низкая задержка для загрузок/запросов изображения.
  • Если пользователь загружает изображение на сервер, то его данные должны всегда оставаться целостными и доступными.
  • Система должна быть простой в обслуживании (управляемость).
  • Так как хостинг изображений не приносит большой прибыли, система должна быть экономически эффективной.


Рисунок 1.1 представляет собой упрощенную схему функциональности.




Рисунок 1.1: Упрощенная схема архитектуры для приложения хостинга изображений




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





Сервисы



При рассмотрении дизайна масштабируемой системы, бывает полезным разделить функциональность и подумать о каждой части системы как об отдельной службе с четко определенным интерфейсом. На практике считается, что системы разработанные таким образом имеют Service-Oriented Architecture (SOA). Для этих типов систем у каждой службы существует свой собственный отличный функциональный контекст, и взаимодействие с чем-либо за пределами этого контекста происходит через абстрактный интерфейс, обычно общедоступный API другой службы.



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



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



Предположим, что в будущем служба находится в интенсивном использовании; такой сценарий помогает лучше проследить, как более длительные записи влияют на время для считывания изображения (так как две функции будут конкурировать за совместно используемые ресурсы). В зависимости от архитектуры этот эффект может быть существенным. Даже если скорость отдачи и приема будут одинаковы (что не характерно для большинства сетей IP, поскольку они разработаны для соотношения скорости приема к скорости отдачи как минимум 3:1), считываемые файлы будут обычно извлекаться из кэша, и записи должны, в конечном счете, попасть на диск (и возможно подвергнуться многократной перезаписи в похожих ситуациях). Даже если все данные будут в памяти или читаться с дисков (таких как твердотельные диски SSD), то записи в базу данных почти всегда будут медленнее, чем чтения из нее. (Pole Position, инструмент с открытым исходным кодом для сравнительного тестирования баз данных, http://polepos.org/ и результаты http://polepos.sourceforge.net/results/PolePositionClientServer.pdf.).



Другая потенциальная проблема с этим дизайном состоит в том, что у веб-сервера, такого как Apache или lighttpd обычно существует верхний предел количества одновременных соединений, которые он в состоянии обслужить (значение по умолчанию — приблизительно 500, но оно может быть намного выше), и при высоком трафике записи могут быстро израсходовать этот предел. Так как чтения могут быть асинхронными или использовать в своих интересах другую оптимизацию производительности как gzip-сжатие или передача с делением на порции, веб-сервер может переключить чтения подачи быстрее и переключиться между клиентами, обслуживая гораздо больше запросов, чем максимальное число соединений (с Apache и максимальным количеством соединений, установленном в 500, вполне реально обслуживать несколько тысяч запросов чтения в секунду). Записи, с другой стороны, имеют тенденцию поддерживать открытое соединение на протяжении всего времени загрузки. Так передача файла размером 1 МБ на сервер могла занять больше 1 секунды в большинстве домашних сетей, в результате веб-сервер сможет обработать только 500 таких одновременных записей.




Рисунок 1.2: Разделение чтения и записи


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



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



Конечно, работа вышеупомянутой модели будет оптимальной, в случае наличия двух различных конечных точек (фактически, это очень похоже на несколько реализаций провайдеров «облачного» хранилища и Сетей доставки контента). Существует много способов решения подобных проблем, и в каждом случае можно найти компромисс.



К примеру, Flickr решает эту проблему чтения-записи, распределяя пользователи между разными модулями, таким образом, что каждый модуль может обслуживать только ограниченное число определенных пользователей, и когда количество пользователи увеличиваются, больше модулей добавляется к кластеру (см. презентацию масштабирования Flickr,
http://mysqldba.blogspot.com/2008/04/mysql-uc-2007-presentation-file.html). В первом примере проще масштабировать аппаратные средства на основе фактической нагрузки использования (число чтений и записей во всей системе), тогда как масштабировние Flickr просиходит на основе базы пользователей(однако, здесь используется предположение равномерного использования у разных пользователей, таким образом, мощность нужно планировать с запасом). В прошлом недоступность или проблема с одной из служб приводили в нерабочее состояние функциональность целой системы (например, никто не может записать файлы), тогда недоступность одного из модулей Flickr будет влиять только на пользователей, относящихся к нему. В первом примере проще выполнить операции с целым набором данных — например, обновляя службу записи, чтобы включить новые метаданные, или выполняя поиск по всем метаданным изображений — тогда как с архитектурой Flickr каждый модуль должен был быть подвергнут обновлению или поиску (или поисковая служба должна быть создана, чтобы сортировать те метаданные, которые фактически для этого и предназначены).



Что касается этих систем — не существует никакой панацеи, но всегда следует исходить из принципов, описанных в начале этой главы: определить системные потребности (нагрузка операциями «чтения» или «записи» или всем сразу, уровень параллелизма, запросы по наборам данных, диапазоны, сортировки, и т.д.), провести сравнительное эталонное тестирование различных альтернатив, понять условия потенциального сбоя системы и разработать комплексный план на случай возникновения отказа.





Избыточность



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



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



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

.

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

.

Например, в нашем приложении сервера изображения, все изображения имели бы избыточные копии где-нибудь в другой части аппаратных средств (идеально — с различным географическим местоположением в случае такой катастрофы, как землетрясение или пожар в центре обработки данных), и службы получения доступа к изображениям будут избыточны, при том, что все они потенциально будут обслуживать запросы. (См. рисунок 1.3.)
Забегая вперед, балансировщики нагрузки — отличный способ сделать это возможным, но подробнее об этом ниже.




Рисунок 1.3: Приложение хостинга изображений с избыточностью




Сегментирование



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



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



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



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



В нашем примере сервера изображения, возможно, что единственный файловый сервер, используемый для хранения изображения, можно заменить множеством файловых серверов, при этом каждый из них будет содержать свой собственный уникальный набор изображений. (См. рисунок 1.4.) Такая архитектура позволит системе заполнять каждый файловый сервер изображениями, добавляя дополнительные серверы, по мере заполнения дискового пространства. Дизайн потребует схемы именования, которая свяжет имя файла изображения с содержащим его сервером. Имя изображения может быть сформировано из консистентной схемы хеширования, привязанной к серверам. Или альтернативно, каждое изображение может иметь инкрементный идентификатор, что позволит службе доставки при запросе изображения обработать только диапазон идентификаторов, привязанных к каждому серверу (в качестве индекса).




Рисунок 1.4: Приложение хостинга изображений с избыточностью и сегментированием


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



Другая потенциальная проблема возникает в форме
несогласованности (неконсистетности).Когда различные сервисы выполняют считывание и запись на совместно используемом ресурсе, потенциально другой службе или хранилище данных, существует возможность возникновения условий «состязания» — где некоторые данные считаются обновленными до актуального состояния, но в реальности их считывание происходит до момента актуализации — и таком случае данные неконсистентны. Например, в сценарии хостинга изображений, состояние состязания могло бы возникнуть в случае, если бы один клиент отправил запрос обновления изображения собаки с изменением заголовка «Собака» на «Гизмо», в тот момент, когда другой клиент считывал изображение. В такой ситуации неясно, какой именно заголовок, «Собака» или «Гизмо», был бы получен вторым клиентом.

.

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







1.3. Структурные компоненты быстрого и масштабируемого доступа к данным



Рассмотрев некоторые базовые принципы в разработке распределенных систем, давайте теперь перейдем к более сложному моменту — масштабирование доступа к данным.



Самые простые веб-приложения, например, приложения стека LAMP, схожи с изображением на рисунке 1.5.




Рисунок 1.5: Простые веб-приложения


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



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




Рисунок 1.6: Упрощенное веб-приложение


Большинство систем может быть упрощено до схемы на рисунке 1.6,
которая является хорошей отправной точкой для начала рассмотрения. Если у Вас есть много данных, можно предположить, что Вы хотите иметь к ним такой же легкий доступ и быстрый доступ, как к коробке с леденцами в верхнем ящике вашего стола. Хотя данное сравнение чрезмерно упрощено, оно указывает на две сложные проблемы: масштабируемость хранилища данных и быстрый доступ к данным.



Для рассмотрения данного раздела давайте предположим, что у Вас есть много терабайт (ТБ) данных, и Вы позволяете пользователям получать доступ к небольшим частям этих данных в произвольном порядке. (См. рисунок 1.7.)
Схожей задачей является определение местоположения файла изображения где-нибудь на файловом сервере в примере приложения хостинга изображений.




Рисунок 1.7: Доступ к определенным данным


Это особенно трудно, потому что загрузка терабайтов данных в память может быть очень накладной и непосредственно влияет на количество дисковых операций ввода-вывода. Скорость чтения с диска в несколько раз ниже скорости чтения из оперативной памяти — можно сказать, что доступ к памяти с так же быстр, как Чак Норрис, тогда как доступ к диску медленнее очереди в поликлинике. Эта разность в скорости особенно ощутима для больших наборов данных; в сухих цифрах доступ к памяти 6 раз быстрее, чем чтение с диска для последовательных операций чтения, и в 100,000 раз — для чтений в случайном порядке (см. «Патологии Больших Данных», http://queue.acm.org/detail.cfm?id=1563874). ). Кроме того, даже с уникальными идентификаторами, решение проблемы нахождения местонахождения небольшой порции данных может быть такой же трудной задачей, как и попытка не глядя вытащить последнюю конфету с шоколадной начинкой из коробки с сотней других конфет.



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



Кэши



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



Каким же образом кэш может использоваться для ускорения доступа к данным в рамках нашего примера API? В этом случае существует несколько мест, подходящих размещения кэша. В качестве одного из возможных вариантов размещения можно выбрать узлы на уровне запроса, как показано на
рисунке 1.8.




Рисунок 1.8: Размещение кэша на узле уровня запроса


Размещение кэша непосредственно на узле уровня запроса позволяет локальное хранение данных ответа. Каждый раз, когда будет выполняться запрос к службе, узел быстро возвратит локальные, кэшированные данные, если таковые существуют. Если это не будет в кэше, то узел запроса запросит данные от диска. Кэш на одном узле уровня запроса мог также быть расположен как в памяти (которая очень быстра), так и на локальном диске узла (быстрее, чем попытка обращения к сетевому хранилищу).




Рисунок 1.9: Системы кэшей


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





Глобальный кэш



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



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




Рисунок 1.10: Глобальный кэш, где кэш ответственен за извлечение



Рисунок 1.11: Глобальный кэш, где узлы запроса ответственны за извлечение




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





Распределенный кэш



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



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




Рисунок 1.12: Распределенный кэш.


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



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



Memcached используется во многих больших веб-сайтах, и даже при том, что он может быть очень мощным, представляет собой просто хранилище типа ключ-значение в оперативной памяти, оптимизированного для произвольного хранения данных и быстрых поисков (O(1)).



Facebook использует несколько различных типов кэширования, чтобы добиться высокой производительности своего сайта (см., «Facebook: кэширование и производительность»). Они используют $GLOBALS и APC, кэширующие на уровне языка (представленные в PHP за счет вызова функции), который способствует ускорению промежуточных вызовов функции и получению результатов. (Большинство языков оснащены этими типами библиотек для улучшения производительность веб-страницы, и они почти всегда должны использоваться.) Кроме того Facebook использует глобальный кэш, который распределен на множество серверов (см. "Масштабирование memcached в Facebook"), таким образом, что один вызов функции, получающий доступ к кэшу, мог параллельно выполнить множество запросов для данных, хранящихся на различных серверах Memcached. Такой подход позволяет добиться намного более высокой производительности и пропускной способности для данных профиля пользователя, и создать централизованную архитектуру обновления данных. Это важно, так как, при наличии тысяч серверов, функции аннулирования и поддержания непротиворечивости кэша могут вызывать затруднение.



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





Прокси



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




Рисунок 1.13 Прокси-сервер


Прокси также очень полезны при координировании запросов, поступающих от большого количества серверов, что дает возможность оптимизировать трафик запроса в масштабе всей системы. Один их способов использования прокси для ускорения доступа к данным заключается в объединении одинаковых или схожих запросов и передачи единого ответа клиентам запроса. Этот термин получил название сжатое перенаправление (collapsed forwarding).



Представим, что с нескольких узлов поступают запросы на одинаковые данные (назовем их littleB), но в кэше часть этих данных отсутствует. Если этот запрос направляется через прокси, то все запросы могут быть объединены в один, и в результате этой оптимизации littleB будет считан с диска только один раз. (См. рисунок 1.14) В этом случае придется немного пожертвовать скоростью, поскольку процесс обработки запросов и их объединения привести к несколько более длительным задержкам. Однако, при высокой нагрузке это напротив приведет к улучшению производительности, особенно в случае многократных запросов одинаковых данных. Стратегия функционирования прокси аналогична кэшу, но вместо хранения данных, он оптимизирует запросы или вызовы документам.



В LAN-прокси, например, клиенты не нуждаются в своем собственном IP-адресе, для соединения с Интернетом. Прокси объединяет запросы от клиентов на одинаковый контент. Однако это порождает двусмысленность, так как многие прокси являются также и кэшами (поскольку являются логичным местом для размещения кэша), но не все кэши работают как прокси.




Рисунок 1.14: Использование прокси-сервера для комбинирования запросов


Еще одним отличный способ использования прокси состоит не просто в объединении запросов одинаковых данных, но также и частей данных, которые находятся пространственно близко друг к другу в хранилище источника (последовательно на диске). Использование такой стратегии максимизирует локальность данных для запросов, что может привести к сокращению задержки запроса. Например, если набор узлов запрашивает части B: часть-B1, часть-B2, и т.д., мы можем настроить наш прокси, чтобы он распознавал пространственное местоположение отдельных запросов, комбинируя их в единственный запрос и возвращаясь только bigB, значительно минимизируя чтения из источника данных. (См. рисунок 1.15) В случае доступа к целым терабайтам данных в произвольном порядке время реализации запроса может сильно отличаться. Так как прокси могут, по существу, сгруппировать несколько запросов в один, они особенно полезны в ситуациях с высокой нагрузкой или ограниченными возможностями кэширования.




Рисунок 1.15: Использование прокси для комбинирования запросов на данные, находящихся пространственно близко друг к другу


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



Если вы рассматриваете добавление прокси в ваши системы, то у вас есть много вариантов для выбора;
Squid и
Varnish прошли испытания временем и широко используются во многих производительных веб-сайтах. Эти решения для прокси предлагают множество вариантов оптимизации, чтобы выжать максимум из клиент-серверного обмена данными. Установка одного из них в режиме реверсивного прокси (описан ниже в разделе о балансирующей нагрузке) на уровне веб-сервера может значительно улучшить производительность веб-сервера, уменьшая объем работы для обработки входящих клиентских запросов.





Индексы



Использование индекса для получения быстрого доступа к вашим данным — известная стратегия для того, чтобы эффективно оптимизировать доступ к данным. Наиболее широкое применение индексирование находит в базах данных. Индекс делает взаимные уступки, используя издержки объемов хранения данных и снижая скорости операций «записи» (так как вы должны одновременно и записывать данные, и обновлять индекс), позволяя получить выигрыш в виде более быстрых операций «чтения».



Вы можете также применить эту концепцию к более крупным хранилищам данных, точно так же, как и к реляционным наборам данных. Хитрость с индексами заключается в четком понимании того, как пользователи получают доступ к вашим данным. В случае если объемы наборов данных измеряются многими терабайтами, а полезной информации в них совсем немного (например, 1 Кбайт), использование индексов является необходимостью для оптимизации доступа к данным. Нахождение малой по размеру полезной информации в таком большом наборе данных может быть реальной проблемой, так как вы точно не сможете последовательно перебрать такое большое количество данных за любое разумное время. Кроме того, весьма вероятно, что такой большой набор данных распределен между несколькими (или многими!) физическими устройствами, и это означает, что вам необходимо каким-то образом найти правильное физическое местоположение нужных данных. Индексы — лучший способ сделать это.




Рисунок 1.16: Индексы


Индекс может использоваться как оглавление, которое направляет вас к местоположению ваших данных. К примеру, скажем, вы ищете порцию данных, часть №2 секции «B» — как вы узнаете, где ее найти? Если у вас есть индекс, отсортированный по типу данных — назовем данные «A», «B», «C» — он укажет вам расположение данных «B» в источнике. Тогда вы просто должны найти это расположение и считать ту часть «B», которая вам нужна. (См. рисунок 1.16)



Данные индексы часто хранятся в памяти или где-нибудь очень локально по отношению к входящему запросу клиента. Berkeley DB (BDB) и древовидные структуры данных, которые обычно используются, чтобы хранить данные в упорядоченных списках, идеально подходят для доступа с индексом.



Часто имеется много уровней индексов, которые служат картой, перемещая вас от одного местоположения к другому, и т.д., до тех пор пока вы не получите ту часть данных, которая вам необходима. (См. рисунок 1.17)




Рисунок 1.17: Многоуровневые индексы


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



Например, предположим, что система хостинга изображений, упомянутая выше, на самом деле размещает изображения книжных страниц, и сервис обеспечивает возможность клиентских запросов по тексту в этих изображениях, ища все текстовое содержимое по заданной теме также, как поисковые системы позволяют вам искать по HTML-содержимому. В этом случае все эти книжные изображения используют очень много серверов для хранения файлов, и нахождение одной страницы для представления пользователю может быть достаточно сложным. Изначально обратные индексы для запроса произвольных слов и наборов слов должны быть легкодоступными; тогда существует задача перемещения к точной странице и месту в этой книге и извлечения правильного изображения для результатов поиска. Таким образом, в этом случае инвертированный индекс отобразился бы на местоположении (таком как книга B), и затем B может содержать индекс со всеми словами, местоположениями и числом возникновений в каждой части.



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





Слово(а) Книга(и)
будучи удивительной книга B, Книга C, Книга D
всегда Книга C, Книга F
верьте Книга B



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



И это ключевой момент в крупномасштабных системах, потому что даже будучи сжатыми, эти индексы могут быть довольно большими и затратными для хранения. Предположим, что у нас есть много книг со всего мира в этой системе, — 100,000,000 (см. запись блога «Внутри Google Books»)— и что каждая книга состоит только из 10 страниц (в целях упрощения расчетов) с 250 словами на одной странице: это суммарно дает нам 250 миллиардов слов. Если мы принимаем среднее число символов в слове за 5, и каждый символ закодируем 8 битами (или 1 байтом, даже при том, что некоторые символы на самом деле занимают 2 байта), потратив, таким образом, по 5 байтов на слово, то индекс, содержащий каждое слово только один раз, потребует хранилище емкостью более 1 терабайта. Таким образом, вы видите, что индексы, в которых есть еще и другая информация, такая, как наборы слов, местоположение данных и количества употреблений, могут расти в объемах очень быстро.



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



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



Балансировщики нагрузки



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




Рисунок 1.18: Балансировщик нагрузки


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



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




Рисунок 1.19: Множественные балансировщики нагрузки


Как и прокси, некоторые балансировщики нагрузки могут также направлять запросы по-разному, в зависимости от типа запроса. Они также известны как реверсивные (обратные) прокси.



Управление данными, специфичными для определенного сеанса пользователя, является одной из проблем при использовании балансировщиков нагрузок. На сайте электронной коммерции, когда у Вас есть только один клиент, очень просто позволить пользователям помещать вещи в свою корзину и сохранять ее содержимое между визитами (это важно, так как вероятность продажи товара значительно возрастает, если по возвращении пользователя на сайт, продукт все еще находится в его корзине). Однако если пользователь направлен к одному узлу для первого сеанса, и затем к другому узлу во время его следующего посещения, то могут возникать несоответствия, так как новый узел может не иметь данных относительно содержимого корзины этого пользователя. (Разве вы не расстроитесь, если поместите упаковку напитка Mountain Dew в Вашу корзину, и, когда вернетесь, ее там уже не будет?) Одно из решений может состоять в том, чтобы сделать сеансы «липкими», так чтобы пользователь был всегда направлен к тому же узлу. Однако использование в своих интересах некоторых функций надежности, таких как автоматическая отказоустойчивость, будет существенно затруднено. В этом случае корзина пользователя всегда будет иметь содержание, но если их липкий узел станет недоступным, то будет необходим особый подход, и предположение о содержании корзины не будет больше верно (хотя, стоит надеяться, что это предположение не будет встроено в приложение). Конечно, данную проблему можно решить при помощи других стратегий и инструментов, как описанных в этой главе, таких как службы, так и многих других (как кэши браузера, cookie и перезапись URL).



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



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





Очереди



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




Рисунок 1.20: Синхронный запрос


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



Такой вид синхронного поведения может значительно ухудшить производительность клиента; фактически простаивая, клиент вынужден ожидать, пока не получит ответ на запрос. Добавление дополнительных серверов с целью справиться с нагрузкой системы, по сути, не решает проблемы; даже с эффективным выравниванием нагрузки на месте, чрезвычайно трудно обеспечить равномерное и справедливое распределение нагрузки необходимое для максимизации производительности клиента. Более того, если сервер для обработки этого запроса недоступен (или он вышел из строя), то клиент, подключенный к нему, также перестанет работать. Эффективное решение этой проблемы требует абстракции между запросом клиента и фактической работой, выполняемой для его обслуживания.




Рисунок 1.21: Использование очередей для управления запросами


Очереди входа. Механизм работы очереди очень прост: задача приходит, попадает в очередь, и затем «рабочие» принимают следующую задачу, как только у них появляется возможность обработать ее. (См. рисунок 1.21.) Эти задачи могут представлять собой простые записи в базу данных или что-то столь же сложное как генерация изображения предварительного просмотра для документа. Когда клиент отправляет запросы постановки задач в очередь, ему больше не требуется ожидать результатов выполнения; вместо этого запросы нуждаются только в подтверждении факта их получения должным образом. Это подтверждение может позже служить ссылкой на результаты работы, когда клиент затребует их.



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



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



Очереди — основной принцип в управлении распределенной передачей между различными частями любой крупномасштабной распределенной системы, и есть много способов реализовать их. Есть довольно много реализаций очередей с открытым исходным кодом как RabbitMQ,
ActiveMQ,
BeanstalkD, но некоторые также используют службы как
Zookeeper, или даже хранилища данных как Redis.





1.4. Заключение



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








Эта работа распространяется под неизменённой лицензией Creative Commons Attribution 3.0. См. подробности в полном описании лицензии.

Наверх
Назад к The Architecture of Open Source Applications.







Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+71
Комментарии 5
Комментарии Комментарии 5

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн