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

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