Создание эмулятора игр 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: |     <!doctype html>     <html>     <head>         <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>         <style>             html, body, #jsdos {                 width: 100%;                 height: 100%;                 margin: 0;                 padding: 0;             }         </style>         <script src="js-dos.js"></script>         <link href="js-dos.css" rel="stylesheet">     </head>     <body>     <div id="jsdos"/>     <script>         emulators.pathPrefix = "/";         Dos(document.getElementById("jsdos"))             .run("{{.Bundle}}");     </script>     </body>     </html>

В разделе 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-канале ?


ссылка на оригинал статьи https://habr.com/ru/articles/781902/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *