Одноарендный Titus: изоляция на уровне виртуальных машин на многоарендной контейнерной платформе
- Titus запускает миллионы контейнеров в неделю на десятках тысяч экземпляров EC2 в нескольких регионах AWS.
- Общие кеш, пропускная способность памяти и обработка сетевых пакетов могут вызывать задержки, даже если контейнеры не превышают лимиты cgroup.
- Одноарендный режим выделяет задаче собственную виртуальную машину, сохраняя API Titus и управляемую среду платформы.
Почему это важно: Помехи соседей могут остановить потоковую обработку; восстановление после отставания способно занять много минут.
[не удалось получить полный текст]
Самартха Чандрашекар & Ник Паркер
Вы нажимаете «Воспроизвести» в пятницу вечером, и уже через секунду десятки сервисов отвечают за миллисекунды. Большинство из них работает на Titus — нашей внутренней платформе контейнеров. По сути, Titus — это платформа для размещения контейнеров на базе Kubernetes: мы поддерживаем в актуальном состоянии и плоскость управления, и плоскость данных (хосты контейнеров, на которых они фактически запускаются), чтобы команды приложений получали единообразную и надёжную среду для работы с контейнерами. Они передают нам контейнер, а мы запускаем его — без необходимости владеть хостом, кластером или ядром. Titus каждую неделю запускает миллионы контейнеров на десятках тысяч экземпляров EC2 в нескольких регионах AWS. По умолчанию платформа работает в многопользовательском режиме: на каждом хосте размещается множество контейнеров от разных команд. Именно такая плотность делает вычисления в контейнерах эффективными, поэтому уже много лет Titus остаётся стандартной платформой для значительной части вычислительных ресурсов Netflix.
Для большинства задач совместное использование хоста работает хорошо. Но для некоторых — нет, и среди них есть несколько самых требовательных систем реального времени, которые мы запускаем. Эта статья посвящена им и однопользовательскому режиму Titus — варианту, при котором задача получает собственную виртуальную машину, а всё остальное в Titus остаётся прежним: API, путь развёртывания и управляемая среда, где хостом владеет команда Compute/Platform, а не команда приложения.
Мы расскажем, почему изоляции на уровне общего ядра недостаточно для определённых задач; как мы измеряем изоляцию, а не просто заявляем о ней; какие варианты рассматривали (microVM, gVisor, EC2 и дальнейшую настройку общего ядра) и почему остановились на выделенной виртуальной машине внутри Titus; как этот режим построен на уровне федерации Titus и автомасштабирования;, а также что мы наблюдали по трём направлениям: изоляция, эффективность парка и эксплуатационные затраты на поддержание парка в актуальном состоянии.
Цена хорошего соседства
Многопользовательский режим — это компромисс. Размещение множества контейнеров на большом сервере без виртуализации позволяет поддерживать высокую утилизацию, но означает и то, что контейнеры используют одно ядро Linux и одно аппаратное обеспечение. Titus изолирует их с помощью предоставляемых ядром средств: cgroups ограничивают CPU, память и дисковый ввод-вывод; пространства имён дают каждому контейнеру собственное представление системы; seccomp и AppArmor ограничивают системные вызовы, которые может выполнять контейнер; Completely Fair Scheduler распределяет процессорное время; контроллер iocost регулирует дисковый ввод-вывод;, а управление трафиком выделяет каждому контейнеру его долю сети. Кроме того, у каждого контейнера есть собственная идентичность, роль IAM и группы безопасности, благодаря чему арендаторы изолированы с точки зрения безопасности и управления ресурсами.
Эти механизмы обеспечивают изоляцию того, что можно разделить средствами ядра. Но они не могут полностью разделить аппаратные ресурсы, которые остаются общими. Группа ядер использует общий кеш последнего уровня и общий путь к памяти, поэтому контейнер, который внезапно начинает активно читать данные из ОЗУ, вытесняет строки кеша соседа и замедляет его, даже если оба не превышают свои лимиты CPU. Для пропускной способности памяти или присутствия данных в кеше нет cgroup, аналогичной той, что регулирует долю CPU. Помехи реальны, они возникают на уровне аппаратного обеспечения и не проявляются как превышение квоты одним из контейнеров.

Шумный сосед и приложение, размещённое на том же хосте, не выходят за лимиты CPU и памяти, установленные для их cgroup, но используют общий кеш последнего уровня и пропускную способность памяти, которые не защищены ограничениями cgroup. Шумный сосед забирает большую часть ресурсов, поэтому совместно размещённое приложение ждёт, а его хвостовая задержка растёт, хотя никто не превысил квоту.
Мы уже писали об этом. Предиктивная изоляция контейнеров по CPU в Netflix перенесла часть решений о размещении из ядра в сервис, который прогнозирует потребность каждого контейнера в CPU и перепривязывает контейнеры к ядрам, чтобы уменьшить помехи от кеша. Задача сформулирована как комбинаторная оптимизация, решаемая каждую минуту. Обнаружение шумных соседей с помощью eBPF позволяет отслеживать планировщик ядра и получать данные о задержке в очереди выполнения для каждого контейнера, благодаря чему мы почти в реальном времени видим, когда какой-либо контейнер лишается ресурсов. А статья «Гора хаоса» показала, что медленный запуск контейнеров был связан с глобальной блокировкой ядра, влияние которой усиливалось из-за современной архитектуры кешей CPU. Каждая из этих мер повысила минимальный уровень производительности для всех пользователей платформы, и мы продолжаем эту работу.
Десятилетие изоляции на уровне общего ядра — в Netflix и во всей отрасли — не сделало совместное размещение незаметным, потому что проблема шумного соседа — не одна проблема, а целый их набор. Почти каждый физический ресурс, общий для хоста, представляет собой отдельный источник помех, а cgroup может оградить лишь немногие из них:
- Непредсказуемость производительности CPU. Совместно размещённые потоки конкурируют за очередь выполнения, поэтому растёт задержка планирования, даже если контейнер не превышает свою квоту CPU. Кроме того, сосед с большим рабочим набором вытесняет строки кеша последнего уровня арендатора и насыщает пропускную способность памяти. Разделить эти ресурсы с помощью долей CPU нельзя.
- Пропускная способность сети и число пакетов в секунду. Ограничения трафика для каждого контейнера заставляют их конкурировать за пропускную способность канала, а обработка пакетов выполняется в контексте softirq, из-за чего работа по обработке пакетов одного контейнера ложится на любое ядро CPU, используемое соседом. Поток мелких пакетов исчерпывает бюджет softirq ядра задолго до достижения любого ограничения пропускной способности.
- Пропускная способность хранилища и IOPS. Сосед, насыщающий очередь устройства, сериализует операции ввода-вывода для всех остальных. В облачном блочном хранилище пропускная способность тома и экземпляра также ограничена общим бюджетом.
- Длинный хвост. Возврат страниц из кеша, трафик между NUMA-узлами и сокетами, прерывания TLB shootdown, конкуренция за PCIe и GPU, ограничения по питанию и температуре на уровне процессорного корпуса, а также конкуренция за глобальные блокировки ядра (класс проблем, лежащий в основе «Горы хаоса») — всё это по-своему ухудшает работу соседей.
Общее у всех этих проблем то, что они связаны с физическими общими ресурсами и возникают ниже того уровня, на котором действуют квоты. Платформа может ограничить выделяемое контейнеру процессорное время или объём памяти, но не может выделить каждому арендатору отдельную часть шины памяти, собственный бюджет softirq или собственную долю кеша последнего уровня.
На практике сильнее всего нас подвела сеть. Ограничение пропускной способности для каждого контейнера использует дисциплину очереди hierarchical token bucket (HTB), которая сериализует трафик с помощью глобальной блокировки, учитывая байты и пакеты каждого контейнера. Если один контейнер отправляет множество мелких пакетов, конкуренция за эту блокировку может расходовать процессорное время ядра и лишать совместно размещённые контейнеры времени, необходимого для обработки собственного трафика. Это приводит к повторным передачам TCP и всплескам хвостовой задержки в сервисах, которые ни в чём не виноваты. Механизм, призванный предотвращать появление шумных соседей, сам становится одним из них.
Больше всего от этого страдают потоковые задачи с сохранением состояния и высокой пропускной способностью: конвейеры обработки данных реального времени и потоковые процессоры, которые непрерывно перемещают большие объёмы данных и хранят состояние в памяти. Если работа одной из таких систем нарушается, восстановление обходится недёшево. Потоковому процессору, который отстал или потерял позицию, может потребоваться восстановиться из контрольной точки и приостановить обработку на много минут, чтобы наверстать упущенное. Для таких задач периодические помехи от соседей — не небольшое замедление, которое усредняется со временем, а остановка всей обработки, из-за которой приходится вызывать дежурного инженера. Те же команды справедливо отмечают, что на обычном экземпляре EC2 такого не наблюдали.
Почему мы снова и снова возвращались к виртуальным машинам
Полная изоляция виртуальной машины, при которой задача получает собственные хост и ядро, — это стандарт, которым наши клиенты уже довольны. Вопрос был не в том, работает ли изоляция виртуальных машин, а в том, как предоставить её, не отказавшись от преимуществ Titus. Мы рассмотрели четыре варианта.
- Продолжить совершенствовать изоляцию на уровне общего ядра. Это помогает всем задачам на платформе, не требует отдельного включения, и мы по-прежнему вкладываемся в это направление (ниже расскажем о планах). Но ограничения здесь заложены в самой архитектуре. Каждая новая настройка сокращает разрыв, но не устраняет его, поскольку аппаратные ресурсы остаются общими. Кеш, пропускную способность памяти, а также блокировки ядра, используемые для управления трафиком и планирования, невозможно полностью оградить границами контейнера. Изоляция на уровне общего ядра — правильный вариант по умолчанию, но не то, что следует обещать задаче, не способной пережить даже минуту сбоев.
- microVM (Firecracker, Kata Containers). Лёгкие виртуальные машины загружаются примерно за 150 миллисекунд, эмулируя гораздо меньше оборудования, чем традиционные виртуальные машины. Поэтому на них работают бессерверные платформы. На бумаге это была самая привлекательная альтернатива, и мы тщательно её изучили. Однако три обстоятельства затрудняли оправдание затрат. Во-первых, необходимая им аппаратная виртуализация доступна только на экземплярах без виртуализации или через вложенную виртуализацию. На тот момент вложенная виртуализация не была готова для типов экземпляров, которые мы используем в Netflix, поэтому широкое внедрение microVM ограничило бы нас небольшой частью парка. Во-вторых, управляемого сервиса microVM, на котором можно было бы строить решение, не существует. Это означало бы, что нам пришлось бы самостоятельно запускать и поддерживать слой виртуализации: устанавливать обновления гипервизора, выполнять динамическую миграцию гостевых систем и проверять эмулируемые устройства. Именно такой операционной нагрузкой мы предпочитаем не заниматься, если можем этого избежать. В-третьих, даже если оставить это в стороне, microVM создают накладные расходы на виртуализацию при задачах, ограниченных CPU и вводом-выводом, и не поддерживают GPU. Изоляция превосходная, но мы не были готовы платить за неё снижением гибкости и дополнительной операционной нагрузкой.
- gVisor. Ядро в пользовательском пространстве, перехватывающее системные вызовы, обеспечивает изоляцию уровня песочницы без гипервизора и тем самым позволяет обойти ограничение на использование экземпляров без виртуализации. Но есть нюансы, связанные с производительностью и зависимостью от проекта. Перехват системных вызовов в пользовательском пространстве добавляет накладные расходы на путях с интенсивным вводом-выводом — именно таких задачах, которые нам важнее всего защитить. Кроме того, проект с открытым исходным кодом отстаёт от внутреннего аналога по исправлениям и фактически поддерживается одним поставщиком. Изоляция, которую мы получили бы, обошлась бы снижением пропускной способности именно тех задач, которым мы хотели помочь.
- Перенести задачу в EC2. Контейнеры можно запускать непосредственно на экземплярах EC2. Проблема в том, что команде приложения пришлось бы снова взять на себя эксплуатационную нагрузку, от которой избавляет Titus: выбирать типы экземпляров, обновлять хосты, определять размеры парка и масштабировать его. Для команды, пришедшей в Titus именно затем, чтобы не заниматься всем этим, такой переход ухудшил бы условия работы. К тому же переход между Titus и EC2 исторически был дорогой в один конец, на которую команды неохотно решаются.

Спектр вариантов изоляции — от более лёгких и общих к более надёжным: контейнеры с общим ядром, затем gVisor, microVM (Firecracker или Kata), а затем полноценная виртуальная машина, используемая в однопользовательском режиме Titus и выделенная на схеме. Для каждого варианта указаны задержка запуска, накладные расходы и оператор; однопользовательский Titus — это выделенный экземпляр EC2 без дополнительных накладных расходов на виртуализацию.
Важно и то, какой именно тип изоляции нам нужен. microVM и gVisor предназначены для изоляции в целях безопасности арендаторов, которые не доверяют друг другу, — например, когда один арендатор может намеренно попытаться атаковать другого или выйти за пределы своей среды. Для команд Netflix такая модель угроз строже, чем требуется. Этим задачам нужна изоляция ресурсов и производительности: гарантия того, что сосед не сможет отнять у них CPU, кеш, пропускную способность памяти или сеть, а не защита от враждебного арендатора. Выделенная виртуальная машина EC2 обеспечивает именно такую изоляцию, при этом нам не приходится эмулировать оборудование или помещать ядро в песочницу. Мы платим за необходимую нам изоляцию, а не за более строгую границу безопасности.
microVM и gVisor потребовали бы крупных специализированных инвестиций и сопряжённых с ними рисков, чтобы приблизиться к тому, что можно получить напрямую: виртуальной машине. Запуск контейнеров в EC2 даёт виртуальную машину, но лишает управляемой среды. Поэтому мы выбрали прямой путь и добавили изоляцию на уровне однопользовательских виртуальных машин непосредственно в Titus, сохранив существующие API и модель разделения ответственности.
Как работает однопользовательский режим Titus
Идея проста: задача в однопользовательском режиме запускается на собственном экземпляре EC2, подобранном по размеру, и рядом с ней не планируются другие задачи. Этот экземпляр по-прежнему является хостом контейнеров Titus, которым владеет и который обновляет команда Compute/Platform. Команда, запускающая задачу, пользуется тем же API Titus и тем же процессом развёртывания. Способ упаковки и доставки задачи не меняется.

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

Когда задача подходит для однопользовательского режима, происходят три вещи.
- Федерация определяет допустимость и параметры. Допуск регулируется списком разрешённых задач, который поддерживает команда Compute/Platform. Команда включает этот режим, выбирая группу задач для однопользовательского размещения, а не переключая параметр в общедоступном API. Если задача подходит, федерация помечает её как однопользовательскую и записывает список семейств экземпляров, на которых её можно запускать, чтобы обеспечить стабильную производительность. Централизация этого решения на уровне федерации соответствует общему принципу маршрутизации: логика размещения должна находиться в одном месте, над отдельными ячейками, чтобы учитывать доступность ресурсов во всём регионе.
- Автомасштабирование создаёт выделенный узел. Когда задача для однопользовательского режима ожидает запуска, наша система автомасштабирования создаёт именно тогда наименьший экземпляр из разрешённых семейств, который удовлетворяет её требованиям к CPU, памяти и GPU. Выбор наиболее точного соответствия позволяет свести к минимуму неиспользуемый остаток ресурсов каждого выделенного узла. Подробнее об этом — в следующем разделе.
- Планировщик обеспечивает размещение одной задачи на узел. Для подов в однопользовательском режиме задаётся антиаффинность, предотвращающая совместное размещение с другими подами, а также класс приоритета, освобождающий задачу от вытеснения. На однопользовательские узлы также устанавливаются taint-метки, чтобы не допускать туда посторонние задачи; эти метки учитывают только задачи в однопользовательском режиме. Кроме того, мы отключаем дефрагментацию и переупаковку таких узлов, поэтому однопользовательскую задачу никогда не перемещают ради более плотной упаковки парка. В результате задача на протяжении всего срока работы занимает хост одна — ей не мешают ни соседи, ни собственная система упаковки задач платформы.
Стоит подробнее остановиться на двух решениях. Во-первых, однопользовательский режим намеренно доступен только по списку разрешённых задач. Если бы это была свободно выбираемая опция API, большинство команд включили бы её — кому не хочется работать на более спокойном хосте? Но тогда эффективность, ради которой используется многопользовательский режим, снизилась бы во всём парке. Контроль со стороны команды Compute/Platform позволяет направлять выделенную изоляцию задачам, которым она необходима, а остальные оставлять на общих ресурсах, сохраняя небольшую и понятную поверхность общедоступного API, пока мы изучаем возможности режима. Во-вторых, мы не меняем незаметно размер задачи, чтобы заполнить узел. Задача сохраняет запрошенные ресурсы; мы рекомендуем подбирать её размер близким к конфигурации узла, чтобы эффективно использовать выделенный экземпляр. При этом задача остаётся переносимой и работает одинаково как в однопользовательском режиме, так и вне его, независимо от размеров хоста. Переход в этот режим и обратно требует изменения конфигурации, а не переписывания задачи.
Несколько виновников — много пострадавших
Проблема шумных соседей затрагивает не всех одинаково. На практике основную часть помех создаёт небольшое число задач: работа, которая наводняет хост мелкими пакетами, активно вытесняет страницы из кеша или насыщает пропускную способность памяти, ухудшает производительность всех остальных контейнеров, которым не повезло оказаться на том же хосте. Некоторые из этих задач сами особенно страдают от конкуренции за ресурсы: задача может одновременно создавать помехи и быть чувствительной к ним. Иными словами, есть несколько виновников и множество пострадавших.
Именно поэтому однопользовательский режим — точный инструмент, а не грубое решение. Нам не нужно выделять отдельную машину каждой задаче. Достаточно перенести на выделенные узлы несколько основных источников помех и наиболее чувствительных к ним задач. Это напрямую защищает их прежних соседей и устраняет причину, по которой платформа изначально разносила чувствительные задачи с помощью антиаффинного размещения. На оставшихся хостах становится спокойнее, а поскольку их больше не нужно оставлять частично пустыми ради соблюдения правил разнесения, задачи можно размещать плотнее.

Сравнение парка Titus до и после изменений. До: несколько красных задач-виновников распределены по общим хостам, соседние с ними рабочие нагрузки (янтарные) испытывают помехи, а антиаффинное размещение оставляет незаполненные участки ресурсов, обозначенные пунктиром. После: каждая задача-виновник работает на собственном выделенном узле в однопользовательском режиме, оставшиеся общие хосты полностью загружены задачами без проблем, а объём ресурсов, равный одному хосту, высвобождается.
Слева показан парк до изменений: задачи-виновники размещены на общих хостах, соседние задачи страдают, а правила антиаффинности оставляют пробелы, которые нельзя заполнить. Справа — тот же парк после изменений: у каждой задачи-виновника есть узел подходящего размера, где она может создавать сколько угодно помех, никому не мешая; на оставшихся хостах работают только задачи, которым ничто не мешает;, а ресурсы, которые раньше простаивали, снова становятся доступными. Несколько выделенных узлов обеспечивают изоляцию для виновников и делают парк спокойнее и плотнее для всех остальных.
Подготовка множества небольших узлов
Однопользовательский режим заменяет меньшее число крупных общих экземпляров большим числом небольших выделенных, и это изменение сказывается на всех уровнях стека.
Первое последствие — нагрузка на плоскость управления. Чем больше узлов, тем больше данных нужно отслеживать плоскости управления Titus и тем больше виртуальных машин запускается через плоскость управления EC2. Последнее особенно важно при переключении на резервный регион, когда мы масштабируемся быстрее всего и меньше всего можем позволить себе ограничение скорости. Titus уже является федеративной системой: каждый регион разделён на независимые ячейки, что ограничивает размер каждой плоскости управления и позволяет наращивать мощности, добавляя ячейки, вместо бесконечного роста одного стека. Мы переносим задачи поэтапно, а не все сразу, и регулярно проверяем скорость запуска под контролируемой нагрузкой, чтобы не обнаруживать ограничения во время инцидента.
Второе последствие — управление ресурсами; именно здесь проектирование изменилось сильнее всего. Когда на узел приходится одна задача, парк включает множество типов экземпляров, а спрос на них постоянно меняется по мере запуска и завершения задач. Заранее создавать статические пулы для каждого типа было бы сложно в управлении и неэкономно, поэтому мы создаём однопользовательские узлы по запросу. Эта система обеспечивает весь процесс подготовки ресурсов и предлагает несколько возможностей, которые как раз нужны для однопользовательского режима:
- Своевременное создание узлов подходящего размера. Для однопользовательских задач задаётся список поддерживаемых семейств экземпляров в зависимости от требований к производительности. При запуске задачу сопоставляют с узлом наиболее подходящего размера, сравнивая запрошенные ресурсы с выбранными семействами экземпляров. Автомасштабировщик создаёт новые узлы нужного типа, чтобы удовлетворить ожидающий спрос. Для подов в однопользовательском режиме задаётся антиаффинность относительно всех остальных подов, поэтому планировщик естественным образом размещает по одному поду на каждом узле, созданном автомасштабировщиком. Освободившиеся узлы можно использовать повторно для ожидающих развёртываний, прежде чем они будут автоматически удалены. Пулы узлов общие для задач в однопользовательском режиме и представляют собой каталог доступных семейств и размеров экземпляров.
- Пулы узлов для однопользовательского режима. Однопользовательский режим можно запускать в отдельных кластерах или использовать совместно с многопользовательскими кластерами. Для пулов задаётся список допустимых сочетаний типов экземпляров, а сами они привязываются к AMI хоста контейнеров Titus. Именно здесь обеспечивается управляемая среда: образ хоста и его конфигурация принадлежат нам, объявляются один раз и применяются к каждому создаваемому узлу.
- Управление консолидацией. В многопользовательских средах по умолчанию используется переупаковка и дефрагментация, при которых задачи периодически перемещаются. В однопользовательском режиме это не применяется, поскольку на каждом узле явно запускается не более одной задачи. Поэтому мы освобождаем только узлы, оставшиеся пустыми после масштабирования или завершения задач. Удаление узлов выполняется в два этапа, чтобы их можно было повторно использовать для ожидающих задач до окончательного завершения работы.
- Обновление на основе выявленных изменений. Когда мы выпускаем новую AMI хоста контейнеров, можно выполнить «изменяемое» развёртывание на существующие узлы, не затрагивая работающие на них контейнеры, или «неизменяемое» развёртывание, при котором новая целевая AMI применяется к вновь создаваемым узлам. Это позволяет обновлять многие узлы без перерыва в работе задач, но при необходимости мы оставляем за собой право обновить весь парк вместе с пользователями.
Цена задержки запуска и почему здесь сложнее использовать тёплые пулы
Создание узла по запросу не происходит мгновенно, поэтому задержка запуска — это компромисс, которым в однопользовательском режиме нужно управлять особенно тщательно. Чтобы однопользовательская задача начала обрабатывать трафик, необходимо выполнить два этапа: подготовить узел (запустить новый экземпляр EC2, а затем загрузить и распаковать образ контейнера — для большинства задач это самая долгая часть подготовки хоста) и запустить само приложение (например, загрузить и JIT-компилировать классы JVM, заполнить кеш и так далее). После запуска нового экземпляра проходят десятки секунд, прежде чем начнётся загрузка образа.
В многопользовательском режиме Titus большая часть этой задержки незаметна. Новая задача обычно запускается на уже работающем хосте контейнеров Titus, где уже закешированы слои её образа, поэтому запуск занимает секунды, а не минуты. Хост был запущен один раз давным-давно, и его стоимость распределилась между всеми задачами, которые с тех пор на нём выполнялись. Однопользовательский режим лишает нас этого преимущества. Каждая такая задача ждёт, пока загрузится её собственная машина и будет загружен её образ.
Обычно проблему решают с помощью тёплого пула: некоторые экземпляры заранее запускают и оставляют без дела, чтобы ожидающая задача могла сразу занять один из них. Это хорошо работает для многопользовательского парка именно потому, что он достаточно однороден. Почти все задачи помещаются на небольшое число крупных типов узлов, поскольку любую задачу можно разместить на большом общем хосте. Небольшого запаса тёплых ресурсов нескольких типов хватает для высокой вероятности совпадения, а затраты распределяются между множеством задач, которые ими воспользуются.
В однопользовательском режиме экономика меняется на противоположную — и этот момент стоит особо подчеркнуть.
Нажмите Enter или щёлкните, чтобы открыть изображение в полном размере

Поскольку для каждой задачи подбирается узел подходящего размера, в парке используются разные типы экземпляров (несколько семейств, каждое во множестве размеров), а тёплый экземпляр помогает только той ожидающей задаче, тип узла которой совпадает с ним. Тёплый r7i.2xlarge бесполезен для задачи, которой нужен m7a.8xlarge. Чтобы обеспечить высокую вероятность совпадения, нам пришлось бы держать незанятые ресурсы для каждой комбинации семейства и размера, а незанятые ресурсы в пуле, где нет задач, — это чистые затраты. Свойство, благодаря которому однопользовательский режим эффективен при стабильной нагрузке, — точный подбор узла по размеру — мешает повторно использовать тёплый пул: чем больше различных типов узлов, тем больше пулов и тем меньше вероятность совпадения на каждый доллар выделенной мощности. Нагрузка особенно высока именно тогда, когда это наименее желательно. Во время переключения на резервный регион или крупного события, когда множеству задач одновременно нужны новые узлы, небольшой тёплый пул, распределённый по множеству типов, быстро исчерпывается, и для остальных задач приходится оплачивать полную стоимость холодного запуска.
Поэтому мы используем тёплые ресурсы не по умолчанию, а как небольшой целевой инструмент. Для самых чувствительных к задержкам однопользовательских запусков мы держим небольшой запас заранее подготовленных узлов, а набор типов намеренно ограничиваем тщательно отобранными размерами, а не используем весь возможный диапазон. Так тёплая мощность сосредоточивается в меньшем числе пулов, что немного снижает эффективность точного подбора, но повышает вероятность совпадения. Все остальные узлы создаются по запросу, и приходится ждать их запуска.
Более долговременный эффект дают не предварительная оплата запуска, а сокращение его стоимости. Это помогает и однопользовательским, и многопользовательским узлам, поэтому именно сюда лучше направить усилия:
- Более быстрая доставка образов. Поскольку загрузка образа составляет основную часть подготовки хоста, новые форматы образов OCI и средства создания снимков с отложенной загрузкой, например stargz и SOCI, которые загружают только те данные, которые контейнер фактически читает при запуске, а не весь образ целиком, сокращают самую значительную часть задержки.
- Создание контрольной точки и восстановление. Для процессов с медленной инициализацией можно сохранить снимок уже прогретого приложения с помощью такого инструмента, как CRIU, и восстановить его вместо холодного запуска. Это устраняет большую часть задержки второй половины запуска.
- Выбор типа экземпляра. В многопользовательских конфигурациях мы исторически использовали экземпляры metal: они обеспечивают немного лучшую производительность, но медленнее запускаются. В однопользовательском режиме задержка запуска узла важнее, поэтому мы отказались от этих экземпляров.
- Оптимизация планирования. Регулярно проверяя масштабирование парка под нагрузкой, мы обнаружили узкое место в планировании, вызванное логикой вытеснения Kubernetes: узлы быстро создавались, но затем простаивали, пока на них ожидали размещения задачи. В итоге выяснилось, что эта логика не нужна, поскольку в наших однопользовательских пулах не запускаются задачи, допускающие вытеснение. Отключение вытеснения значительно ускорило планирование при высокой нагрузке.
Ничто из этого не отменяет компромисс, и об этом стоит сказать прямо: запуск однопользовательского узла занимает больше времени, чем размещение задачи на уже подготовленном общем хосте. Кроме того, здесь обход задержки с помощью тёплых пулов обходится дороже на каждый узел, чем в многопользовательском парке, поскольку тёплый пул не может быть таким же однородным. Но для длительно работающих сервисов, на которые рассчитан однопользовательский режим, команды готовы один раз заплатить эту цену при запуске, чтобы потом работать без помех.
Измерение разницы
Легко заявить об изоляции, но трудно поверить таким заявлениям, поэтому мы измеряем её с помощью воспроизводимого теста и единой оценки.
Метод основан на сравнении. Сначала мы запускаем эталонный контейнер — исправное и репрезентативное приложение — и записываем его пропускную способность и хвостовую задержку, пока оно работает в одиночку. Затем запускаем его повторно рядом с контейнером havoc, специально созданным для того, чтобы стать плохим соседом: поток UDP через XDP для проверки механизма управления сетевым трафиком, обход указателей и STREAM для загрязнения кеша последнего уровня и насыщения пропускной способности памяти, шквал вызовов fsync для проверки диска, stress-ng для создания нагрузки на планировщик и GPU-burn для монополизации ускорителя. Мы доверяем результатам только после того, как хост достигает устойчивого состояния. Это подтверждаем с помощью Pressure Stall Information ядра: ждём, пока cpu_some или mem_some покажет, что задачи действительно простаивают в ожидании ресурса, за который идёт конкуренция, а не просто то, что ресурс занят. Каждый сценарий многократно выполняется с фиксированными начальными значениями, а результатом становится медиана с доверительным интервалом, полученным бутстрэп-методом: распределение помех от шумных соседей имеет тяжёлые хвосты, а простое среднее может скрыть важные всплески.
По показателям эталонного контейнера мы рассчитываем простую оценку эффективности изоляции. Эффективность пропускной способности — это доля операций в секунду, которую приложение сохраняет под воздействием атаки; эффективность задержки — отношение невозмущённой задержки P99 к задержке P99 под нагрузкой. Если сосед удваивает P99 эталонного приложения, эффективность задержки снижается. Например, для эталонного приложения, задержка P99 которого увеличивается при работе рядом с контейнером havoc, можно рассчитать определённую эффективность задержки. Аналогично, если пропускная способность падает с a до b операций в секунду, можно рассчитать эффективность пропускной способности. Итоговая оценка представляет собой взвешенную комбинацию двух показателей; предусмотрена настройка для задач, чувствительных к задержкам, которая придаёт больший вес хвостовой задержке интерактивных сервисов и сервисов потоковой обработки. Идеально изолированная система получает 100%, а единственный серьёзный сбой делает тест недействительным независимо от среднего результата.
Изоляция от шумных соседей
Изоляция контейнеров с помощью cgroups не идеальна, и приложения могут работать хуже из-за совместного размещения на машине с настойчивым соседом. Это может касаться дискового и/или сетевого ввода-вывода, если задачи используют общие тома или интерфейсы. В распределённой задаче из-за этого производительность может заметно различаться в зависимости от размещения реплик. Для более чувствительных задач такая изменчивость может стать разницей между стабильной работой и вызовом дежурного инженера.
Однопользовательский режим позволяет лучше изолировать задачи, которые создают проблемы соседям и/или особенно чувствительны к таким проблемам. Его можно использовать как временную меру, пока не будет устранена конкретная регрессия и не восстановится стабильность, или оставить навсегда для моделей нагрузки, поддержку которых в общей среде нецелесообразно обеспечивать.
Эффективность
Второе последствие касается эффективности парка, и именно здесь обычно ожидают ухудшения. Выделение узла одной задаче означает, что свободная часть узла простаивает, поэтому на первый взгляд кажется, что утилизация должна снизиться. Однако в целом она может, напротив, вырасти — по причине, связанной с тем, как до появления однопользовательского режима мы справлялись с проблемой недостаточной изоляции.
Чтобы защищать чувствительные задачи на общих хостах, платформа использовала антиаффинное размещение, распределяя их по всему парку и стараясь не размещать задачи одного типа на одних и тех же узлах. Такое распределение мешает плотной упаковке: оно создаёт нагрузку на доступные ресурсы, поскольку хост, который приходится оставлять частично пустым для соблюдения правила антиаффинности, нельзя заполнить задачей, которая иначе там поместилась бы. Когда задачи, чувствительные к изоляции, переходят на выделенные узлы, необходимость в распределении и фрагментации исчезает, и оставшийся многопользовательский парк можно заполнить плотнее.
Ещё один источник фрагментации — сервисы, чувствительные к сбоям. Обычно это сервисы с сохранением состояния, восстановление которых обходится дорого. По мере того как таких сервисов в парке становится больше, поды со строгими требованиями к допустимым сбоям могут мешать освобождать узлы. В результате вне пиковых часов становится невозможно дефрагментировать и уплотнить пул. Проявления проблемы и последующее снижение эффективности парка из-за задач, чувствительных к сбоям, похожи на то, что наблюдается у задач с правилами антиаффинности.
В зависимости от масштаба проблемы взаимного исключения и/или бюджета на нарушения, перенос таких рабочих нагрузок в пулы с выделенными узлами может обеспечить такое же или даже более эффективное размещение, если учесть все издержки. Кроме того, так проще оценить затраты конечных пользователей. Вместо того чтобы пытаться отнести фрагментацию всего парка к отдельным рабочим нагрузкам, мы можем напрямую измерить потери как накладные расходы на узлы с одним арендатором.
Здесь теория расходится с практикой, и решающей оказалась практика. В принципе, у размещения задач на общих узлах методом упаковки максимальная эффективность выше: если объединить достаточно задач, узел будет заполнен почти целиком. Но нам не удается достичь этого предела. AWS предлагает ограниченный набор размеров инстансов, поэтому большой общий узел редко точно соответствует сумме запросов, которые мы на нем размещаем. Разница между емкостью узла и тем, что мы действительно можем на нем разместить, — это незаполненный «зазор» при упаковке, который невозможно устранить. На крупных инстансах такие зазоры значительны. По нашему опыту, подбор размера инстанса под одну рабочую нагрузку — выбор наиболее подходящего варианта из множества форм-факторов вместо размещения множества нагрузок на одном большом узле — позволил использовать доступные ресурсы эффективнее, чем обещал более высокий теоретический предел. Чистая теория была на стороне мультиарендности, но измерения на нашем парке показали преимущество подбора инстансов по размеру.
Обслуживание контейнерных хостов
Третий эффект связан с эксплуатацией и проявляется в том, как мы устанавливаем обновления на парк. Общий узел трудно выводить из эксплуатации для обслуживания именно потому, что он заполнен: чтобы освободить его, приходится одновременно перемещать множество арендаторов, некоторые из которых имеют бюджеты на нарушения, ограничивающие скорость перемещения. Поэтому обычное обновление превращается в тщательно спланированную и согласованную эвакуацию. На узле с одним арендатором выполняется ровно одна задача, а консолидация и дефрагментация отключены. Поэтому для его освобождения нужно переместить только одну рабочую нагрузку, и мы можем делать это автоматически при выходе нового AMI. Обслуживать оставшиеся общие узлы тоже становится проще, поскольку наиболее чувствительные к нарушениям рабочие нагрузки больше на них не остаются.
Что это меняет, а что — нет
Режим с выделенным узлом — это настройка, а не миграция. Команда может переместить рабочую нагрузку на выделенный узел, а затем вернуть ее обратно, изменив метку, — ничего не переписывая и не покидая Titus. Благодаря этому решение об изоляции становится обратимой настройкой, а не переходом в один конец между Titus и EC2, с которым раньше сталкивались команды. Команда может попробовать режим с выделенным узлом в течение квартала, оценить результаты и отказаться от него, если компромисс не оправдает себя.
При этом условия работы не меняются. Команда Compute/Platform по-прежнему выбирает тип инстанса, устанавливает обновления на хост и определяет размер и масштаб парка. Команда приложения по-прежнему использует API Titus, тот же конвейер доставки и те же средства наблюдения. Выделенный режим — это новая точка на шкале изоляции в рамках уже существующей управляемой среды, а не отдельный продукт, который команде придется изучать и обслуживать.
Когда выделенный узел — неверный выбор
Выделенный узел обходится недешево, а изоляция в таком режиме не бесплатна. Прежде чем выбирать его, стоит учесть два момента.
Во-первых, задача на узле с одним арендатором получает не всю машину, а ресурсы за вычетом накладных расходов. На каждом контейнерном хосте Titus — выделенном или общем — работают служебные контейнеры платформы, системные службы, kubelet и сама хостовая среда. Они потребляют относительно фиксированную долю ресурсов CPU и несколько гигабайт памяти. На общем узле эта доля оплачивается один раз и распределяется между множеством выполняемых там задач. На выделенном узле ее берет на себя одна задача. Поскольку эти затраты фиксированы, а не пропорциональны размеру инстанса, на небольших инстансах они особенно ощутимы, а на крупных — незначительны: небольшой выделенный узел может терять из-за накладных расходов десятки процентов ресурсов, тогда как на самых крупных инстансах речь идет всего о паре процентов.
Во-вторых, поскольку задача размещается на самом маленьком подходящем инстансе, между запрошенными ею ресурсами и параметрами следующего доступного типоразмера обычно остается разница. На выделенном узле это чистые потери — нет соседних задач, которые могли бы использовать оставшиеся ресурсы. Эти эффекты складываются: фиксированные накладные расходы и остаток после подбора подходящего типоразмера ограничивают долю ресурсов выделенного узла, которую рабочая нагрузка может использовать на практике. И этот предел особенно низок именно там, где изоляцию часто выбирают без особой необходимости, — на небольших инстансах. Однако эти потери из-за подбора размера при необходимости можно сократить, согласовав решение с владельцем сервиса.
Рекомендации следуют из расчетов. Выделенный режим оправдан для крупных, долго работающих, чувствительных к производительности и нарушениям рабочих нагрузок, размер которых близок к типоразмеру узла; их лучше запускать в виде меньшего числа крупных реплик, а не множества мелких. Это неверный выбор для небольших, импульсных или очень эластичных рабочих нагрузок: доля накладных расходов у них велика, выделенный узел большую часть времени простаивает, а размещение на общих узлах обходится дешевле и позволяет быстрее масштабироваться. Именно поэтому режим доступен только для рабочих нагрузок из списка разрешенных, а не в виде переключателя для самостоятельного включения: платформа может оставлять нагрузки по правильную сторону этой границы, а те, которым собственный узел даст мало преимуществ, — на общем парке, где их дешевле запускать.
Кто уже использует этот режим
Первыми его начали использовать рабочие нагрузки, ради которых и затевалась эта работа: инфраструктура обработки данных в реальном времени. Управляемая потоковая обработка данных в Apache Flink, коннекторы потоковых данных, а также конвейеры телеметрии и логирования чаще всего страдали от сетевой конкуренции и сильнее других испытывали ее последствия: восстановление их работы обходится дорого, а допустимый уровень нарушений у них невелик. Это работающие с состоянием высокопроизводительные задачи, где несколько секунд нехватки ресурсов могут обернуться минутами наверстывания отставания, а собственное перепаковывание задач платформой само по себе становилось источником сбоев.
Перенос на выделенные узлы устранил сам класс проблем, а не просто смягчил их последствия. Команды, которые запускают эти нагрузки, сообщают о стабильной работе: раньше они сталкивались с периодическими помехами, причины которых было трудно установить. Теперь их задачи больше не перемещаются при перепаковке парка, что устранило еще один источник сбоев, созданный самой платформой. По той же причине на выделенные узлы перешли сервисы, чувствительные к задержкам, а также несколько сервисов GraphQL и периферийных сервисов: для них предсказуемость хвостовых задержек важнее нескольких процентных пунктов эффективности упаковки.
Развертывание продолжается и еще не завершено. По мере накопления опыта мы расширяем список подходящих рабочих нагрузок, проверяем предположения о масштабировании платформы на крупнейших производственных задачах и следим за метриками плоскости управления и скоростью запуска инстансов EC2 по мере роста числа небольших узлов. Мы также периодически проводили стресс-тесты, чтобы проверить масштабирование до появления реальных пользовательских нагрузок, и по ходу дела улучшали логику автомасштабирования и планирования.
Что дальше
Мы продолжаем работать над обеими сторонами компромисса. Для режима с выделенными узлами мы сокращаем задержки запуска благодаря более точному предварительному прогреву распространенных типоразмеров, расширяем список разрешенных нагрузок по мере подтверждения результатов масштабирования и распространяем эту модель за пределы долго работающих сервисов по мере развития учета затрат и планирования емкости. Для мультиарендной среды, которая остается оптимальным вариантом по умолчанию для большинства нагрузок, мы устраняем наиболее серьезные недостатки. Главный из них — сеть: мы заменяем формирование трафика HTB с глобальной блокировкой подходом на основе eBPF — Expected Departure Time, который ограничивает скорость, проставляя временные метки пакетам вместо конкуренции за блокировку, и дополняем его сетевым устройством контейнера с меньшими накладными расходами. Мы также оцениваем возможности разделения аппаратного кэша и пропускной способности памяти (Intel RDT и AMD PQoS), чтобы дать ядру реальный механизм управления кэшем последнего уровня, аппаратное разделение GPU (NVIDIA MIG) для изоляции ускорителей, а также продолжаем работу над контроллером ввода-вывода cgroup v2 для дисков. Этот бенчмарк изоляции позволяет ранжировать эти решения по отдаче от затраченных усилий, а не по интуитивным оценкам, и наглядно показывать клиентам, как общий парк со временем становится менее шумным.
Выделенный режим Titus — также шаг к более масштабной цели. Команды платформы Netflix предлагают два способа получить вычислительные ресурсы: виртуальные машины в EC2 и контейнеры в Titus. Мы хотим заменить их единым API Compute, который учитывает намерения, а не конкретный механизм: насколько сильная изоляция нужна рабочей нагрузке, как быстро она должна запускаться, как долго может работать без сбоев и к какой бизнес-области относится. После этого платформа сама выбирает способ размещения и среду выполнения, причем может принимать оптимальные для всей системы решения, поскольку видит картину целиком.
Заключение
Мультиарендность — правильный вариант по умолчанию для контейнерной платформы, и мы продолжим улучшать его для всех. Но даже лучший вариант по умолчанию не подходит для каждой задачи. Выделенный режим Titus обеспечивает нужным в этом рабочим нагрузкам изоляцию на уровне выделенной виртуальной машины; такие машины подготавливаются и обновляются с помощью уже существующей системы управления узлами, работают через тот же API и в той же управляемой среде, а команда при этом сохраняет возможность передумать. Теперь разницу можно не описывать, а показать: хвостовые задержки перестают колебаться из-за шумных соседей, парк размещает задачи плотнее после отключения распределения, а обновлять хосты становится быстрее и безопаснее. Для потоковых и дата-систем, лежащих в основе платформы Netflix для обработки данных в реальном времени, важнейшей функцией в итоге оказалось собственное пространство.
Выделенный режим Titus: изоляция на уровне виртуальной машины на мультиарендной контейнерной платформе впервые опубликована в Netflix TechBlog на Medium, где читатели продолжают обсуждение, выделяя фрагменты и отвечая на эту статью.
