В этой статье мы разберёмся, как можно интегрировать 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-канале ????

Комментарии (3)


  1. RichardMerlock
    20.12.2023 15:47

    Экзешник с игрой схавает?


    1. hiGRND
      20.12.2023 15:47

      Steam накатить можно


  1. nsvk13
    20.12.2023 15:47

    Просто зачем? не понимаю..