xesite/blog/dhall-kubernetes-2020-01-25...

434 lines
13 KiB
Markdown

---
title: Dhall for Kubernetes
date: 2020-01-25
tags:
- dhall
- kubernetes
- witchcraft
---
Kubernetes is a surprisingly complicated software package. Arguably, it has to
be that complicated as a result of the problems it solves being complicated; but
managing yaml configuration files for Kubernetes is a complicated task. [YAML][yaml]
doesn't have support for variables or type metadata. This means that the
validity (or sensibility) of a given Kubernetes configuration file (or files)
isn't easy to figure out without using a Kubernetes server.
[yaml]: https://yaml.org
In my [last post][cultk8s] about Kubernetes, I mentioned I had developed a tool
named [dyson][dyson] in order to help me manage Terraform as well as create
Kubernetes manifests from [a template][template]. This works for the majority of
my apps, but it is difficult to extend at this point for a few reasons:
[cultk8s]: https://christine.website/blog/the-cult-of-kubernetes-2019-09-07
[dyson]: https://github.com/Xe/within-terraform/tree/master/dyson
[template]: https://github.com/Xe/within-terraform/blob/master/dyson/src/dysonPkg/deployment_with_ingress.yaml
- It assumes that everything passed to it are already valid yaml terms
- It doesn't assert the type of any values passed to it
- It is difficult to add another container to a given deployment
- Environment variables implicitly depend on the presence of a private git repo
- It depends on the template being correct more than the output being correct
So, this won't scale. People in the community have created other solutions for
this like [Helm][helm], but a lot of them have some of the same basic problems.
Helm also assumes that your template is correct. [Kustomize][kustomize] does
help with a lot of the type-safe variable replacements, but it doesn't have the
ability to ensure your manifest is valid.
[helm]: https://helm.sh
[kustomize]: https://kustomize.io
I looked around for alternate solutions for a while and eventually found
[Dhall][dhall] thanks to a friend. Dhall is a _statically typed_ configuration
language. This means that you can ensure that inputs are _always_ the correct
type or the configuration file won't load. There's also a built-in
[dhall-to-yaml][dhallyaml] tool that can be used with the [Kubernetes
package][dhallk8s] in order to declare Kubernetes manifests in a type-safe way.
[dhall]: https://dhall-lang.org
[dhallyaml]: https://github.com/dhall-lang/dhall-haskell/tree/master/dhall-yaml#dhall-yaml
[dhallk8s]: https://github.com/dhall-lang/dhall-kubernetes
Here's a small example of Dhall and the yaml it generates:
```dhall
-- Mastodon usernames
[ { name = "Cadey", mastodon = "@cadey@mst3k.interlinked.me" }
, { name = "Nicole", mastodon = "@sharkgirl@mst3k.interlinked.me" }
]
```
Which produces:
```yaml
- mastodon: "@cadey@mst3k.interlinked.me"
name: Cadey
- mastodon: "@sharkgirl@mst3k.interlinked.me"
name: Nicole
```
Which is fine, but we still have the type-safety problem that you would have in
normal yaml. Dhall lets us define [record types][dhallrecord] for this data like
this:
[dhallrecord]: http://www.haskellforall.com/2020/01/dhall-year-in-review-2019-2020.html
```dhall
let User =
{ Type = { name : Text, mastodon : Optional Text }
, default = { name = "", mastodon = None }
}
let users =
[ User::{ name = "Cadey", mastodon = Some "@cadey@mst3k.interlinked.me" }
, User::{
, name = "Nicole"
, mastodon = Some "@sharkgirl@mst3k.interlinked.me"
}
]
in users
```
Which produces:
```yaml
- mastodon: "@cadey@mst3k.interlinked.me"
name: Cadey
- mastodon: "@sharkgirl@mst3k.interlinked.me"
name: Nicole
```
This is type-safe because you cannot add arbitrary fields to User instances
without the compiler rejecting it. Let's add an invalid "preferred_language"
field to Cadey's instance:
```
-- ...
let users =
[ User::{
, name = "Cadey"
, mastodon = Some "@cadey@mst3k.interlinked.me"
, preferred_language = "en-US"
}
-- ...
]
```
Which gives us:
```
$ dhall-to-yaml --file example.dhall
Error: Expression doesn't match annotation
{ + preferred_language : …
, …
}
4│ User::{ name = "Cadey", mastodon = Some "@cadey@mst3k.interlinked.me",
5│ preferred_language = "en-US" }
example.dhall:4:9
```
Or [this more detailed explanation][explanation] if you add the `--explain` flag
to the `dhall-to-yaml` call.
[explanation]: https://clbin.com/JtVWT
We tried to do something that violated the contract that the type specified.
This means that it's an invalid configuration and is therefore rejected and no
yaml file is created.
The Dhall Kubernetes package specifies record types for _every_ object available
by default in Kubernetes. This does mean that the package is incredibly large,
but it also makes sure that _everything_ you could possibly want to do in
Kubernetes matches what it expects. In the [package
documentation][k8sdhalldocs], they give an example where a
[Deployment][k8sdeployment] is created.
[k8sdhalldocs]: https://github.com/dhall-lang/dhall-kubernetes/tree/master/1.15#quickstart---a-simple-deployment
[k8sdeployment]: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/
``` dhall
-- examples/deploymentSimple.dhall
-- Importing other files is done by specifying the HTTPS URL/disk location of
-- the file. Attaching a sha256 hash (obtained with `dhall freeze`) allows
-- the Dhall compiler to cache these files and speed up configuration loads
-- drastically.
let kubernetes =
https://raw.githubusercontent.com/dhall-lang/dhall-kubernetes/1.15/master/package.dhall
sha256:4bd5939adb0a5fc83d76e0d69aa3c5a30bc1a5af8f9df515f44b6fc59a0a4815
let deployment =
kubernetes.Deployment::{
, metadata = kubernetes.ObjectMeta::{ name = "nginx" }
, spec =
Some
kubernetes.DeploymentSpec::{
, replicas = Some 2
, template =
kubernetes.PodTemplateSpec::{
, metadata = kubernetes.ObjectMeta::{ name = "nginx" }
, spec =
Some
kubernetes.PodSpec::{
, containers =
[ kubernetes.Container::{
, name = "nginx"
, image = Some "nginx:1.15.3"
, ports =
[ kubernetes.ContainerPort::{
, containerPort = 80
}
]
}
]
}
}
}
}
in deployment
```
Which creates the following yaml:
```
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
replicas: 2
template:
metadata:
name: nginx
spec:
containers:
- image: nginx:1.15.3
name: nginx
ports:
- containerPort: 80
```
Dhall's lambda functions can help you break this into manageable chunks. For
example, here's a Dhall function that helps create a docker image reference:
```
let formatImage
: Text -> Text -> Text
= \(repository : Text) -> \(tag : Text) ->
"${repository}:${tag}"
in formatImage "xena/christinewebsite" "latest"
```
Which outputs `xena/christinewebsite:latest` when passed to `dhall text`.
All of this adds up into a powerful toolset that lets you express Kubernetes
configuration in a way that does what you want without as many headaches.
Most of my apps on Kubernetes need only a few generic bits of configuration:
- Their name
- What port should be exposed
- The domain that this service should be exposed on
- How many replicas of the service are needed
- Which Let's Encrypt Issuer to use (currently only `"prod"` or `"staging"`)
- The [configuration variables of the service][12factorconfig]
- Any other containers that may be needed for the service
[12factorconfig]: https://12factor.net/config
From here, I defined all of the [bits and pieces][kubermemeshttp] for the
Kubernetes manifests that Dyson produces and then created a `Config` type that
helps to template them out. Here's my [`Config` type
definition][configdefinition]:
[kubermemeshttp]: https://tulpa.dev/cadey/kubermemes/src/branch/master/k8s/http
[configdefinition]: https://tulpa.dev/cadey/kubermemes/src/branch/master/k8s/app/config.dhall
```dhall
let kubernetes = ../kubernetes.dhall
in { Type =
{ name : Text
, appPort : Natural
, image : Text
, domain : Text
, replicas : Natural
, leIssuer : Text
, envVars : List kubernetes.EnvVar.Type
, otherContainers : List kubernetes.Container.Type
}
, default =
{ name = ""
, appPort = 5000
, image = ""
, domain = ""
, replicas = 1
, leIssuer = "staging"
, envVars = [] : List kubernetes.EnvVar.Type
, otherContainers = [] : List kubernetes.Container.Type
}
}
```
Then I defined a `makeApp` function that creates everything I need to deploy my
stuff on Kubernetes:
```dhall
let Prelude = ../Prelude.dhall
let kubernetes = ../kubernetes.dhall
let typesUnion = ../typesUnion.dhall
let deployment = ../http/deployment.dhall
let ingress = ../http/ingress.dhall
let service = ../http/service.dhall
let Config = ../app/config.dhall
let K8sList = ../app/list.dhall
let buildService =
\(config : Config.Type)
-> let myService = service config
let myDeployment = deployment config
let myIngress = ingress config
in K8sList::{
, items =
[ typesUnion.Service myService
, typesUnion.Deployment myDeployment
, typesUnion.Ingress myIngress
]
}
in buildService
```
And used it to deploy the [h language website][hlang]:
[hlang]: https://h.christine.website
```dhall
let makeApp = ../app/make.dhall
let Config = ../app/config.dhall
let cfg =
Config::{
, name = "hlang"
, appPort = 5000
, image = "xena/hlang:latest"
, domain = "h.christine.website"
, leIssuer = "prod"
}
in makeApp cfg
```
Which produces the following Kubernetes config:
```yaml
apiVersion: v1
items:
- apiVersion: v1
kind: Service
metadata:
annotations:
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/hostname: h.christine.website
external-dns.alpha.kubernetes.io/ttl: "120"
labels:
app: hlang
name: hlang
namespace: apps
spec:
ports:
- port: 5000
targetPort: 5000
selector:
app: hlang
type: ClusterIP
- apiVersion: apps/v1
kind: Deployment
metadata:
name: hlang
namespace: apps
spec:
replicas: 1
selector:
matchLabels:
app: hlang
template:
metadata:
labels:
app: hlang
name: hlang
spec:
containers:
- image: xena/hlang:latest
imagePullPolicy: Always
name: web
ports:
- containerPort: 5000
imagePullSecrets:
- name: regcred
- apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
annotations:
certmanager.k8s.io/cluster-issuer: letsencrypt-prod
kubernetes.io/ingress.class: nginx
labels:
app: hlang
name: hlang
namespace: apps
spec:
rules:
- host: h.christine.website
http:
paths:
- backend:
serviceName: hlang
servicePort: 5000
tls:
- hosts:
- h.christine.website
secretName: prod-certs-hlang
kind: List
```
And when I applied it on my Kubernetes cluster, it worked the first time and had
absolutely no effect on the existing configuration.
In the future, I hope to expand this to allow for multiple deployments (IE: a
chatbot running in a separate deployment than a web API the chatbot depends on
or non-web projects in general) as well as supporting multiple Kubernetes
namespaces.
Dhall is probably the most viable replacement to Helm or other Kubernetes
templating tools I have found in recent memory. I hope that it will be used by
more people to help with configuration management, but I can understand that
that may not happen. At least it works for me.
If you want to learn more about Dhall, I suggest checking out the following
links:
- [The Dhall Language homepage](https://dhall-lang.org)
- [Learn Dhall in Y Minutes](https://learnxinyminutes.com/docs/dhall/)
- [The Dhall Language GitHub Organization](https://github.com/dhall-lang)
I hope this was helpful and interesting. Be well.