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