Прим. перев.: системный архитектор Avery Pennarun, создавший VPN-решение Tailscale на базе WireGuard, размышляет об отличиях монолитов с модулями от микросервисов. Он рассказывает об эволюции подхода к модульности вообще и о том, почему изоляция до сих пор далека от совершенства, а также делится своим мнением о том, когда проводить границы между сервисами рационально.
В последнее время меня часто спрашивают, в каких случаях переход на микросервисы — хорошая затея. В статье «Systems design explains the world» я размышляю о таких типичных проблемах, как эффект второй системы, дилемма инноваторов и других. Может ли проектирование систем дать ответ на вопрос о микросервисах?
Да, хотя ответы могут вам не понравиться. Но сначала немного истории.
Что такое микросервис?
В Интернете можно найти различные определения. Вот моё: микросервисы — это самая экстремальная возможная ответная реакция на монолиты.
Монолиты получаются, когда все нужды вашего приложения объединяются в одну гигантскую программу, и она разворачивается как один цельный набор. У монолитов богатая история, восходящая к таким фреймворкам, как CGI, Django, Rails и PHP.
Давайте сразу же откажемся от допущения, что существует только две возможности — монолит и набор микросервисов. В промежутке между этими двумя крайностями лежит целый континуум, начиная от «одного гигантского сервиса, который делает всё», и заканчивая мириадами «бесконечно крошечных сервисов, которые почти ничего не делают».
Если вы склонны следовать за глобальной тенденцией, то наверняка собирали монолит хотя бы однажды (намеренно, или потому что традиционные фреймворки подталкивали вас к этому). После чего «набивали шишки» с монолитами и начинали посматривать в сторону микросервисов, потому что они якобы «являются ответом».
Но не гонитесь за тенденциями. Между этими крайностями существует масса промежуточных решений. Одно из них, вероятно, подходит именно для вашей ситуации. Оптимальный подход начинается с определения того, какими должны быть интерфейсы.
Блоки и стрелки
Интерфейс соединяет модули. Модуль — это набор связанного кода. В системном проектировании говорится о «блоках и стрелках»: модули — это блоки, а интерфейсы — стрелки.
Возникает более глубокий вопрос: насколько велики блоки? Что вмещает каждый блок? Как решить, когда один большой блок разделить на пару маленьких? Как соединять блоки оптимальным образом? Существует множество подходов, позволяющих ответить на эти вопросы. И никто точно не знает, какой из них — наилучший. Это одна из самых сложных проблем в архитектуре программного обеспечения.
На протяжении десятилетий мы прошли через множество типов «блоков». Goto-выражения были признаны «вредными» в основном потому, что они вообще не допускали никакой иерархии. Затем были добавлены функции или процедуры — примитивные блоки с соответствующими интерфейсами (параметрами и кодами возврата) между ними.
В зависимости от выбранного подхода к программированию, вы далее обнаружите рекурсивные функции, комбинаторы, прототипы статических функций, библиотеки (статические или динамические), объекты (ООП), сопрограммы, защищённую виртуальную память, процессы, потоки, динамическую компиляцию (JIT), пространства имен, песочницы, chroot'ы, jail'ы, контейнеры, виртуальные машины, супервизоры, гипервизоры, микроядра и «униядра» (unikernels).
И это только блоки! Теперь, когда у нас есть блоки, изолированные друг от друга, их необходимо соединить стрелками. Для этого есть ABIS, API, syscalls, сокеты, RPC, файловые системы, базы данных, системы передачи сообщений и «виртуализированное аппаратное обеспечение».
Если попытаться нарисовать полную диаграмму из блоков и стрелок современной Unix-системы (что я делать не буду), она окажется невероятно запутанной: функции внутри потоков внутри процессов внутри контейнеров внутри userspace, сгруппированные слоями под ядром, внутри VM, работающих на аппаратном обеспечении в стойке в дата-центре облачного провайдера, связанных воедино системой оркестровки, и т.д.
Каждый из этих блоков на каждом уровне абстракции каким-то образом изолирован, а затем связан с некоторыми другими, на том же уровне или на иных. Некоторые находятся внутри других. Попытка нарисовать диаграмму всего лишь в двух измерениях обречена на провал: получится невероятное нагромождение всевозможных линий.
Всё это развивалось десятилетиями. Чудаки называют это «зависимостью от пути», я — бардаком. И давайте будем откровенны: большая часть этого бардака больше не представляет ценности.
Вместо того, чтобы концентрироваться на уродливых результатах эволюции, давайте поговорим о том, чего пытались добиться люди, изобретая все эти штуки.
Стремление к модульности
Основные цели модульных систем всегда одни и те же:
-
Изолировать каждый фрагмент кода от других фрагментов.
-
Соединять эти фрагменты только там, где это явно предусмотрено (через чётко структурированный интерфейс).
-
Гарантировать, что измененные фрагменты сохраняют совместимость с нужными остальными.
-
Обеспечивать upgrade/downgrade фрагментов и масштабировать их без необходимости одновременно вносить изменения во все остальные фрагменты.
Компьютерная отрасль тратит невероятное количество времени, пытаясь найти идеальный баланс между всеми этими требованиями, и в то же время сделать разработку как можно менее болезненной, максимально её упростить.
Но у нас ничего не получается.
Пока главной проблемой выступает первый пункт — изоляция. Если бы мы только смогли по-настоящему и эффективно изолировать фрагменты кода друг от друга, все остальные проблемы решились бы сами собой. Но мы просто не знаем, как этого добиться.
Изоляция — это очень сложная проблема. Человечество неоднократно пыталось её решить. Тем не менее, выходы за пределы sandbox’а в браузере по-прежнему случаются с завидной регулярностью; существование атак, связанных с повышением привилегий, по умолчанию предполагается для каждой ОС; iOS по-прежнему периодически подвергается джейлбрейку; DRM не работает (не знаю, плохо это или хорошо); постоянно обнаруживаются уязвимости в виртуальных машинах и контейнерах, а в системах вроде Kubernetes контейнеры по умолчанию настроены небезопасно.
Известно даже, что удавалось вычислять ключи шифрования на удаленных серверах, посылая им через Интернет пакеты через определённые промежутки времени. Между тем, самыми впечатляющими сбоями изоляции за последнее время были атаки Meltdown и Spectre, которые позволяли любой программе на компьютере, даже JavaScript'у в веб-браузере, считывать память других программ на том же компьютере (даже в песочницах и виртуальных машинах).
Каждая новая технология изоляции проходит цикл от оптимизма к отчаянию, подобный следующему:
-
Новая идея: на этот раз наконец всё будет круто, раз и навсегда!
-
Предварительные эксперименты показывают, что новый подход, похоже, работает.
-
(Пользователи жалуются на его более медленную и проблемную работу по сравнению с предыдущим.)
-
Выявляются и исправляются первые фатальные недостатки.
-
Широкое распространение.
-
Успешно выявляются и исправляются всё более коварные недостатки.
-
Наконец, обнаруживаются недостатки, как исправить которые — неизвестно.
-
Сообщество теряет веру в то, что эффективная изоляция возможна с помощью этого метода.
-
В то же время полностью отказаться от него невозможно, поскольку он уже широко применяется.
-
Цикл повторяется.
Например, на данный момент любой специалист по безопасности скажет вам, что ни одна из этих технологий (когда-то каждая из них считалась самой перспективной) не является полностью безопасной:
-
Изоляция процессов и защита памяти в Unix.
-
Разделение привилегий между процессами ОС при разрешённом удалённом выполнении кода (RCE).
-
Фильтрация системных вызовов для изоляции процесса.
-
Совместная работа двух взаимно ненадёжных процессов на одном hyperthread'е CPU.
-
Изоляция памяти между виртуальными машинами на ядре процессора.
Насколько я знаю современное положение дел, самая лучшая изоляция — это что-то вроде Chrome sandbox или gVisor. Основные разработчики браузеров и облачные провайдеры используют подобные инструменты. Хотя их и нельзя назвать совершенными, провайдеры исправляют каждую новую проблему так быстро, как только могут, и новые уязвимости появляются довольно редко.
Сегодня изоляция лучше, чем была когда-либо прежде… если вынести её на уровень виртуальной машины (VM), чтобы о ней заботился облачный провайдер, поскольку никто другой с этим не справится (и не сможет выпускать обновления достаточно часто).
Тот, кто верит в VM-изоляцию облачного провайдера, может надеяться, что все известные проблемы устранены; но увы, есть все основания полагать, что наверняка возникнут новые проблемы.
И это, на самом деле, совсем неплохо, учитывая все обстоятельства. По крайней мере, есть хоть что-то, что работает.
Класс! Виртуальные машины для всего!
Хотя... постойте. Запуск изолированной VM для каждого отдельного модуля — это боль. И насколько велик этот модуль?
Давным-давно, когда только появилась Java, в воздухе витала идея о том, что каждую строку каждой функции в каждом объекте можно ограничить разрешениями, даже между объектами в одном и том же бинарнике приложения, так что защита памяти с помощью процессора не потребуется. Больше в это никто не верит. И если отбросить маркетинговые уловки вроде «облачных функций», никто на самом деле не думает, что этим имеет смысл заниматься.
Ни один из известных в настоящее время методов изоляции не совершенен, но каждый из них до некоторой степени приближен к идеалу. С ростом опыта нападающих и важности целей их атак растут и требования к изоляции. Лучшая изоляция на данный момент — это закрытое песочницей взаимодействие между виртуальными машинами (inter-VM sandboxing), предоставляемое облачными провайдерами с Tier-1. А худшая — когда её нет.
Давайте также предположим (оставив доказательства в стороне), что большинство систем настолько тесно связаны, что достаточно опытный злоумышленник может проскользнуть через границы модулей. Так, например, если кто-то может подключить вредоносную библиотеку к программе на Go или C++, то он, вероятно, сможет получить контроль над этой программой.
Аналогичным образом, если у программы есть доступ на запись в базу данных, злоумышленники, вероятно, смогут с её помощью вести запись в любое место базы данных. Если программа может подключаться к сети, то они смогут связаться с любым узлом сети. Если она способна выполнять произвольные Unix-команды или делать системные вызовы, они, скорее всего, смогут получить root-доступ к Unix. Если она в контейнере, они, вероятно, смогут выйти из него и попасть в другие контейнеры. Если вредоносные данные способны вызвать сбой декодера PNG, они, вероятно, смогут сделать и всё остальное, что позволено делать программе декодера. И так далее.
Особо опасной формой атаки является возможность делать коммиты в код, поскольку этот код в конечном итоге попадёт на компьютеры разработчиков, и у какой-нибудь dev- или prod-машины, вероятно, будет доступ к тому, что нужно злоумышленникам.
Вышеизложенный сценарий, возможно, слишком пессимистичен, но подобные предположения помогают избежать чрезмерного усложнения систем без повышения реальной безопасности. В Some thoughts on security after ten years of qmail 1.0, Daniel J. Bernstein указывает (перефразируя), что многие из защитных средств, которые он добавил в qmail (в частности, изоляция различных компонентов друг от друга с помощью chroot и разных Unix UID), так и не окупились.
В любом случае, давайте примем как должное, что злоумышленники, обладающие способностью выполнять код, «как правило», могут прыгать латерально между связанными модулями практически для любого метода их изоляции. Это означает, что существует только два типа границ модулей:
-
Надёжные: границы, когда каждый из модулей доверяет другому, и поэтому можно использовать слабую изоляцию.
-
Ненадёжные: границы, когда модули не доверяют друг другу, и поэтому необходимо использовать сильную изоляцию.
Не думаю, что мои слова стали для вас откровением. Современные популярные платформы уже построены вокруг этого различия.
Например, Chrome запускает случайный JavaScript в сильно изолированной VM-песочнице, поскольку веб-страницы ненадёжны.
Большинство ОС запускают native-приложения как обычные процессы (без песочницы), с общими файловыми системами, сетевыми пространствами имён и т. д., потому что когда-то они считались относительно надёжными (так и появились вирусы).
Эксперты больше не доверяют многопользовательским Unix-системам, поскольку изоляция процессов в них оказалась слабой. Облачные VM по умолчанию используют sudo
без пароля — изоляция для root/non-root оказалась слабой, поэтому нет смысла вообще с нею заморачиваться.
(Хотя sudo
по-прежнему используется для снижения влияния человеческих ошибок при удалении кучи файлов и т.п.)
Shared-библиотеки и DLL от разных вендоров включаются в приложения от других вендоров, поскольку весь код считается надёжным. (Это открывает путь для атак с эксплуатацией доверия к сторонней организации (атак на цепочки поставок, supply chain attacks) через поставщиков Open Source-библиотек. Я по-прежнему удивляюсь, что это не случается намного чаще. В циничные моменты склонен думать, что они всё же происходят, просто их редко обнаруживают.)
Джейлбрейки ОС смартфонов происходят потому, что магазинные ограничения якобы должны делать песочницы для приложений достаточно надёжными, но изоляция неизменно оказывается слишком слабой.
Kubernetes и Docker запускают несколько слабо изолированных контейнеров на одной машине или VM, потому что подразумевается, что все контейнеры надежны. Их разработчики настоятельно не рекомендуют пытаться запускать Kubernetes-кластеры категории multi-tenant (с ненадёжными приложениями, работающими от отдельных пользователей, не доверяющих друг другу), поскольку изоляция контейнеров слаба.
Кроме того, даже если вы используете сильную изоляцию вроде VM в gVisor для каждого сервиса, это не поможет, если сам код не построен с использованием сильно изолированного инструментария. Если некая группа людей может внести изменения в библиотеку, которая затем будет включена в ряд приложений, то эти приложения нельзя считать изолированными друг от друга, независимо от того, как именно они запускаются.
Границы модулей и границы сервисов
Если такое обилие изолирующих слоев ничего не даёт, зачем вообще с ними связываться?
В основном это диктуется сложившейся практикой; безопасность не пострадает сильно, а простота значительно возрастет, если мы отбросим большинство из этих слоев. Думаю, со временем это произойдёт. Тенденция уже заметна. Многопользовательские Unix-системы уже почти вымерли; серверы для «serverless» отказываются от всех типов изоляции, кроме самого сильного, и услужливо пытаются привязать вас к облачному провайдеру, пока вы там.
Но оставим историю в стороне. Мне пришлось вспомнить все эти концепции изоляции только для того, чтобы заявить нечто простое: границы модулей почти никогда не определяются по соображениям безопасности.
Вместо этого границы модулей обычно следуют закону Конвея. Границы модулей проводятся в зависимости от того, как распределяется нагрузка между разработчиками, и в итоге модули повторяют структуру коммуникаций в команде. (Закон Конвея удивителен и реален, и в Интернете есть масса мест, где о нём можно почитать, так что оставим подробности в стороне.)
Чего не делают границы модулей, так это не определяют размер единицы развертывания.
Посмотрите, к примеру, на операционные системы:
-
Над ChromeOS работают тысячи разработчиков, однако пользователи получают единое обновление, содержащее прошедшую всестороннюю проверку комбинацию ядра Linux, драйверов устройств, оконного менеджера, веб-браузера и т.п. Интерфейсы между этими модулями могут измениться в любой версии, поскольку обратная совместимость им не требуется (за исключением, конечно, совместимости с аппаратным обеспечением и сетями). macOS, iOS и Android следуют аналогичной модели.
-
Над Debian Linux трудятся тысячи разработчиков, но пользователи скачивают и устанавливают отдельные пакеты. Один пакет может принадлежать к древней версии Debian-oldstable, другой — к сегодняшней версии Debian-unstable, и скорее всего они будут работать. Скорее всего, никто не тестировал данную конкретную комбинацию, но она (вероятно) будет работать благодаря чётко определенным интерфейсам между пакетами.
(Народ шутит о ненадёжности «Linux на десктопе». Но речь всегда идёт о вторичном, нишевом, сложном для тестирования продукте, а не о первичном, мейнстримном и лёгком для тестирования. Не думаю, что воспринимаемая разница в качестве на самом деле объясняется отличиями корпоративных денег от Open Source. Скорее, разница заключается в модели развёртывания.)
Обе системы содержат массу пакетов (модулей), разработанных множеством разработчиков, объединённых в команды. В обеих определены интерфейсы между модулями. Если нарисовать диаграмму с блоками и стрелочками для каждой системы, они будут весьма похожи: ядра, драйверы, оконные менеджеры, песочницы, браузеры и т.д.
Но если бы вместо ОС это были облачные бэкенд-сервисы, мы бы отнесли их, соответственно, к монолитам и микросервисам из-за разницы в моделях развёртывания. В первой — только один разворачиваемый «сервис», а во второй — их множество, и каждый разворачивается отдельно. И это при одинаковой архитектуре модулей! Что происходит?
Дело в том, что границы модулей и границы сервисов — это две разные вещи.
Где проводить границы сервисов?
Давайте вспомним изначальные постулаты в отношении модульности:
-
Изоляция: если сильная изоляция действительно необходима, стоит обратиться к раздельным сервисам, поскольку изолированные VM можно развернуть только раздельно. (Однако обратите внимание: это скорее ограничение наших систем изоляции, а не архитектурная цель. «Инфраструктура как код» и синезелёные развёртывания пытаются синхронизировать эти сервисы, так что вполне возможна монолитная модель развёртывания.)
-
Соединение: следует закону Конвея. Границы модулей, как правило, повторяют коммуникационные паттерны в команде. Но, что может показаться странным, закон Конвея не должен определять границы сервисов.
-
Гарантии совместимости: стремятся сдвинуть вас в сторону монолитов. Это особенно верно, если монолит написан на типобезопасном языке, таком как Go, TypeScript, Rust или даже C++ (например, Chrome — это один гигантский бинарник).
-
Upgrade/downgrade и масштабируемость: именно они преимущественно определяют границы сервисов. Давайте поговорим о них немного подробнее.
Вот кое-что, о чём стоит подумать при выборе границ сервисов:
-
Монолит долго запускается? Обновления превратятся в мучение, поэтому, возможно, имеет смысл выделить медленную часть, чтобы обновление остальных проходило быстрее.
-
Нужна определенная версия схемы хранилища данных? Иногда это требует согласованных upgrade’ов/downgrade’ов всех инстансов бэкенда, чтобы у них была одинаковая версия схемы. Согласованные обновления рискованны и, как правило, делают откаты невозможными; так что в некоторых случаях имеет смысл делать зависящую от схемы часть минимально возможной.
-
CI-тесты часто терпят неудачу? Если так, то у меня для вас плохие новости. Неудачные тесты указывают на то, что код — плохой. Это фича! Разбиение сервисов и их раздельное разворачивание, вероятно, позволит обмануть тесты, но результатом станут будут проблемы с совместимостью и перекосом версий в production. Так что это не поможет.
-
Масштабирование одних частей отличается от других? Например, некоторые операции требовательны к памяти, а другие — к процессору. Но часто это не так важно, как представляется. Если балансировка нагрузки всех инстансов настроена правильно, то нагрузка распределяется довольно эффективно естественным образом. Если балансировка нагрузки становится проблемой, её можно измерить и исправить конкретную проблему гранулярности позже.
-
Должны ли дорогие запросы выполняться с меньшим параллелизмом? Классическая практика в архитектуре микросервисов состоит в объединении запросов в очередь сообщений, которую затем последовательно обрабатывают worker'ы. Но часто такой подход становится проблемой, и существуют лучшие решения, которые помогают избежать проблем со «взрывом очереди» (queue explosion). Их вполне можно реализовать и в монолите.
-
Есть ли сервисы с различными целями по качеству/надёжности? Это может быть хорошим поводом для разделения сервисов. Например, у нас в Tailscale лишь несколько сервисов с очень жёсткими целями по uptime: сервис координации (coordination) и сервис захвата логов (log catcher). Они уже разделены из соображений изоляции для безопасности, поскольку логи могут содержать весьма щекотливую информацию. Кроме того, наш пайплайн обработки логов/метрик в «real-time» менее чувствителен к простоям (и, соответственно, допускает больше экспериментирования), поэтому он отделён от высоконадёжных сервисов и предполагает иные процедуры разворачивания.
По правде говоря, большинство из вышеперечисленных причин едва ли можно назвать вескими для проведения границ между сервисами. Они могут прекрасно подходить для определения границ между модулями или командами. При этом модули вполне можно объединить в один или несколько монолитов.
Помните, ChromeOS — это монолит, iOS — тоже монолит. Ваша команда, вероятно, намного меньше, чем любая из их команд. То есть совершенно нет нужды жонглировать большим числом микросервисов, чтобы получить желаемое. Старайтесь, чтобы архитектура была максимально простой, пока не придётся сделать ее сложной (по серьезным причинам). Именно так мы и стараемся поступать.
Обновление на следующий день статьи. Ах да, ещё комбинаторы (по ссылке можно найти полезное дополнение к типам изоляции от читателя Hacker News и его последующее обсуждение — прим. перев.).
P.S. от переводчика
Читайте также в нашем блоге:
Автор: kalashmatik