Daily MaverickAt least 28 killed in Ukraine, Zelenskiy condemns one of Russia’s most ‘vile strikes’ESPN DeportesRays, a Serie de Campeonato por tercera vez; Yankees, fueraESPNFollow live: Chourio's RBI single gives Brewers edge in Game 4UOLSite distribui criptomoedas em troca de divulgação de conteúdo que incentiva voto em Flávio BolsonaroTVN24Tajemnicza śmierć laborantki z Irkucka. WHO i USA domagają się wyjaśnieńWirtualna PolskaDziało się w nocy. Trump pytany o rząd w Polsce. Berkowicz obraża dziennikarkę한겨레이 대통령 “한-이집트 ‘전략적 동반자’로 격상… CEPA 협상 공식 개시”Channel News AsiaPM Wong congratulates new Lao PM Saleumxay KommasithZDF heuteAktuelle Pressemitteilungen des ZDFThe Sydney Morning HeraldAustralia news LIVE: Joe Hockey says corruption flourishing in Washington; Ministers’ mixed messages as ATO prepares credit card banSözcüSON DAKİKA: Maltepe'de 6 katlı bina çöktüDaily MailMichael Douglas' Basic Instinct co-star Jeanne Tripplehorn breaks cover with husband after Hollywood icon revealed their affair
The Daily Newsstand · Free, Always
Thursday, October 8, 2026

Палеокомпьютинг, часть 2: Kubernetes на Обероне, радио Вирта вместо сети, кластер в браузере и преимущества перед K8s

Translate

Представьте кластер Kubernetes, в котором нет ни одной сетевой карты. Его узлы ничего не знают ни о TCP/IP, ни даже об Ethernet и перекликаются по радио короткими пакетами по 32 байта, как прорабы по рациям на стройке. Управляющий слой написан на языке конца восьмидесятых и вместе с сетевым протоколом занимает около 1250 строк. Чтобы запустить под, ничего не нужно скачивать: узел просто загружает модуль и вызывает в нём процедуру. А главное, всё это открывается во вкладке браузера: можно выдернуть узел из розетки, заглушить эфир, подсунуть кластеру поддельную команду и посмотреть, что из этого выйдет.

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

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

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

Это вторая часть моей серии о палеокомпьютинге. В первой части я рассказывал, как запустил процессор Вирта в браузере, в QEMU, в Kubernetes и в Cozystack, где машину Оберона теперь можно поставить из каталога одной кнопкой. Читать её не обязательно: всё, что касается Оберона и машины Вирта, я объясню по ходу дела, а Kubernetes вы, думаю, и так знаете не хуже меня. Статья опять получилась длинной. Сначала я расскажу, как устроен и как работает Kube, потом о принципах, которые навязали ему язык и операционная система Оберон, о том, чем он отличается от настоящего Kubernetes, в чём оказался лучше и в чём заметно хуже. Дальше будут шесть лабораторных, которые можно пройти прямо в браузере, инструкции для тех, кто захочет поставить всё это у себя, а в конце мои выводы о том, чему такие опыты могут научить людей, которые строят инфраструктуру сегодня.

Кому хочется сначала потрогать, а потом читать, откройте лабораторию с кластером. Ставить ничего не придётся. Через полминуты три машины загрузятся, управляющая запустит Kube, узлы подключатся, и справа появится таблица кластера, которую страница составляет, просто слушая эфир. Исходники, документация и все замеры лежат в репозитории github.com/tym83/paleocomputing.

Как устроен Kube

Оберон и машина Вирта в двух словах

Оберон — это одновременно язык программирования и операционная система. Их сделали в середине восьмидесятых в ETH Zurich Никлаус Вирт, автор Pascal и Modula-2, и Юрг Гуткнехт. Язык совсем маленький: модули, записи, массивы, указатели, сборка мусора и строгая типизация, а исключений, обобщённых типов и даже беззнаковых целых в нём нет. Система под стать языку. В ней нет ни процессов, ни потоков, ни защиты памяти. В самом низу крутится единственный цикл, Oberon.Loop: он опрашивает мышь и клавиатуру, по очереди вызывает команды, а в паузах между событиями ввода дёргает фоновые задачи, Oberon.Task. Прервать задачу нельзя, поэтому она обязана быстро сделать свою часть работы и вернуть управление. Многозадачность здесь, иначе говоря, кооперативная и держится на вежливости участников.

В 2013 году Вирт выпустил новую редакцию книги Project Oberon и добавил к системе собственный процессор RISC5, описанный на языке Verilog. Залитый в ПЛИС, этот процессор работал на плате с мегабайтом памяти и частотой 25 МГц. Это и есть машина Вирта. У нас она живёт в трёх обличьях: в браузере работает настоящая схема Вирта, переведённая в C++ и скомпилированная в WebAssembly, в QEMU — наша собственная модель этой машины, а в Kubernetes та же модель QEMU запускается внутри KubeVirt.

С чего всё началось

Первая версия Kube была совсем игрушечной, и я честно рассказывал о ней в отдельном выпуске серии. Это был единственный модуль на Обероне, где жили хранилище объектов и три контроллера: деплойментов, ReplicaSet и узлов. Узлами служили три имени, node-a, node-b и node-c, а работающим под считался только потому, что контроллер вписал в него имя узла. Нигде ничего не выполнялось. Зато уже по этой версии было отлично видно, что сердце Kubernetes составляют не контейнеры, а циклы согласования и что на Оберон эти циклы ложатся превосходно.

Ложатся они потому, что в Обероне уже есть всё, что нужно управляющему слою, а именно центральный цикл, который вызывает фоновую работу. Kube.Start ставит три контроллера как три задачи Oberon.Task с периодом в 50 миллисекунд, в то же кольцо, где крутится системный сборщик мусора, и с этой минуты Oberon.Loop вызывает их всякий раз, когда человек не печатает и не двигает мышью. Друг друга контроллеры не вызывают и ничего друг другу не посылают. Каждый смотрит только на объекты своего вида и меняет только то, чем владеет, а результат его прохода становится исходными данными для соседа на следующем проходе. Точно так же контроллеры взаимодействуют и в настоящем Kubernetes: через общее состояние, а не через сообщения.

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

Радио Вирта

Рабочие станции Оберона в ETH всегда были связаны сетью. Вирт написал её ещё в конце восьмидесятых для проводной рабочей станции Ceres, а в редакции 2013 года Пол Рид, работавший с ним над новой версией проекта, перевёл её на радио. На плату ставится приёмопередатчик nRF24L01+, дешёвая микросхема, которую и сегодня можно найти в беспроводных клавиатурах и любительских поделках, а процессор общается с ней по простой последовательной шине SPI. Возможности у микросхемы очень скромные. За один раз она передаёт кадр не длиннее 32 байт, принятые кадры дожидаются своей очереди в буфере на три места, все станции на одном канале слышат друг друга, а защиты от столкновений нет вовсе: заговорят две станции одновременно, и приёмник получит либо кашу, либо ничего.

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

Почему радио, а не обычная сеть? Потому что другой сети у машины Вирта просто нет. В ней нет ни Ethernet, ни стека TCP/IP, и написать их значило бы построить на Обероне маленький Linux. Радио же описано в той самой книге, и мне было интересно, какая получится система, если честно принять ограничения машины, а не тащить на неё современную сеть.

У виртуальной машины настоящего радио, конечно, нет. Поэтому наша модель в QEMU изображает микросхему nRF24L01+ целиком, со всеми регистрами и очередями, и каждый отправленный кадр упаковывает в UDP-датаграмму. Датаграммы стекаются к ретранслятору, маленькой программе, которая рассылает каждый кадр всем остальным машинам. Это и есть эфир. Ретранслятор умеет терять заданную долю кадров, а если его выключить, эфир пропадёт целиком. В Cozystack, нашей открытой платформе на базе Kubernetes, ретранслятор стал приложением каталога OberonAir, а в браузере роль эфира играет сама страница.

Три сообщения, и каждое в один кадр

Протокол, который я назвал KubeNet, состоит всего из трёх сообщений. Каждое рассылается всем сразу, ведь выбрать адресата в радио всё равно нельзя, и каждое умещается в один кадр.

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

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

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

Главное свойство протокола в том, что он level-triggered, как контроллеры Kubernetes, только не внутри управляющего слоя, а прямо в сети. Узел выполняет ровно то, что написано в последнем назначении, а не череду команд «запусти» и «останови». Потерялось назначение, и через секунду придёт следующее с тем же содержимым, так что чинить ничего не нужно. Потерялся сигнал жизни, и управляющая машина дождётся следующего. Ни подтверждений, ни повторных передач, ни порядковых номеров ради надёжности здесь нет: вся надёжность берётся из того, что каждое сообщение несёт полное состояние, а не изменение. Даже на эфире, который теряет 30 процентов пакетов, за минуту не переехал ни один под, и это я проверял.

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

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

Под — это модуль

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

Узнав о новом поде, kubelet вызывает Modules.Load с именем образа. Если модуль ещё не загружен, система найдёт на диске его скомпилированный файл, загрузит его в память, свяжет со всеми модулями, которые он импортирует, сверит ключи их интерфейсов и выполнит тело модуля. Ключ — это что-то вроде контрольной суммы интерфейса, которую компилятор записывает в каждый модуль. Если интерфейс изменился, система откажется загружать модули, собранные против старой версии. Затем kubelet находит в модуле команду Start и вызывает её, а когда под должен исчезнуть, вызывает команду Stop того же модуля.

Командой в Обероне называется любая экспортированная процедура без параметров. Запускают её средним щелчком мыши по тексту Модуль.Процедура в любом окне, а нужные параметры она при необходимости сама читает из текста после своего имени. Поэтому передать поду его номер напрямую нельзя, и для этого служит крошечный модуль Pods с двумя переменными, номером и образом. kubelet заполняет их перед вызовом, а модуль нагрузки читает. Решение не самое изящное, зато вполне в духе Оберона: глобальная переменная модуля здесь законный способ передать контекст, потому что в каждый момент выполняется только одна команда и гонкам взяться неоткуда.

Учебная нагрузка существует в двух версиях, Ticker и Ticker2. Для каждого запущенного пода она заводит счётчик секунд, а команда Ticker.Show печатает, какие поды работают на этой машине и сколько секунд прожил каждый. На них очень удобно наблюдать выкатку: по журналу узла видно, как поды первой версии останавливаются, а второй запускаются.

Если модуля с таким именем на узле нет, вызвать Start не получится, и kubelet просто не упомянет этот под в сигнале жизни. Управляющая машина увидит, что под назначен, но не работает, и он так и останется в состоянии Pending. В Kubernetes так выглядит под, чей образ не удалось скачать.

Хранилище на диске

В Kubernetes всё состояние кластера хранится в etcd, и перезапущенный управляющий слой прочитает его оттуда. В Kube роль etcd исполняет массив из 256 записей в памяти управляющей машины, а чтобы он переживал перезапуск, фоновая задача записывает его на диск при каждом изменении.

Записывается он в два файла по очереди, Kube.Store0 и Kube.Store1, и в каждом хранятся номер поколения и контрольная сумма. Если питание пропадёт посреди записи, испорченным окажется только один файл, а второй, предыдущего поколения, уцелеет. При старте Kube.Start читает оба и берёт тот из целых, что новее. Файлы переписываются на месте, а не создаются заново, и этого тоже требует Оберон: его файловая система освобождает место из-под заменённых файлов только при следующей загрузке, и если заводить новый файл на каждое изменение, при оживлённой работе кластера диск попросту закончится.

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

Выкатки и две ошибки, хорошо знакомые Kubernetes

Команда Kube.Apply web 6 Ticker2, отданная работающему web 6 Ticker, меняет образ. Контроллер деплойментов создаёт новый ReplicaSet и начинает переносить поды по одному. Сначала добавляется под с новым образом. Как только его kubelet сообщит, что под работает, подов становится на один больше желаемого, и старый ReplicaSet убирает один из своих. Так повторяется, пока старый ReplicaSet не опустеет, после чего его удаляют.

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

Первая связана с остановкой пода. Когда управляющая машина удаляет под из хранилища, на узле он продолжает работать, пока kubelet не услышит следующее назначение, а это может занять до секунды. Контроллер же к этому времени уже видит, что подов стало на один меньше, и добавляет новый. В итоге на мгновение работало восемь подов там, где разрешено было семь. Настоящий Kubernetes ведёт себя точно так же, и лишь недавно у деплоймента появилось поле podReplacementPolicy, которое заставляет дожидаться полной остановки старых подов, да и то пока в альфе и за флагом. В Kube я сделал такое поведение единственным: поды, о которых узлы ещё сообщают, но которых уже нет в хранилище, считаются останавливающимися, и пока они есть, новый под не добавляется.

Вторая ошибка связана с номерами подов. Узел знает о поде только его однобайтовый номер. Поначалу новый под получал наименьший свободный номер, а им часто оказывался номер того самого старого пода, которого он только что сменил. kubelet видел в назначении знакомый номер и решал, что ничего не изменилось. Хранилище утверждало, что работает Ticker2, а на узле по-прежнему крутился Ticker. Kubernetes именно поэтому никогда не использует UID пода повторно. Теперь номера в Kube идут по кругу, а проверка после каждой выкатки требует, чтобы ни один номер старых подов больше не работал.

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

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

Отказы, ради которых строят кластеры

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

Если выключить узел, через пять секунд молчания управляющая машина пометит его как NotReady, а ещё через две перенесёт его поды на другой узел, так что всё вместе занимает около восьми секунд. Если выключить управляющую машину, узлы продолжат выполнять последнее назначение, потому что сказать им что-то другое некому. Когда она загрузится снова, хранилище вернётся с диска, и ни одно назначение не изменится. Если на узлах нет нужного модуля, поды останутся Pending. А если целую минуту терять 30 процентов пакетов, ни один под не переедет.

Поначалу таймаут составлял три секунды. На эфире с потерей в 30 процентов три сигнала жизни подряд пропадали по нескольку раз в минуту, и управляющая машина, приняв узел за мёртвый, переносила поды, которые на самом деле никуда не девались. С пятью секундами ложные срабатывания стали в десятки раз реже, а расплатиться пришлось более медленным восстановлением после настоящей аварии. Kubernetes заключает ту же сделку, только в другом масштабе: kubelet отчитывается раз в десять секунд, а управляющий слой ждёт сорок–пятьдесят.

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

Kubernetes знает эту ловушку: когда NotReady разом становятся все узлы зоны, контроллер узлов переводит её в состояние FullDisruption и перестаёт выселять поды, рассудив, что одновременная гибель всех узлов куда менее вероятна, чем обрыв связи. Kube поступает так же, если NotReady становятся все узлы или хотя бы 55 процентов из трёх и более. Но заработало это не сразу: понадобились две детали, которые обнаружились только на проваленной проверке.

Во-первых, узлы становятся NotReady не одновременно, их сигналы жизни разнесены во времени на величину до секунды. Поэтому первый замолчавший узел успевал лишиться подов раньше, чем замолкали остальные и становилось понятно, что это обрыв. Теперь поды узла переезжают, только когда он пробыл в NotReady две секунды, а за это время успевают замолчать и все прочие. Kubernetes по умолчанию ждёт для этого пять минут. Во-вторых, узлы и возвращаются по одному, и первый же вернувшийся выводил кластер из этого состояния, пока остальные ещё не успели отозваться, отчего их поды тут же переезжали. Теперь молчащие узлы после выхода из него получают ещё один таймаут на ответ, как это делает и Kubernetes, сбрасывая таймеры узлов, когда зона выходит из FullDisruption. С этими двумя поправками за двадцать секунд обрыва не меняется ни одно назначение.

Чужие в эфире

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

Но метка ничего не доказывает, послать её может кто угодно, поэтому следом появилась подпись. Каждое сообщение несёт код аутентификации, MAC, вычисленный из самого сообщения и секретного ключа кластера, и без ключа правильный код не подобрать. Вычисляется он функцией HalfSipHash-2-4, уменьшенным вариантом SipHash, который работает с 32-битными словами и выдаёт 32-битный результат. HMAC-SHA256 здесь не годится: процессор Вирта 32-битный и работает на 25 МГц, а из 24 байт сообщения на подпись остаётся от силы четыре. HalfSipHash придуман как раз для таких маленьких устройств и на Обероне занимает около трёх десятков строк. Тридцати двух бит маловато для серьёзной криптографии, но для учебного кластера это честный компромисс.

От повтора записанного настоящего сообщения подпись не спасает, поэтому в каждом сообщении есть ещё 16-битный счётчик, и получатель отбрасывает всё, что не новее последнего принятого от того же отправителя. Тест на отказы проверяет и это. Сразу после настоящего назначения он отправляет узлу от лица постороннего «ничего не запускай» тремя способами: с меткой чужого кластера, с подписью не тем ключом и в виде записанного ранее подлинного назначения. Все три узел игнорирует. А то же самое сообщение, честно подписанное ключом и со свежим счётчиком, выполняет, и это контрольный случай, без которого проверка ничего бы не доказывала.

Сколько это выдерживает

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

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

Две ошибки в нашем QEMU

Чтобы всё это заработало, пришлось исправить две ошибки не в Kube, а в нашей модели машины для QEMU, и без кластера я бы не нашёл ни одну. Операция MOD после умножения иногда возвращала не ту половину произведения, из-за чего контрольная сумма хранилища всегда получалась нулевой. Фоновая задача не замечала по ней никаких изменений, и хранилище так ни разу и не записалось. А машина, у которой однажды пропал ретранслятор, переставала слышать эфир навсегда, потому что UDP-канал QEMU после неудачного чтения тихо отключал своего читателя. Обе находки описаны в репозитории, и это хороший пример того, почему я так люблю гонять на эмуляторе настоящие программы: при загрузке системы ни одна из этих ошибок не проявлялась.

Команды при старте и OberonKube

Последний шаг касался удобства, но без него всё остальное теряло смысл. Кластер, где на каждой машине нужно набирать команды через VNC, никто не станет запускать во второй раз. Мне нужно было, чтобы машина при старте сама знала, кто она такая, как облачная виртуалка узнаёт свою роль из cloud-init.

Теперь QEMU принимает строку команд, которые машина должна выполнить при старте, и подаёт её на последовательный порт, словно её набрали на консоли. Маленький модуль Boot читает эту строку и выполняет команды одну за другой, каждую со своими параметрами. Вызывается он в самом конце тела модуля System, который загружается при старте системы. Сам System пересобран из исходников образа с этой единственной добавленной строкой, поэтому его интерфейс и ключ, который сверяют все остальные модули, остались прежними.

Тут кластер преподал ещё один урок. Команды при старте выполняются при каждой загрузке, а не только при первой. Если среди них стоит Kube.Apply web 4 Ticker, то после перезапуска управляющей машины деплоймент вернётся к тому, что написано в командах, и выкатка, сделанная с тех пор вручную, тихо откатится. Хорошо это или плохо, зависит от того, что считать источником истины. В облаке им служит форма заказа, и откат к ней там как раз правильное поведение. А в браузерной лаборатории, где человек выкатывает новую версию руками, это была ошибка, и нашла её, кстати, сама лабораторная, а не тесты в QEMU. Для таких случаев появилась команда Kube.Ensure, которая создаёт деплоймент, только если его ещё нет.

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

Что навязал Оберон

Когда пишешь Kubernetes на Go для Linux, почти любое решение можно принять как угодно. Нужна очередь, и под рукой каналы; нужна база, и есть etcd; нужна сеть, и есть gRPC поверх TCP; нужна изоляция, и есть пространства имён ядра. Выбор есть всегда, и потому решения часто принимаются по привычке. На Обероне выбирать почти не из чего, и в этом самое интересное: каждое ограничение языка и системы вынуждало принять вполне определённое решение, а по этим решениям хорошо видно, какие свойства Kubernetes следуют из самой его идеи, а какие из того, на чём его построили.

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

Что навязал язык

Размеры известны заранее. В Oberon-07 размер массива почти всегда известен при компиляции, а динамическая память выделяется только под записи, на которые ссылаются указатели. Писать на таком языке программу, где всё растёт по мере надобности, неудобно, и я не стал. Хранилище Kube — массив на 256 объектов, у узла не больше десяти подов, имя узла не длиннее шести символов, имя образа не длиннее пятнадцати, номер пода занимает один байт. У каждого из этих чисел есть причина, и все они собраны в двух блоках констант, в начале модулей Kube и KubeNet. Благодаря этому Kube не выделяет память в рабочем цикле, не может исчерпать её под наплывом объектов и на пределе ведёт себя предсказуемо: лишний под просто не будет создан. Настоящий Kubernetes тоже живёт с лимитами, вроде 110 подов на узел по умолчанию или полутора мегабайт на объект в etcd, но там они разбросаны по документации, а здесь их не спрячешь.

Целые числа 32-битные, а беззнаковыми бывают только байты. HalfSipHash как раз работает с 32-битными словами и даёт 32-битную подпись, которая помещается в кадр. Отсюда же арифметика счётчика по модулю 65536 с аккуратной проверкой «новее ли этот номер», которая не ошибается и после переполнения. Отсюда же контрольная сумма хранилища, посчитанная так, чтобы не зависеть от знака. Мелочь, но именно на ней обнаружилась ошибка с MOD в нашем QEMU.

Команды без параметров. Экспортированная процедура без параметров считается командой, а с параметрами уже нет. Поэтому kubelet не может вызвать Start(pod): он кладёт номер пода в модуль Pods и вызывает Start без аргументов. На любом другом языке это сочли бы дурным тоном, а здесь иначе нельзя, и это безопасно, потому что одновременно выполняется только одна команда.

Модули с ключами. Каждый скомпилированный модуль несёт ключ своего интерфейса, и при загрузке система сверяет его с тем, против чего собраны модули-импортёры. Получается, что образ пода в Kube — не просто имя, а имя со встроенной проверкой совместимости. Если модуль нагрузки собран против старой версии Pods, система откажется его загружать, и под останется Pending, вместо того чтобы упасть посреди работы. В мире контейнеров ближе всего к этому закреплённый дайджест образа, но он гарантирует лишь, что вы получили те самые байты, а вовсе не то, что они совместимы с окружением.

Язык маленький. Звучит как недостаток, но на деле дисциплинирует сильнее всего остального. В Обероне нет обобщённых коллекций, исключений, интерфейсов, горутин и отражения, поэтому весь Kube написан простыми циклами по массивам и процедурами, которые вместо исключения возвращают булево значение. Читается это почти как псевдокод, и я как раз хотел, чтобы его можно было так читать.

Что навязали система и машина

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

Защиты памяти нет. Все модули живут в одном адресном пространстве, и испортить чужую память одному модулю мешает только язык со строгой типизацией и проверкой границ массивов. Для Kube это означает, что поды никак не изолированы ни друг от друга, ни от kubelet. Это самое серьёзное отличие от настоящего Kubernetes, и я ещё вернусь к нему, когда речь пойдёт о том, чего Оберону не хватило.

Интерфейс — это текст. В Обероне любая строчка вида Модуль.Команда в любом окне является командой. Поэтому у Kube нет ни API-сервера, ни YAML, ни kubectl. Его API составляют команды Kube.Apply web 6 Ticker2, Kube.Get, Kube.DeletePod, которые человек пишет в любом окне и запускает средним щелчком, а результат читает в системном журнале. Тот же принцип позволил сделать команды при старте: роль машины задаётся обычной строкой команд, которую QEMU подаёт на последовательный порт, а модуль Boot выполняет её так же, как выполнил бы человек. Никакого особого формата конфигурации не понадобилось, конфигурацией машины стал текст её команд.

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

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

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

Чем Kube отличается от настоящего Kubernetes

Чтобы не создавать иллюзий, вот краткое сравнение. Kube воплощает идею Kubernetes, а не сам Kubernetes, и разница между ними огромна.

Kubernetes

Kube

объём

миллионы строк на Go

около 1400 строк на Обероне, из них управляющий слой около 800

хранилище

etcd, распределённое и согласованное по Raft

массив на 256 объектов в памяти одной машины и два файла на её диске

API

API-сервер, REST, YAML, kubectl, RBAC

команды Оберона, которые человек пишет в окне

виды объектов

десятки, плюс собственные через CRD

четыре: Deployment, ReplicaSet, Pod и Node

сеть между узлами

TCP/IP, обычно с отдельной сетью для подов

вещание по радио, кадры по 32 байта

образ пода

контейнерный образ из реестра

модуль Оберона на диске узла

изоляция подов

пространства имён и cgroups ядра Linux

никакой, все поды в одном адресном пространстве с kubelet

ресурсы

запросы и лимиты по CPU и памяти

только число подов на узле, не больше десяти

планировщик

фильтры, оценки, сродство, приоритеты

наименее загруженный узел

сеть для приложений

Service, DNS, Ingress

нет

хранилища для приложений

PersistentVolume

нет

отказоустойчивость управляющего слоя

несколько реплик API-сервера и etcd

одна машина, после перезапуска состояние читается с диска

проверка готовности

readiness и liveness probes

под готов, как только вернулась его команда Start

безопасность протокола

TLS со взаимной проверкой сертификатов

32-битная подпись общим ключом и счётчик против повторов

масштаб

тысячи узлов

проверено до восьми узлов, все в одном эфире

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

Где Kube оказался лучше, а где Оберон сплоховал

Сравнивать игрушечный кластер на радио с Kubernetes, на котором держится половина интернета, вроде бы нечестно, и утверждать, что Kube лучше, я не собираюсь. Меня интересует другое: какие качества проявились у Kube сами собой, без всяких усилий, просто потому, что он вырос на Обероне. Я имею в виду те свойства, которых обычный Kubernetes либо лишён, либо которые обходятся там слишком дорого. Таких свойств набралось шесть.

Весь кластер можно прочитать за вечер

Управляющий слой занимает около восьмисот строк, сетевой модуль с kubelet и подписями около четырёхсот, а вместе с учебной нагрузкой и модулем команд при старте получается около тысячи четырёхсот. Ниже лежит только система Оберон, которую тоже можно прочитать целиком, и процессор Вирта на Verilog, на который хватит пары вечеров. Выходит, что весь путь от «хочу шесть реплик Ticker2» до регистра радиомикросхемы, через который назначение улетает на узел, можно пройти глазами и ни разу не упереться в библиотеку, которую никто никогда не читал.

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

Эфир и есть наблюдаемость

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

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

Под запускается в мгновение ока

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

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

Совместимость проверяется при загрузке

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

Нет блокировок, а значит, нет и состояния гонки

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

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

Безопасность с первого сообщения

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

Весь кластер во вкладке браузера

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

Чего Оберону не хватило

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

Изоляции. Это главное. В Обероне все модули живут в одном адресном пространстве и доверяют друг другу. Под, испортивший память через SYSTEM.PUT, испортит заодно и kubelet, и радио, и всё прочее. Под, ушедший в бесконечный цикл, заморозит весь узел, потому что кооперативная задача, не вернувшая управление, останавливает систему целиком. Пока на машине работает код одного автора, который этому коду доверяет, всё в порядке, и Вирт так и задумывал свою систему: для одного человека за одним компьютером. Но кластер существует как раз для того, чтобы запускать чужой код, и без изоляции это невозможно. Kubernetes на Linux получает изоляцию от ядра, а Оберону, чтобы её получить, пришлось бы обзавестись защитой памяти в процессоре, вытесняющей многозадачностью и учётом ресурсов, то есть стать совсем другой системой.

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

Сети. Радио с кадрами по 32 байта прекрасно подходит для управления, но данным приложений в нём места нет. У подов Kube нет ни адресов, ни сервисов, ни способа поговорить друг с другом. Файлы по радио Вирта передавать можно, но это будет больше похоже на флешку, чем на сеть.

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

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

Многопользовательность. У Оберона один пользователь, у Kube один ключ на кластер. Нет ни пространств имён, ни ролей, ни разграничения прав, и тот, кто знает ключ, может всё. Kubernetes тратит большую часть своей сложности как раз на то, чтобы множество людей и команд могли безопасно делить один кластер, а в Kube такого слоя нет совсем.

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

Шесть лабораторных в браузере

Поначалу я сомневался, что кластер вообще удастся запустить в браузере. Машина Оберона в лабораторках из предыдущей статьи серии уже работала во вкладке: это настоящая схема Вирта, переведённая в C++ и скомпилированная в WebAssembly. Но радио у неё не было, а кластеру нужны три машины, которые слышат друг друга. Пришлось перенести в браузерную машину ту же модель приёмопередатчика nRF24L01+, что уже была в QEMU, и научить её отдавать отправленные кадры наружу и принимать чужие. Каждая машина работает в собственном фоновом потоке, а страница забирает у них кадры и раздаёт остальным, то есть сама служит эфиром. Заодно она умеет терять заданную долю кадров, отрезать от эфира отдельную машину и читать сообщения Kube, сверяя подписи, и поэтому справа на ней видна таблица кластера, составленная по эфиру.

Понадобился и последовательный порт, чтобы машины при включении сами узнавали свои роли, как в облаке. Управляющая машина получает команды Kube.Start, KubeNet.Serve kube 00c0ffee00c0ffee и Kube.Ensure web 4 Ticker, а узлы KubeNet.Join со своими именами, так что набирать ничего не нужно. Kube.Ensure здесь стоит не случайно, но об этом в шестой лабораторной.

Отдельной задачей оказалась скорость. В WebAssembly схема Вирта выполняет меньше миллиона инструкций в секунду на машину, то есть работает в десятки раз медленнее платы на 25 мегагерц. А Kube отсчитывает время в секундах машины: сигнал жизни раз в секунду, таймаут в пять. Оставь я часы машин честными, кластер собирался бы несколько минут, а лабораторную с выключением узла пришлось бы ждать минут пять. Поэтому страница пускает часы машин вдесятеро быстрее их инструкций, и машины думают, что работают на частоте 2,5 мегагерца. Их секунды текут в темпе, за которым можно уследить глазами, а протоколу всё равно: он не знает, сколько инструкций уложилось в секунду.

Ещё одна неожиданная ловушка подстерегала в фоновых вкладках: браузер сильно замедляет в них таймеры, и кластер почти замирал. Пришлось сделать так, чтобы машины сами крутили свой цикл и не зависели от таймеров страницы. Последней нашлась ловушка с вводом. Страница набирает команду на машине клавишами и щелчками мыши и поначалу делала это за один присест. Это около одиннадцати миллионов инструкций, а при ускоренных часах больше четырёх секунд машинного времени, в течение которых машина не слышит эфира. После каждого нажатия кнопки управляющая машина объявляла оба узла NotReady, и спасало её только то, что при FullDisruption выселение останавливается. Теперь ввод подаётся маленькими порциями вперемежку с работой машины, и эфир доставляется в промежутках.

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

Открыть лабораторку

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

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

1. Кластер собирается сам

Делать ничего не нужно, достаточно подождать. Машины загружаются, и на экране управляющей видно, как модуль Boot выполняет команды, пришедшие по последовательному порту. Kube ставит три контроллера, начинает слушать эфир и создаёт деплоймент web на четыре пода, а ещё через пару секунд в журнале появляются строчки «node node1 Ready» и «node node2 Ready».

Экран управляющей машины. Всё, что в журнале ниже строчки с версией системы, сделали команды при старте, никто не нажал ни одной клавиши

Экран управляющей машины. Всё, что в журнале ниже строчки с версией системы, сделали команды при старте, никто не нажал ни одной клавиши

Тем временем на узлах видно, как kubelet получил назначение и запустил поды модуля Ticker.

Экран узла node1. Kubelet подключился к эфиру, получил назначение с двумя подами, загрузил модуль Ticker и вызвал его Start для каждого. Последние строчки — вывод Ticker.Show: сколько секунд прожил каждый под

Экран узла node1. Kubelet подключился к эфиру, получил назначение с двумя подами, загрузил модуль Ticker и вызвал его Start для каждого. Последние строчки — вывод Ticker.Show: сколько секунд прожил каждый под

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

Таблица кластера, составленная по эфиру. Страница ничего не спрашивает у Kube, она просто слушает сигналы жизни и назначения

Таблица кластера, составленная по эфиру. Страница ничего не спрашивает у Kube, она просто слушает сигналы жизни и назначения

2. Выкатить новый код

Нажмите кнопку web 4 Ticker2. Страница наберёт на управляющей машине команду Kube.Apply web 4 Ticker2 ~ и выполнит её средним щелчком, как это сделал бы человек. Kube создаст новый ReplicaSet и начнёт переносить поды по одному. Проверка засчитает выкатку, когда все поды заработают на новом образе, и только если за всё это время подов ни разу не стало меньше четырёх или больше пяти. Считает она по сигналам жизни, то есть по тому, что на самом деле выполняли узлы.

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

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

Когда выкатка закончится, нажмите на узле Ticker2.Show, и он покажет, что считает уже новая версия. Вся история при этом видна в журнале узла: поды Ticker v1 остановлены, поды Ticker v2 запущены, а номера у новых подов не те, что у старых.

Экран узла после выкатки. Каждый старый под получил Stop, каждый новый Start, а Ticker2.Show перечисляет новые поды

Экран узла после выкатки. Каждый старый под получил Stop, каждый новый Start, а Ticker2.Show перечисляет новые поды

3. Узел умирает

Нажмите Выключить над экраном того узла, на котором работают поды. Экран погаснет, сигналы жизни прекратятся, и через пять секунд машинного времени управляющая машина пометит узел как NotReady, а ещё через две перенесёт его поды на оставшийся. Если включить узел снова, он загрузится, подключится к эфиру и станет Ready, но поды ему никто не вернёт: как и настоящий планировщик, Kube не переносит работающие поды на узел, появившийся позже. Зато новые поды при масштабировании пойдут уже на него как на наименее загруженный.

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

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

4. Пропадает эфир

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

Эфир выключен. Оба узла NotReady, но назначения прежние: Kube понял, что это обрыв связи

Эфир выключен. Оба узла NotReady, но назначения прежние: Kube понял, что это обрыв связи

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

5. Нарушитель

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

Четвёртая кнопка подписывает подделку настоящим ключом кластера и ставит свежий счётчик. Это контрольный случай, и узел подчиняется, останавливая поды. Без него лабораторная ничего бы не доказывала: вдруг узел просто не слушает никого, кроме управляющей машины? А через секунду управляющая машина присылает очередное назначение, и поды возвращаются, потому что протокол передаёт полное состояние, а не команды.

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

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

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

6. Перезапуск управляющей машины

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

Именно эта лабораторная нашла последнюю ошибку перед публикацией. Поначалу в командах при старте стояло Kube.Apply web 4 Ticker, и после перезапуска деплоймент возвращался к Ticker, перечёркивая выкатку из второй лабораторной. Тесты в QEMU этого не замечали, потому что перезапускали управляющую машину ещё до выкатки. Здесь, где человек выкатывает новую версию вручную, правильно сохранять то, что он сделал, поэтому в командах теперь стоит Kube.Ensure: она создаёт деплоймент, только если его ещё нет. В облаке, напротив, источником истины служит форма заказа, и там остался Kube.Apply.

Все шесть лабораторных пройдены

Все шесть лабораторных пройдены

Что ещё можно попробовать

Лабораторными возможности страницы не исчерпываются. Ползунком потерь можно испортить эфир, скажем, терять 30 процентов кадров, и убедиться, что поды при этом никуда не переезжают. Если поднять потери намного выше, рано или поздно пропадут пять сигналов жизни подряд, и Kube примет живой узел за мёртвый; это и есть цена таймаута. Кнопкой Отрезать от эфира можно изолировать машину, не выключая её, и посмотреть, как отрезанный узел продолжает выполнять свои поды, хотя управляющая машина уже раздала их другим. Это тот самый случай, когда под работает в двух местах, и Kubernetes живёт с ним точно так же. В поле команды можно написать любую команду Оберона, например Kube.Apply api 2 Ticker ~, чтобы создать второй деплоймент, а можно щёлкнуть прямо в экран машины и поработать в ней руками. Средняя кнопка там изображается щелчком с Alt.

Как попробовать самому

Способов четыре, от совсем простого, где ничего не нужно ставить, до собственного облака. Всё, о чём пойдёт речь, открыто: исходники лежат в репозитории tym83/paleocomputing, код Kube в папке impl/kube, а подробное описание с замерами в impl/kube/README.md.

В браузере

Откройте лабораторию с кластером и подождите полминуты. Ставить ничего не нужно: всё работает во вкладке, а сервер только отдаёт файлы. Лучше всего страница чувствует себя в свежем Chrome или Firefox на компьютере с несколькими ядрами, потому что каждая из трёх машин занимает своё ядро. Вкладку лучше держать на виду: в фоне браузер её притормаживает, и кластер хоть и не останавливается, но живёт заметно медленнее.

Чтобы запустить ту же страницу у себя, достаточно склонировать репозиторий, поднять любой статический сервер в папке impl/web, например python3 -m http.server 8765, и открыть http://127.0.0.1:8765/kube.html. А если браузер вообще не нужен, тот же кластер из трёх машин запускается в Node.js командой node impl/web/kube-test.mjs. Она загружает три машины на настоящей схеме Вирта, ждёт, пока кластер соберётся, и по эфиру проверяет, что все поды работают, а все подписи верны. Часы машин здесь идут честно, так что на это уходит около минуты.

В QEMU на своём компьютере

Понадобятся Docker, git и make. Сначала нужно собрать нашу модель машины для QEMU. Сборка идёт в контейнере, поэтому ставить в систему зависимости QEMU не придётся, но займёт она минут десять:

git clone https://github.com/tym83/paleocomputing
cd paleocomputing
make -C qemu build

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

docker create --name payload ghcr.io/tym83/paleocomputing/oberon-run:v0.1.21
docker cp payload:/opt/oberon/payload/prom.bin .
docker cp payload:/opt/oberon/payload/oberon.dsk .
docker rm payload
for m in plane node1 node2; do cp oberon.dsk $m.dsk; truncate -s 8M $m.dsk; done

Дальше нужен эфир, то есть отдельная сеть Docker и ретранслятор в ней:

docker network create kube-air
docker run -d --name relay --network kube-air -v "$PWD/qemu/radio:/r" \
  qemu-build:risc5 'python3 -u /r/relay.py'

И наконец три машины, каждая со своими командами при старте. Строка после commands= — ровно то, что машина выполнит после загрузки, а команды в ней разделены точкой с запятой:

run() {
  docker run -d --name $1 --network kube-air -p 127.0.0.1:$2:5900 \
    -v "$PWD/.qemu-work:/src:ro" -v "$PWD:/w" -w /w qemu-build:risc5 \
    "/src/build/qemu-system-risc5 -machine 'oberon,radio=air,commands=$3' \
     -bios prom.bin -drive if=none,id=sd0,file=$1.dsk,format=raw -vnc :0 \
     -chardev udp,id=air,host=relay,port=7524,localaddr=0.0.0.0,localport=7524"
}
KEY=00c0ffee00c0ffee
run plane 5900 "Kube.Start;KubeNet.Serve kube $KEY;Kube.Apply web 4 Ticker"
run node1 5901 "KubeNet.Join node1 kube $KEY"
run node2 5902 "KubeNet.Join node2 kube $KEY"

Здесь стоит Kube.Apply, а не Kube.Ensure, как в браузерной лаборатории: в выпуске v0.1.21 команды Kube.Ensure ещё нет. Разница проявится, только если выкатить новую версию вручную и перезапустить управляющую машину: с Kube.Apply деплоймент вернётся к тому, что написано в командах.

Экраны машин доступны по VNC на портах 5900, 5901 и 5902. В программной эмуляции загрузка занимает до минуты, после чего в журнале управляющей машины появятся строчки о готовых узлах, а в журналах узлов о запущенных подах. Мышь в Обероне трёхкнопочная, и средняя кнопка выполняет команду, на которую указывает. Поэтому Kube.Get в любом окне управляющей машины покажет все объекты, а Ticker.Show на узле его поды. Чтобы выкатить новую версию, напишите на управляющей машине Kube.Apply web 4 Ticker2 ~ и щёлкните по этой строчке средней кнопкой.

Управляющая машина в QEMU после перезапуска. Хранилище вернулось с диска, а  показывает те же поды на том же узле. Снимок сделан автоматической проверкой, у которой в командах при старте стоит , поэтому в журнале видно, что существующий деплоймент она не тронула

Управляющая машина в QEMU после перезапуска. Хранилище вернулось с диска, а показывает те же поды на том же узле. Снимок сделан автоматической проверкой, у которой в командах при старте стоит , поэтому в журнале видно, что существующий деплоймент она не тронула

Узел node-b в QEMU. Он подключился первым и получил все четыре пода: как и настоящий планировщик, Kube не переносит работающие поды на узел, появившийся позже

Узел node-b в QEMU. Он подключился первым и получил все четыре пода: как и настоящий планировщик, Kube не переносит работающие поды на узел, появившийся позже

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

docker run --rm -it --network kube-air -v "$PWD/qemu/radio:/r" \
  qemu-build:risc5 'python3 -u /r/listen.py relay --json --key 00c0ffee00c0ffee'

А дальше можно ломать. docker stop node2 выключает узел, docker stop relay глушит эфир, а docker start возвращает и то и другое. Если ретранслятор после перезапуска получит другой адрес, машины найдут его сами: при неудачной отправке наша модель заново узнаёт адрес по имени. В той же папке лежит inject.py, который умеет изображать нарушителя.

Точно так же, только автоматически, кластер запускают проверки в репозитории. Собрав QEMU и инструменты командой make -C impl tools, их можно прогнать самому: python3 qemu/test/kube_dr_check.py проверяет все отказы, о которых шла речь выше (их таблица лежит в impl/kube/README.md), python3 qemu/test/kube_boot_check.py проверяет, что кластер собирается из одних команд при старте, а python3 qemu/test/kube_load_check.py --nodes 4 меряет нагрузку. Эти проверки собирают диск из исходников, поэтому в них уже есть Kube.Ensure. Учтите, что каждая машина Оберона занимает целое ядро, потому что её цикл никогда не простаивает, и восемь узлов на ноутбуке будут мерить скорее ноутбук, чем кластер.

Наигравшись, удалите контейнеры командой docker rm -f plane node1 node2 relay, а сеть командой docker network rm kube-air.

В своём KubeVirt

Машину Оберона можно запустить и в обычном KubeVirt, без Cozystack. Для этого нужен наш образ virt-launcher, который знает архитектуру RISC5, и включённая в KubeVirt возможность подключать к виртуальным машинам перехватчики. Как это сделать, подробно описано в инструкции, там же есть пример ресурса VirtualMachine. Для кластера понадобятся три такие машины, ретранслятор в виде обычного пода с UDP-сервисом и команды при старте в аннотации машины, которые перехватчик передаст в QEMU. Ровно это за вас делает каталог Cozystack, так что без Cozystack проще всего подсмотреть, что создают его чарты в папке marketplace.

В Cozystack

Если у вас есть Cozystack, достаточно один раз подключить наш каталог утилитой cozypkg:

cozypkg tap oci://ghcr.io/tym83/paleocomputing/machines:v0.1.21
cozypkg add paleocomputing.machines

От администратора кластера при этом требуются две вещи: включить в KubeVirt признак Sidecar и поставить наш образ virt-launcher под вашу версию KubeVirt. Подробности на странице проекта.

После этого в дашборде у пользователей появится раздел Paleocomputing, а в нём, среди прочего, OberonVM, OberonAir и OberonKube. Кластер Kube заказывается одной формой или одним ресурсом:

apiVersion: apps.cozystack.io/v1alpha1
kind: OberonKube
metadata:
  name: farm
spec:
  nodes: 3
  key: 00c0ffee00c0ffee
  deployments: web 4 Ticker; api 2 Ticker2

По этому заказу каталог создаст эфир, управляющую машину и три узла, и кластер соберётся сам. Экран любой машины открывается командой virtctl vnc с правами тенанта, а имена машин видны в дашборде. Источник истины здесь — форма: её деплойменты применяются при каждом старте управляющей машины, поэтому изменённая форма вступит в силу после перезапуска, а правка, сделанная через VNC, проживёт только до него. Удаление заказа удаляет и все его части.

Можно собрать кластер и вручную, из отдельных машин. Тогда создайте OberonAir, а в каждой OberonVM укажите этот эфир в поле air, роль в поле kubeRole (plane для управляющей машины и node для узлов), имя узла в kubeNode и одинаковый ключ в kubeKey. Деплойменты управляющей машине задаются в поле commands, а имя кластера, если нужно не kube, в поле kubeCluster. Так интереснее, если хочется, например, поселить на одном эфире два кластера с разными ключами и посмотреть, как они не мешают друг другу.

Что всё это говорит об инфраструктуре завтрашнего дня

В начале я упоминал, что это еще и исследование, а не просто шутка, поэтому попробую сформулировать, чему меня научил кластер на радио Вирта. Не в том смысле, что всем пора переписать Kubernetes на Обероне, а в том, какие идеи стоило бы унести из этой маленькой системы в большую.

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

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

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

Пределы, записанные в коде. Все потолки Kube собраны в двух блоках констант, и у каждого есть причина. У Kubernetes тоже есть пределы, но они разбросаны по флагам, документации и опыту эксплуатации, и о многих узнаёшь, только когда в них упрёшься. Мне бы хотелось, чтобы большие системы честнее объявляли свои пределы и их причины, а не делали вид, будто пределов нет.

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

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

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

Вместо итога

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

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

Проект открытый. Исходники лежат в репозитории; наш код распространяется под лицензией Apache-2.0, а модель машины для QEMU, как и сам QEMU, под GPL. Лаборатория с кластером живёт по адресу tym83.github.io/paleocomputing/oberon/kube.html, а остальные лаборатории серии и страница о каталоге для Cozystack — на сайте проекта. О самом Cozystack можно почитать на cozystack.io. Буду рад, если кто-нибудь выключит в лаборатории эфир не на полминуты, а как-нибудь похитрее, и найдёт, где Kube ломается. Таких находок в этой истории было уже немало, и каждая чему-нибудь научила.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.

Палеокомпьютинг, часть 2: Kubernetes на Обероне, радио Вирта вместо сети, кластер в браузере и преимущества перед K8s — KioskNews