Создание эмулятора игр MS-DOS в Kubernetes

В этой статье мы разберёмся, как можно интегрировать JavaScript-библиотеку js-dos в собственное решение Kubernetes, что позволит нам предоставлять доступ к играм MS-DOS в виде сервисов Kubernetes и запускать их в браузере.
Кроме того, по ходу статьи я дам советы и рекомендации начального, продвинутого и высокого уровней по разработке собственных контроллеров Kubernetes при помощи Golang и Kubebuilder или Operator SDK.
▍ Введение
js-dos — это JavaScript-библиотека, содержащая полнофункциональный DOS-плеер, который можно использовать для загрузки программ MS-DOS и для запуска их в веб-браузере; по сути, это порт знаменитого эмулятора DOSBOX на JavaScript. Библиотека js-dos проста, но очень эффективна, её можно установить и использовать как стандартную JavaScript-библиотеку множеством различных способов: в iframe, в nodeJS, в React или непосредственно на «ванильной» HTML-странице, открываемой в браузере. В нашем решении мы воспользуемся последним вариантом.
js-dos имеет собственный формат файлов дистрибутивов под названием bundle. Bundle — это просто zip-файлы, содержащие кучу файлов, необходимых для настройки и конфигурирования эмулятора и самой игры; всё это удобно упаковано в файл с расширением .jsdos. Для загрузки игры и эмулятора на веб-странице в дополнение к нему понадобится HTML-страница (с очень стандартизированным контентом), которая загружает библиотеку js-dos, её файл CSS, файл игры для js-dos, необходимую для игры конфигурацию DOSBOX и, наконец, сам эмулятор DOSBOX в виде WASM.
▍ Проектирование и реализация
Важное примечание: эта статья не является подробным руководством по созданию собственных контроллеров Kubernetes или по программированию на Golang. Она требует базового понимания языка Go и определённого уровня понимания работы контроллеров и операторов Kubernetes; по сути, это введение в Kubebuilder или Operator SDK.
Основная идея заключается в следующем: нам нужна единая структурная сущность, которая будет указывать на бандл (bundle) игры и позволит Kubernetes заняться обеспечением работы и жизненного цикла всех элементов, необходимых для запуска игры в браузере.
Самое очевидное решение заключается в создании Custom Resource (CR) и собственного контроллера, которые предоставят эти ресурсы Kubernetes, обеспечивающему данную функциональность. Давайте назовём этот CR Game; его определение в самой простой форме будет примерно таким:
type GameSpec struct {
// +kubebuilder:validation:Required
// +kubebuilder:validation:Type=string
GameName string `json:"gameName"`
// +kubebuilder:validation:Required
// +kubebuilder:validation:Pattern:=`^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$`
Url string `json:"url"`
// +kubebuilder:default:=false
// +kubebuilder:validation:Required
// +kubebuilder:validation:Type=boolean
Deploy bool `json:"deploy"`
// +optional
// +kubebuilder:default=80
// +kubebuilder:validation:Type=integer
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
// +kubebuilder:validation:ExclusiveMinimum=false
// +kubebuilder:validation:ExclusiveMaximum=false
Port int `json:"port,omitempty"`
}
Game состоит из трёх обязательных и одного опционального поля: GameName — очевидно, это человекочитаемое название игры, Url — это адрес, с которого будет скачиваться бандл игры, Deploy — это булев переключатель для установки и удаления игры; опциональный Port — это номер порта, в котором будет открыт сервис Kubernetes.
Мы можем использовать маркеры (аннотации)
// +kubebuilder:validationдля конфигурирования и применения значений, валидации диапазонов или типов, напрямую или косвенно при помощи regex для нашего API. Подробнее об этом можно почитать здесь. Такие маркеры подходят для самой простой валидации; если вам нужно реализовать более сложные сценарии, то стоит задуматься о концепции Admissions Webhooks.
Итак, давайте вернёмся к нашему введению. У нас уже есть структура, хранящая базовую информацию об игре. Чтобы эта игра была играбельной, нужно настроить ещё пару вещей. Нам необходима HTML-страница, которая будет загружать а) бандл б) скрипты и таблицы стилизации в) эмулятор DOSBOX и всё, что будет получать и передавать веб-сервер.
Давайте начнём снизу. В качестве веб-сервера мы воспользуемся простым компонентом; это будет инстанс nginx, считывающий простую HTML-страницу и публикующий её в порт 80. Проще всего это сделать, создав Pod и предоставить в этом Pod контейнер nginx, передающий эту страницу.
Но как мы предоставим эту HTML-страницу (отдельно настраиваемую для каждой игры) Kubernetes и позволим контейнеру nginx получать её? Ответ прост: при помощи ConfigMap, которую мы позже смонтируем в контейнер nginx как Volume:
apiVersion: v1
kind: ConfigMap
metadata:
name: {{.Name}}-index-configmap
namespace: {{.Namespace}}
data:
index.html: |
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
В разделе data ConfigMap мы укажем ключ index.html, а его значение будет равно template-версии HTML-страницы, отвечающей за загрузку необходимых артефактов для каждой игры.
Ниже я объясню, почему этот и последующие манифесты являются шаблонами, а также расскажу, как мы ими будем пользоваться.
Но здесь у нас возникает проблема! Как говорилось выше, HTML-страница будет отвечать за загрузку необходимых артефактов для каждой игры (например, js-dos.js, jsdos.css и шаблонизированное значение url бандла {{.Bundle}}).
Где мы будем находить эти файлы? Нам нужно скачать их и поместить в Volume, чтобы они были доступны веб-серверу nginx в первый раз, когда кто-то попытается загрузить страницу. Чтобы добиться этого, мы предоставим в нашем Pod второй контейнер, но он будет иметь существенное отличие от предыдущего: это будет контейнер инициализации.
Контейнеры инициализации (Init Container) — это особые контейнеры, запускаемые до других контейнеров в Pod. Контейнеры инициализации могут содержать утилиты или скрипты настройки, отсутствующие в основном контейнере, и помогут нам скачивать файлы, загружать или перезагружать конфигурацию, и так далее.
Поэтому мы не будем усложнять и воспользуемся в качестве образа контейнера инициализации busybox, настроив его на выполнение команды wget, которая скачает все нужные нам артефакты и сохранит их в ту же папку, в которой будет храниться наша HTML-страница. По сути, это означает, что нам нужно смонтировать в контейнер инициализации volume, который мы смонтировали в контейнер nginx.
wget -P "/mnt/game" --no-check-certificate \
{{.BundleUrl}} \
https://js-dos.com/v7/build/releases/latest/js-dos/js-dos.css \
https://js-dos.com/v7/build/releases/latest/js-dos/js-dos.js \
https://js-dos.com/v7/build/releases/latest/js-dos/wdosbox.js \
https://js-dos.com/v7/build/releases/latest/js-dos/wdosbox.wasm
Но теперь вы вполне справедливо можете задаться вопросом: постойте-ка, а что насчёт Volume, кто и как будет его предоставлять? В случае HTML-контента нашего контейнера nginx ответ прост: ConfigMap можно смонтировать как volume в контейнер, но для артефактов, скачанных контейнером инициализации и используемых контейнером nginx нужно предоставить PersistentVolumeClaim (PVC):
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{.Name}}-pvc
namespace: {{.Namespace}}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{.Storage}}Mi
PersistentVolumeClaim (PVC) — это абстрактный запрос хранилища от пользователя. Он схож с Pod. Pod потребляют ресурсы узлов, а PVC потребляют ресурсы Persistent Volume (PV). Pod могут запрашивать конкретные уровни ресурсов (CPU или память). Claim могут запрашивать конкретный размер и режимы доступа (например, их можно смонтировать как ReadWriteOnce, ReadOnlyMany или ReadWriteMany).
PVC — это абстрактный запрос хранилища; когда Pod в первый раз привязывается к PVC (в нашем случае это происходит в контейнере инициализации) драйвер внутреннего хранилища автоматические создаёт PersistentVolume, который затем будет прикреплён к контейнерам без каких-либо действий с нашей стороны.
Вы можете задаться вопросом: всё это становится немного запутанным, можно ли навести немного порядок? Да, и для этого мы создадим Deployment, который будет владеть всеми этими ресурсами и предоставлять их от нашего имени:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{.Name}}
namespace: {{.Namespace}}
labels:
app: {{.Name}}
spec:
replicas: 1
selector:
matchLabels:
app: {{.Name}}
template:
metadata:
name: {{.Name}}
labels:
app: {{.Name}}
spec:
volumes:
- name: {{.Name}}-storage
persistentVolumeClaim:
claimName: {{.Name}}-pvc
- name: {{.Name}}-index
configMap:
name: {{.Name}}-index-configmap
containers:
- name: {{.Name}}-engine
image: nginx
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
volumeMounts:
- mountPath: /usr/share/nginx/html
name: {{.Name}}-storage
- mountPath: /usr/share/nginx/html/index.html
subPath: index.html
name: {{.Name}}-index
initContainers:
- name: {{.Name}}-init
image: busybox:1.28
imagePullPolicy: IfNotPresent
command: [ "sh" ]
args:
- -c
- >-
wget -P "/mnt/game" --no-check-certificate {{.BundleUrl}} https://js-dos.com/v7/build/releases/latest/js-dos/js-dos.css https://js-dos.com/v7/build/releases/latest/js-dos/js-dos.js https://js-dos.com/v7/build/releases/latest/js-dos/wdosbox.js https://js-dos.com/v7/build/releases/latest/js-dos/wdosbox.wasm;
volumeMounts:
- mountPath: /mnt/game
name: {{.Name}}-storage
restartPolicy: Always

Заглянув в spec.volumes, можно чётко увидеть, что мы приказали создать два volume, один из ConfigMap, второй из PVC. Сделанный из PVC монтируется в оба контейнера (см. volumeMounts в разделе containers и initContainers). В контейнере nginx есть небольшая тонкость, практически благодаря которой всё это работает: оба volume монтируются по одному пути в контейнер nginx, откуда веб-сервер будет брать HTML-страницу index.html.
volumeMounts:
- mountPath: /usr/share/nginx/html
name: {{.Name}}-storage
- mountPath: /usr/share/nginx/html/index.html
subPath: index.html
name: {{.Name}}-index
Разумеется, теперь нужно сделать так, чтобы у нас был доступ к контейнеру nginx вне границ Kubernetes, так мы сможем отрендерить HTML-страницу в браузере и сыграть в игру. Для этого мы предоставим Service:
apiVersion: v1
kind: Service
metadata:
name: {{.Name}}
namespace: {{.Namespace}}
spec:
selector:
app: {{.Name}}
ports:
- protocol: TCP
port: {{.Port}}
targetPort: 80
type: ClusterIP
▍ Создание контроллера
Основная причина разработки контроллера заключается в создании легковесного контроллера, который не будет генерировать внутри кластера ненужного «шума», так как потенциально мы можем загрузить довольно большое количество Game — сейчас в проекте сообщества js-dos dos.zone есть почти 2000 игр.
Чтобы минимизировать ненужные циклы синхронизации (reconciliation), мы будем играть при помощи так называемых Event Filters, настроив контроллер на фильтрацию того, какие события создания/обновления/удаления и общие события будут вызывать процесс синхронизации. Предикат добавляется к watch-объекту, который регулирует, должен ли он вызывать запрос синхронизации:
var (
gameEventFilters = builder.WithPredicates(predicate.Funcs{
UpdateFunc: func(e event.UpdateEvent) bool {
return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration()
},
DeleteFunc: func(e event.DeleteEvent) bool {
return !e.DeleteStateUnknown
},
CreateFunc: func(e event.CreateEvent) bool {
switch object := e.Object.(type) {
case *operatorv1alpha1.Game:
return object.Spec.Deploy
default:
return false
}
},
})
)
func (r *GameReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&operatorv1alpha1.Game{}, gameEventFilters).
Complete(r)
}
Мы имеем возможность выполнять контроль через четыре предикативные функции с соответствующими событиями: GenericFunc (здесь не используется), CreateFunc, UpdateFunc и DeleteFunc. Обратите внимание, что во время события Update синхронизация происходит только тогда, когда возникают реальные изменения в specs Game, а не когда происходит обновление status при сравнении на семантическое равенство значений ObjectOld и ObjectNew:
UpdateFunc: func(e event.UpdateEvent) bool {
return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration()
}
Мы полностью заглушаем события Delete. После удаления Game нам не нужно ничего обновлять в кластере. DeleteStateUnknown равно false, только если объект подтверждён API-сервером как удалённый:
DeleteFunc: func(e event.DeleteEvent) bool {
return !e.DeleteStateUnknown
}
Наконец, в процессе события Create мы позволяем запустить синхронизацию, только если вызвавший срабатывание событий CR — это Game и только если значение его поля Spec.deploy равно true.
CreateFunc: func(e event.CreateEvent) bool {
switch object := e.Object.(type) {
case *operatorv1alpha1.Game:
return object.Spec.Deploy
default:
return false
}
}
На то есть две причины: а) Game владеет ресурсами, которые мы рассмотрели в предыдущем параграфе (Deployment; а Deployment, в свою очередь, владеет ресурсами Pod, ConfigMap, PVC, PV и Service. Если мы удалим Game, то хотим, чтобы все эти ресурсы удалялись автоматически). Если в будущем мы хотим наблюдать за другими такими ресурсами, то у нас должна быть возможность определить источник событий. б) как говорилось ранее, потенциально может существовать огромное количество Game, которые создаются в больших масштабах. Если они одновременно вызовут процесс синхронизации, то мы подвергнем кластер чрезмерной нагрузке, поэтому нужно гарантировать, что только новые создаваемые Game, у которых Spec.Deploy имеет значение true, вызывали синхронизацию во время события Create.
А теперь давайте рассмотрим функциональность цикла синхронизации:
func (r *GameReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger = log.FromContext(ctx).WithName("controller")
game := &operatorv1alpha1.Game{}
if err := r.Get(ctx, req.NamespacedName, game); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
logger.V(5).Error(err, "unable to fetch game")
return ctrl.Result{}, err
}
if !game.Spec.Deploy {
err := r.DeleteDeployment(ctx, req, game)
if err != nil {
return ctrl.Result{}, err
}
_ = r.SetStatus(ctx, req, game, false)
return ctrl.Result{}, nil
}
deployment, err := r.CreateOrUpdateDeployment(ctx, req, game)
if err != nil {
return ctrl.Result{}, err
}
_, err = r.CreateOrUpdateConfigMap(ctx, req, game, deployment)
if err != nil {
return ctrl.Result{}, err
}
_, err = r.CreateOrUpdatePersistentVolumeClaim(ctx, req, game, deployment)
if err != nil {
return ctrl.Result{}, err
}
_, err = r.CreateOrUpdateService(ctx, req, game, deployment)
if err != nil {
return ctrl.Result{}, err
}
return r.RefreshStatus(ctx, req, game, deployment.Labels["app"])
}
Задача функции Reconcile заключается в управлении ресурсом Game. Если Spec.Deploy имеет значение false, то все предоставленные им ресурсы должны быть удалены (если ранее были установлены), а затем Spec.Deploy должно быть присвоено значение true, чтобы все необходимые артефакты были предоставлены идемпотентным образом.
Каждая функция CreateOrUpdateXXX выполняет две простые взаимосвязанные задачи:
а) Создаёт конкретный манифест для соответствующего ресурса:
На самом деле, мы можем создать все эти ресурсы Kubernetes и при помощи одного Golang, без шаблонов манифестов, но на мой взгляд, это обычно сложнее читать, сложнее поддерживать и менять; к тому же в долговременной перспективе повышается возможность ошибок.
Альтернативным решением было бы создание генерации манифестов в формате шаблонов, которые бы мы могли парсить в идиоматическом для Golang стиле при помощи пакета text/template. Для этих целей я сохранил все эти шаблоны YAML в отдельную папку, которую назвал manifests. Я создал пакет assets, использующий пакет embed для получения ссылки на содержимое манифестов.
//go:embed manifests/*
manifests embed.FS

Содержимое каждого шаблона считывается из функции getTemplate при помощи manifests.ReadFile, которая из подвергнутого парсингу контента возвращает вызывающей стороне шаблон:
func getTemplate(name string) (*template.Template, error) {
manifestBytes, err := manifests.ReadFile(fmt.Sprintf("manifests/%s.yaml", name))
if err != nil {
return nil, err
}
tmp := template.New(name)
parse, err := tmp.Parse(string(manifestBytes))
if err != nil {
return nil, err
}
return parse, nil
}
Затем вызывающая сторона создаёт анонимную struct metadata со значениями, которые должны быть заменены в шаблоне (например, для PVC):
metadata := struct {
Namespace string
Name string
Storage uint64
}{
Namespace: namespace,
Name: name,
Storage: storage,
}
Вызывающая сторона вызывает функцию getObject, которая заменяет заполнители шаблона значениями анонимной struct, а после передаёт созданный манифест объекту Golang, который узнает API-сервер.
func getObject(name string, gv schema.GroupVersion, metadata any) (runtime.Object, error) {
parse, err := getTemplate(name)
if err != nil {
return nil, err
}
var buffer bytes.Buffer
err = parse.Execute(&buffer, metadata)
if err != nil {
return nil, err
}
object, err := runtime.Decode(
appsCodecs.UniversalDecoder(gv),
buffer.Bytes(),
)
return object, nil
}
б) Развёртывает этот ресурс:
После получения нужного ресурса каждая функция CreateOrUpdateXXX теперь может создать ресурс в Kubernetes (и задать соответствующего владельца этого ресурса при помощи SetControllerReference):
deployment, err = assets.GetDeployment(game.Namespace, game.Name, game.Spec.Port, game.Spec.Url)
if err != nil {
logger.Error(err, "unable to parse deployment template")
return nil, err
}
err = ctrl.SetControllerReference(game, deployment, r.Scheme)
if err != nil {
logger.Error(err, "unable to set controller reference")
return nil, err
}
err = r.Create(ctx, deployment)
if err != nil {
logger.Error(err, "unable to create deployment")
return nil, err
}

Ожидая развёртывания ресурсов и завершения работы контейнера инициализации, контроллер периодически проверяет его статус (каждые 15 секунд). Когда статус Pod становится ready, выполняется выход из цикла синхронизации и ожидаются только внешние события (Create, Update или Delete), которые могут запустить его в будущем.
func (r *GameReconciler) RefreshStatus(
ctx context.Context,
req ctrl.Request,
game *operatorv1alpha1.Game,
appLabel string,
) (ctrl.Result, error) {
ready, err := r.GetStatus(ctx, req, appLabel)
if err != nil {
logger.V(5).Error(err, "unable to fetch pod status")
_ = r.SetStatus(ctx, req, game, false)
return ctrl.Result{
Requeue: true,
RequeueAfter: 15 * time.Second,
}, err
}
if !ready {
logger.Info("pod not ready, requeue in 15sec")
_ = r.SetStatus(ctx, req, game, ready)
return ctrl.Result{
Requeue: true,
RequeueAfter: 15 * time.Second,
}, nil
}
err = r.SetStatus(ctx, req, game, ready)
if err != nil {
return ctrl.Result{
Requeue: true,
RequeueAfter: 15 * time.Second,
}, nil
}
return ctrl.Result{}, nil
}

После подготовки Deployment и Pod контейнер инициализации скачивает все файлы и сохраняет их в PV.
Давайте протестируем систему и развернём манифест, который установит в наш кластер Prince of Persia:
apiVersion: operator.contrib.dosbox.com/v1alpha1
kind: Game
metadata:
name: prince-of-persia
spec:
gameName: "Prince of Persia"
url: "https://cdn.dos.zone/original/2X/1/1179a7c9e05b1679333ed6db08e7884f6e86c155.jsdos"
deploy: true
После завершения развёртывания мы готовы к переадресации порта Service и проверке в браузере (я выбрал порт 8080; на самом деле, не важно, на какой порт вы решите выполнять переадресацию):

▍ Работа на будущее
Этот мини-проект далёк от завершения. В нём отсутствует даже базовая функциональность, например, сохранение прогресса в PV, публикация метрик в Prometheus, публикация событий в Kubernetes, обновление Conditions статуса для CR, установка лимитов ресурсов для контейнеров, и многое другое. Тем не менее, его стоит попробовать, вы получите много удовольствия от этих ретроигр; также можно форкнуть репозиторий и двигаться в собственном направлении: GitHub — akyriako/kube-dosbox.
Скидки, итоги розыгрышей и новости о спутнике RUVDS — в нашем Telegram-канале 🚀

По «Наруто» анонсировали новый мини-сериал, подробности которого раскроют в ближайшее время
JoyToy выпустит фигурки персонажей из файтинга Super Street Fighter II Turbo
Для ремастера The Witcher 3: Wild Hunt опубликовали официальный список читов
Аналитики оценили перспективы бесплатного ремастера The Witcher 3 в миллионы долларов для CDPR
Разработчик Path of Exile раскритиковал Blizzard Entertainment после анонса Diablo V
Компания Inart анонсировала фигурку Волан-де-Морта со змеей Нагайной по вселенной Гарри Поттера
Hot Toys анонсировала коллекционную фигурку Загадочника из фильма Бэтмен навсегда
Naughty Dog официально подтвердила разработку нескольких новых проектов во вселенной The Last of Us