Dhall — программируемый язык для создания конфигурационных файлов различного назначения. Это Open Source-проект, первый публичный релиз которого состоялся в 2018 году.
Как и всякий новый язык для генерации конфигурационных файлов, Dhall призван решить проблему ограниченной функциональности YAML, JSON, TOML, XML и других форматов конфигурации, но при этом оставаться достаточно простым. Язык распространяется всё шире. В 2020-м году представили его bindings, сделанные специально для Kubernetes.
Рассказывая о Dhall применительно к созданию K8s-манифестов, начнем все же с краткого общего описания.
Чем Dhall отличается от других языков
Авторы проекта предлагают рассматривать Dhall как продвинутый JSON: с функциями, типами, импортами. Зачем нужен новый формат, если уже есть проверенные?
Just because
Главный аргумент создателей: упомянутые JSON и YAML — не программируемые языки. Это сужает их возможности и порой приводит к неоптимальным решениям. Например, к повторениям.
Фокус на DRY
Хороший тон для разработчика — следовать правилу DRY («Don’t repeat yourself»). Когда работаешь с JSON и YAML, не повторять себя трудно. Из-за функциональной ограниченности в конфигурационных файлах часто приходится использовать повторяющиеся блоки конфигурации. Их нельзя упростить или отбросить.
Dhall позиционируется как язык, который помогает придерживаться принципа DRY. Там, где в JSON- или YAML-файл нужно вставить дополнительный блок кода, в Dhall можно подставить результат выполнения функции или значение переменной. В качестве иллюстрации в документации Dhall приводится пример, в котором сравниваются два конфигурационных файла, в JSON- и Dhall-формате соответственно. Каждый выполняет одну и ту же задачу: описывает место хранения публичного и приватного SSH-ключей пользователей.
Исходный JSON-файл:
[
{
"privateKey": "/home/john/.ssh/id_rsa",
"publicKey": "/home/john/.ssh/id_rsa.pub",
"user": "john"
},
{
"privateKey": "/home/jane/.ssh/id_rsa",
"publicKey": "/home/jane/.ssh/id_rsa.pub",
"user": "jane"
},
{
"privateKey": "/etc/jenkins/jenkins_rsa",
"publicKey": "/etc/jenkins/jenkins_rsa.pub",
"user": "jenkins"
},
{
"privateKey": "/home/chad/.ssh/id_rsa",
"publicKey": "/home/chad/.ssh/id_rsa.pub",
"user": "chad"
}
]
Та же конфигурация в Dhall-формате:
-- config0.dhall
let ordinaryUser =
\(user : Text) ->
let privateKey = "/home/${user}/.ssh/id_rsa"
let publicKey = "${privateKey}.pub"
in { privateKey, publicKey, user }
in [ ordinaryUser "john"
, ordinaryUser "jane"
, { privateKey = "/etc/jenkins/jenkins_rsa"
, publicKey = "/etc/jenkins/jenkins_rsa.pub"
, user = "jenkins"
}
, ordinaryUser "chad"
]
Пока по количеству строк файлы почти не отличаются.
Добавим нового пользователя — alice
. Для этого в JSON-файл нужно вставить дополнительный блок из 5 строк:
[
…
{
"privateKey": "/home/alice/.ssh/id_rsa",
"publicKey": "/home/alice/.ssh/id_rsa.pub",
"user": "alice"
}
]
При этом даже при простом копипасте можно ошибиться: например, скопировать конфиг из блока для chad, но в одном из полей не поменять имя на alice.
Для той же цели в Dhall-файл достаточно вызвать ещё раз ранее определенную функцию ordinaryUser
— это займет одну строку:
…
in [ ordinaryUser "john"
, ordinaryUser "jane"
, { privateKey = "/etc/jenkins/jenkins_rsa"
, publicKey = "/etc/jenkins/jenkins_rsa.pub"
, user = "jenkins"
}
, ordinaryUser "chad"
, ordinaryUser "alice" -- та самая новая строка
]
Чем сложнее конфигурационный файл, тем более очевидна негибкость JSON по сравнению с Dhall.
Быстрый экспорт в другие форматы
Для экспорта конфигурации в нужный формат достаточно одной команды. Вот, например, как превратить вышеприведенный Dhall-файл в JSON:
dhall-to-json --pretty <<< './config0.dhall'
По тому же принципу организован экспорт в YAML, XML, Bash. Да, это не ошибка: dhall-bash превращает инструкции на Dhall в Bash-скрипты, однако для этого поддерживается только ограниченное количество конструкций.
Акцент на безопасности
Dhall — программируемый язык, но при этом не тьюринг-полный. Авторы проекта говорят, что такое ограничение повышает безопасность кода и конфигурационных файлов, написанных на нем.
Некоторые инструменты для своих конфигураций используют существующие языки программирования. Например, webpack поддерживает для этого TypeScript (и не только), Django — Python, sbt — Scala и т. п. Однако обратная сторона такой гибкости и свободы — это возможные проблемы со вставками ненадежного кода, межсайтовым скриптингом (XSS), подделками запросов со стороны сервера (SSRF) и другими атаками. Dhall от этого защищен.
Dhall и Kubernetes
Ок, а что насчет применимости Dhall к генерации манифестов K8s?
Неудобство работы со множеством объектов в Kubernetes во многом обуcловлено особенностью дизайна YAML. Хотя YAML поддерживает минимальную шаблонизацию, прежде всего это формат для хранения конфигурации. Обойти его ограничения можно, например, с помощью Helm-шаблонов, но это не всегда просто и даже не всегда выполнимо (у нас была подробная статья на эту тему). Альтернатива — использовать другой, более гибкий язык для конфигурации, например Dhall (а некоторые другие примеры будут приведены ниже). Потому что это язык со встроенными шаблонами, которые в то же время не строго типизированы. Он, как отмечают создатели, предлагает простое описание конфигурации независимо от того, сколько абстракций создается.
Объекты Kubernetes можно генерировать с помощью выражений Dhall, а затем экспортировать их в YAML-формат утилитой dhall-to-yaml
. В публичном GitHub-репозитории dhall-kubernetes содержатся так называемые bindings — типы и функции Dhall, предназначенные для работы с объектами Kubernetes. Вот, например, как выглядит Dhall-конфигурация Deployment’а:
-- examples/deploymentSimple.dhall
let kubernetes =
https://raw.githubusercontent.com/dhall-lang/dhall-kubernetes/master/package.dhall sha256:532e110f424ea8a9f960a13b2ca54779ddcac5d5aa531f86d82f41f8f18d7ef1
let deployment =
kubernetes.Deployment::{
, metadata = kubernetes.ObjectMeta::{ name = Some "nginx" }
, spec = Some kubernetes.DeploymentSpec::{
, selector = kubernetes.LabelSelector::{
, matchLabels = Some (toMap { name = "nginx" })
}
, replicas = Some +2
, template = kubernetes.PodTemplateSpec::{
, metadata = Some kubernetes.ObjectMeta::{ name = Some "nginx" }
, spec = Some kubernetes.PodSpec::{
, containers =
[ kubernetes.Container::{
, name = "nginx"
, image = Some "nginx:1.15.3"
, ports = Some
[ kubernetes.ContainerPort::{ containerPort = +80 } ]
}
]
}
}
}
}
in deployment
При его экспорте в привычный YAML-формат с помощью dhall-to-yaml
получится следующее:
## examples/out/deploymentSimple.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
replicas: 2
selector:
matchLabels:
name: nginx
template:
metadata:
name: nginx
spec:
containers:
- image: nginx:1.15.3
name: nginx
ports:
- containerPort: 80
Говоря о «модульности» Dhall, создатели языка рассматривают случай, когда нужно определить: а) некоторый тип MyService
с настройками для разных deployment’ов, б) функции, которые можно применять к MyService
, чтобы создавать объекты для K8s. Это удобно, потому что позволяет определять сервисы не только в контексте Kubernetes и переиспользовать абстракции для работы с другими типами конфигурационных файлов. При этом принцип DRY остается в силе: чтобы внести небольшое изменение в конфигурации нескольких объектов, чаще всего достаточно изменить функцию в одном Dhall-файле — вместо того, чтобы руками править все связанные YAML’ы.
Пример такой «модульности» Dhall — конфигурация контроллера Nginx Ingress, который настраивает TLS-сертификаты и маршруты для нескольких сервисов:
-- examples/ingress.dhall
let Prelude =
../Prelude.dhall sha256:10db3c919c25e9046833df897a8ffe2701dc390fa0893d958c3430524be5a43e
let map = Prelude.List.map
let kubernetes =
https://raw.githubusercontent.com/dhall-lang/dhall-kubernetes/master/package.dhall sha256:532e110f424ea8a9f960a13b2ca54779ddcac5d5aa531f86d82f41f8f18d7ef1
let Service = { name : Text, host : Text, version : Text }
let services = [ { name = "foo", host = "foo.example.com", version = "2.3" } ]
let makeTLS
: Service → kubernetes.IngressTLS.Type
= λ(service : Service) →
{ hosts = Some [ service.host ]
, secretName = Some "${service.name}-certificate"
}
let makeRule
: Service → kubernetes.IngressRule.Type
= λ(service : Service) →
{ host = Some service.host
, http = Some
{ paths =
[ { backend =
{ serviceName = service.name
, servicePort = kubernetes.IntOrString.Int +80
}
, path = None Text
}
]
}
}
let mkIngress
: List Service → kubernetes.Ingress.Type
= λ(inputServices : List Service) →
let annotations =
toMap
{ `kubernetes.io/ingress.class` = "nginx"
, `kubernetes.io/ingress.allow-http` = "false"
}
let defaultService =
{ name = "default"
, host = "default.example.com"
, version = " 1.0"
}
let ingressServices = inputServices # [ defaultService ]
let spec =
kubernetes.IngressSpec::{
, tls = Some
( map
Service
kubernetes.IngressTLS.Type
makeTLS
ingressServices
)
, rules = Some
( map
Service
kubernetes.IngressRule.Type
makeRule
ingressServices
)
}
in kubernetes.Ingress::{
, metadata = kubernetes.ObjectMeta::{
, name = Some "nginx"
, annotations = Some annotations
}
, spec = Some spec
}
in mkIngress services
Результат экспорта dhall-to-yaml
:
## examples/out/ingress.yaml
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
annotations:
kubernetes.io/ingress.allow-http: 'false'
kubernetes.io/ingress.class: nginx
name: nginx
spec:
rules:
- host: foo.example.com
http:
paths:
- backend:
serviceName: foo
servicePort: 80
- host: default.example.com
http:
paths:
- backend:
serviceName: default
servicePort: 80
tls:
- hosts:
- foo.example.com
secretName: foo-certificate
- hosts:
- default.example.com
secretName: default-certificate
Здесь определенная функция services
была вызвана дважды: с параметрами, указанными в defaultService
(с хостом default.example.com
), и переданными вручную значениями (с хостом foo.example.com
). Таким образом, в итоговом манифесте получаем ресурс Ingress с двумя этими хостами.
Примеры использования в сообществе
На конференции OSDNConf 2021 Олег Николин из Portside рассказал, как Dhall помог его инженерной команде. После перехода на Kubernetes и усложнения CI/CD-процесса количество YAML-конфигураций, используемых в компании, выросло до 12 тысяч. Если нужно было добавлять новую переменную в один из сервисов, приходилось вносить изменения в 40 файлов, которые лежали в разных репозиториях. Проводить ревью кода было очень сложно. Проблемы накапливались, но при этом проявлялись не всегда сразу после деплоя. Если сервис некоторое время работал с некорректной конфигурацией, отследить исходную причину проблемы было тяжело.
После перехода на Dhall и рефакторинга команда избавилась от 50% ненужной конфигурации. Dhall повысил безопасность CI/CD: стало легче проверять манифесты K8s и переменные окружения до деплоя. Также появилась общая библиотека с описанием всех используемых ресурсов K8s. Всё это упростило работу DevOps-инженеров и проверку корректности конфигураций.
Другой интересный пример того, как Dhall упрощает работу с YAML-файлами, приводит Christine Dodrill из Tailscale. Она хотела упростить проверку конфигурационных файлов K8s на предмет их корректности. С этим ей не помогали ни Helm, ни Kustomize — в отличие от Dhall. И она пришла к такому выводу: «Dhall, вероятно, наиболее жизнеспособная замена Helm или другим инструментам для создания манифестов Kubernetes».
Альтернативы для создания манифестов
Да, кроме Dhall есть и другие фреймворки и языки, с которыми можно обойти ограничения YAML, сделать работу с манифестами в Kubernetes более удобной. Примеры Open Source-проектов:
Cue. Язык с широким набором инструментов для определения, генерации и валидации конфигурационных файлов, API, схем баз данных и других типов данных.
Jsonnet. Язык для создания шаблонов конфигураций. Как видно из названия, jsonnet — комбинация JSON и sonnet. Язык во многом похож на Cue.
jk. Шаблонизатор для написании структурированных конфигурационных файлов, включая манифесты K8s.
HCL. Язык, разработанный в HashiCorp. У HCL есть собственный «человекоориентированный» синтаксис, а также вариант на основе JSON, адаптированный для машинной обработки.
cdk8s. Фреймворк для «программирования» Kubernetes-манифестов на JavaScript, Java, TypeScript и Python. Обзор по нему мы недавно публиковали.
Критика Dhall
Хотя синтаксис Dhall несложный, а варианты использования языка подробно описаны в документации, кому-то он может показаться трудным для изучения. Чтобы освоить Dhall более или менее быстро, нужен хотя бы базовый опыт работы с программируемыми языками.
Некоторые согласны с тем, что у существующих языков для создания конфигураций есть проблемы с гибкостью, но не согласны с решением, которое предлагает Dhall. «Привычным языкам не хватает полезных инженерных свойств, — говорит Andy Chu, программист и создатель Oil Shell, — но зато у них нет и побочных эффектов. И Dhall не избавлен от этих эффектов». Ему вторит сотрудник Earthly Adam Gordon Bell который считает, что «Dhall странный» — даже более странный, чем HCL, а ведь последний лучше известен в сообществе.
Резюме
Несмотря на свою относительную новизну, Dhall — это достаточно зрелый фреймворк, который развивается с учетом отзывов и пожеланий сообщества. У проекта 3300+ звезд на GitHub, уверенная база контрибьюторов и регулярные релизы. Dhall, по меньшей мере, достоин рассмотрения как один из вариантов для случаев, когда простых манифестов и их шаблонов перестает хватать.
Язык применяется в production рядом компаний; в частности, среди пользователей, которые используют Dhall для создания и управления манифестами Kubernetes, упоминаются KSF Media, Earnest Research и IOHK.
P.S.
Читайте также в нашем блоге:
«Обзор фреймворка cdk8s для программирования Kubernetes-манифестов»;
«Продвинутая Helm-шаблонизация: выжимаем максимум».