Kubernetes: No YAML, please!?

Kubernetes: No YAML, please!?

Are there any alternatives to k8s YAML manifests?

YAML, or how i like to call it: the Marmite of data-serialization.

grafik.png

Marmites slogan "You either love it or hate it", fits so perfectly to our relationship with YAML. Particular in the Kubernetes world, where all the Kubernetes resource manifest are written in YAML. Whatever your position is, I thought about what other possibities we have to describe our workload for Kubernetes.

I want to see how it is to express the abstraction’s API using strong-typed data types and do all that stuff that we are used to do.

I looked around and found three approaches to not use YAML. All of them with their own twist.

I will use only Golang as the language in the demo code. But expect of naml you can choose also choose the development language of your preference.

Podtato-head

We will deploy the 📨🚚 CNCF App Delivery SIG Demo podtato-head

Podtato-head demonstrates cloud-native application delivery scenarios using many different tools and services. It is intended to help application delivery support teams test and decide which mechanism(s) to use.

grafik.png

I will translate this manifest into code -> github.com/podtato-head/podtato-head/blob/m... So we have a good reference to compare to.

Lets bring the contestants in.

grafik.png

Pulumi

grafik.png

Pulumi is an open source infrastructure as code tool for creating, deploying, and managing cloud infrastructure. Pulumi works with traditional infrastructure like VMs, networks, and databases, in addition to modern architectures, including containers, Kubernetes clusters, and serverless functions.

Starting with Pulumi is so easy, we just need to download the CLI and run following commands:

pulumi new

And chose the kubernetes-go template. Than we can start to build our deployments inside our code.

package main

import (
    "fmt"
    appsv1 "github.com/pulumi/pulumi-kubernetes/sdk/v3/go/kubernetes/apps/v1"
    corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v3/go/kubernetes/core/v1"
    metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v3/go/kubernetes/meta/v1"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func buildPodtatoHeadComponent(ctx *pulumi.Context, ns *corev1.Namespace, appName pulumi.StringMap, componentName,
    imageVersion string, servicePort int, serviceType string) error {

    componentLabel := pulumi.StringMap{
        "component": pulumi.String(componentName),
    }

    _, err := appsv1.NewDeployment(ctx, componentName, &appsv1.DeploymentArgs{
        Metadata: &metav1.ObjectMetaArgs{
            Name:      pulumi.String(componentName),
            Namespace: ns.Metadata.Name(),
            Labels:    appName,
        },
        Spec: appsv1.DeploymentSpecArgs{
            Selector: &metav1.LabelSelectorArgs{
                MatchLabels: componentLabel,
            },
            Template: &corev1.PodTemplateSpecArgs{
                Metadata: &metav1.ObjectMetaArgs{
                    Labels: componentLabel,
                },
                Spec: &corev1.PodSpecArgs{
                    TerminationGracePeriodSeconds: pulumi.Int(5),
                    Containers: &corev1.ContainerArray{
                        &corev1.ContainerArgs{
                            Name:            pulumi.String("server"),
                            Image:           pulumi.String(fmt.Sprintf("ghcr.io/podtato-head/%s:%s", componentName, imageVersion)),
                            ImagePullPolicy: pulumi.String("Always"),
                            Ports: &corev1.ContainerPortArray{
                                &corev1.ContainerPortArgs{
                                    ContainerPort: pulumi.Int(9000),
                                },
                            },
                            Env: &corev1.EnvVarArray{
                                &corev1.EnvVarArgs{
                                    Name:  pulumi.String("PORT"),
                                    Value: pulumi.String("9000"),
                                },
                            },
                        }},
                },
            },
        },
    })
    if err != nil {
        return err
    }

    _, err = corev1.NewService(ctx, componentName, &corev1.ServiceArgs{
        Metadata: &metav1.ObjectMetaArgs{
            Name:      pulumi.String(componentName),
            Namespace: ns.Metadata.Name(),
            Labels:    appName,
        },
        Spec: &corev1.ServiceSpecArgs{
            Selector: componentLabel,
            Ports: &corev1.ServicePortArray{
                &corev1.ServicePortArgs{
                    Name:       pulumi.String("http"),
                    Port:       pulumi.Int(servicePort),
                    Protocol:   pulumi.String("TCP"),
                    TargetPort: pulumi.Int(9000),
                },
            },
            Type: pulumi.String(serviceType),
        },
    })
    if err != nil {
        return err
    }
    return nil
}

<snip ...>

func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {
        namespace, err := corev1.NewNamespace(ctx, "podtato-kubectl", &corev1.NamespaceArgs{
            Metadata: &metav1.ObjectMetaArgs{
                Name: pulumi.String("podtato-kubectl"),
            },
        })
        if err != nil {
            return err
        }
        appLabels := pulumi.StringMap{
            "app": pulumi.String("podtato-head"),
        }

        for _, podtatoPart := range podtatoHead.PodtatoParts {
            err = buildPodtatoHeadComponent(ctx, namespace, appLabels, podtatoPart.PartName,
                podtatoPart.ImageVersion, podtatoPart.ServicePort, podtatoPart.ServiceType)
            if err != nil {
                return err
            }
        }
        return nil
    })
}

After you wrote your Kubernetes deployment you can "deploy" it with just typing:

pulumi up

Pulumi will compare your deployment now against the Kubernetes cluster and will print you out everything what will be modified. This is a huge ddvantage of pulumi.

You get a State handling and can detect any configuration drifts and can re-apply the deployment.

On top has pulumi "CrossGuard" which is their take on the Policy as Code. With CrossGuard you can set guardrails to enforce compliance for resources so developers within your organization can deploy their apps while sticking to best practices and security compliance.

naml

Not Another Markup Language or short naml is a Go library and command line tool that can be used as a framework to develop and deploy Kubernetes applications.

All you need to do is create a go project and add the naml libaries to your go.mod. Plus you need to implement the Deployable Interface

// Deployable is an interface that can be implemented
// for deployable applications.
type Deployable interface {

    // Install will attempt to install in Kubernetes
    Install(client *kubernetes.Clientset) error

    // Uninstall will attempt to uninstall in Kubernetes
    Uninstall(client *kubernetes.Clientset) error

    // Meta returns the Kubernetes native ObjectMeta which is used to manage applications with naml.
    Meta() *metav1.ObjectMeta

    // Description returns the application description
    Description() string

    // Objects will return the runtime objects defined for each application
    Objects() []runtime.Object
}

In our example the podtato app will look like this in naml

package podtato

import (
    "context"
    "fmt"
    appsv1 "k8s.io/api/apps/v1"
    v1 "k8s.io/api/apps/v1"
    apiv1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/util/intstr"
    "k8s.io/client-go/kubernetes"
)

var Version = "0.0.1"

type PodtatoHeadApp struct {
    name         string
    description  string
    objects      []runtime.Object
    podtatoParts []podtatoParts
}

type podtatoParts struct {
    PartName     string
    ImageVersion string
    ServicePort  int
    ServiceType  apiv1.ServiceType
}

func NewPodtatoHeadApp() *PodtatoHeadApp {
    return &PodtatoHeadApp{
        name:        "podtato-kubectl",
        description: "📨🚚 CNCF App Delivery SIG Demo",
        ... snip
        },
    }
}

func (p *PodtatoHeadApp) Install(client *kubernetes.Clientset) error {
    ctx := context.Background()
    namespace := &apiv1.Namespace{
        ObjectMeta: metav1.ObjectMeta{
            Name: p.name,
        },
    }
    ns, err := client.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{})
    if err != nil {
        return fmt.Errorf("unable to install namespace in Kubernetes: %v", err)
    }
    p.objects = append(p.objects, ns)
    appLabels := map[string]string{"app": "podtato-head"}

    err = p.buildPodtatoHeadComponent(ctx, client, appLabels)
    if err != nil {
        return err
    }

    return nil
}

func (p *PodtatoHeadApp) Uninstall(client *kubernetes.Clientset) error {
    ctx := context.Background()
    err := client.CoreV1().Namespaces().Delete(ctx, p.name, metav1.DeleteOptions{})
    if err != nil {
        return err
    }
    return nil
}

func (p *PodtatoHeadApp) Meta() *metav1.ObjectMeta {
    return &metav1.ObjectMeta{
        Name: p.name,
    }
}

func (p *PodtatoHeadApp) Description() string {
    return p.description
}

func (p *PodtatoHeadApp) Objects() []runtime.Object {
    return p.objects
}

func (p *PodtatoHeadApp) buildPodtatoHeadComponent(ctx context.Context, client *kubernetes.Clientset, appName map[string]string) error {

    for _, podtatoPart := range p.podtatoParts {
        componentLables := map[string]string{"component": podtatoPart.PartName}
        terminationGracePeriodSeconds := int64(5)
        deployment := &v1.Deployment{
            ObjectMeta: metav1.ObjectMeta{
                Name:      podtatoPart.PartName,
                Namespace: p.name,
                Labels:    appName,
            },
            Spec: appsv1.DeploymentSpec{
                Selector: &metav1.LabelSelector{
                    MatchLabels: componentLables,
                },
                Template: apiv1.PodTemplateSpec{
                    ObjectMeta: metav1.ObjectMeta{
                        Labels: componentLables,
                    },
                    Spec: apiv1.PodSpec{
                        TerminationGracePeriodSeconds: &terminationGracePeriodSeconds,
                        Containers: []apiv1.Container{
                            {
                                Name:            "server",
                                Image:           fmt.Sprintf("ghcr.io/podtato-head/%s:%s", podtatoPart.PartName, podtatoPart.ImageVersion),
                                ImagePullPolicy: apiv1.PullAlways,
                                Ports: []apiv1.ContainerPort{
                                    {
                                        ContainerPort: 9000,
                                    },
                                },
                                Env: []apiv1.EnvVar{
                                    {
                                        Name:  "PORT",
                                        Value: "9000",
                                    },
                                },
                            },
                        },
                    },
                },
            },
        }
        _, err := client.AppsV1().Deployments(p.name).Create(ctx, deployment, metav1.CreateOptions{})
        if err != nil {
            return fmt.Errorf("unable to install deployment in Kubernetes: %v", err)
        }
        p.objects = append(p.objects, deployment)

        service := &apiv1.Service{
            ObjectMeta: metav1.ObjectMeta{
                Name:      podtatoPart.PartName,
                Namespace: p.name,
                Labels:    appName,
            },
            Spec: apiv1.ServiceSpec{
                Selector: componentLables,
                Ports: []apiv1.ServicePort{
                    {
                        Name:       "http",
                        Port:       int32(podtatoPart.ServicePort),
                        Protocol:   apiv1.ProtocolTCP,
                        TargetPort: intstr.FromInt(9000),
                    },
                },
                Type: podtatoPart.ServiceType,
            },
        }
        _, err = client.CoreV1().Services(p.name).Create(ctx, service, metav1.CreateOptions{})
        if err != nil {
            return fmt.Errorf("unable to install service in Kubernetes: %v", err)
        }
        p.objects = append(p.objects, service)
    }
    return nil
}

Now you can compile this in to binary and deploy / undeploy it with the cli command:

<yourapp> install

or

<yourapp> uninstall

CDK8s

grafik.png

cdk8s is an open-source software development framework for defining Kubernetes applications and reusable abstractions using familiar programming languages and rich object-oriented APIs.

And now comes the fun part: cdk8s apps synthesize into standard Kubernetes manifests which can be applied to any Kubernetes cluster.

To get started you just need to install the cli

npm install -g cdk8s-cli #for the 1.0.0-beta

brew install cdk8s # for the 0.33.0

And then create your project with the language template you want to use. At the momend CDK8s supports following languages:

  • TypeScript
  • Python
  • Java
  • Go
mkdir podtato-head-cdk8s
cd podtato-head-cdk8s
cdk8s init go-app
cdk8s import
cdk8s synth
kubectl apply -f dist/podtato-head-cdk8s.k8s.yaml

One importanc concept of CDK8s is the abstraction through constructs

Constructs are the basic building block of CDK8s. They are the instrument that enables composition and creation of higher-level abstractions through normal object-oriented classes.

So with this in mind, my CDK8s go project looks like this:

package podtato

import (
    "fmt"
    "github.com/aws/constructs-go/constructs/v3"
    "github.com/aws/jsii-runtime-go"
    "github.com/cdk8s-team/cdk8s-core-go/cdk8s"
    "github.com/dirien/podtato-head-cdk8s/imports/k8s"
)

type PodtatoHeadProps struct {
    cdk8s.ChartProps
    PodtatoParts []PodtatoParts
}

type PodtatoParts struct {
    PartName     string
    ImageVersion string
    ServicePort  int
    ServiceType  string
}

func buildPodtatoHeadComponent(scope constructs.Construct, ns k8s.KubeNamespace, appName map[string]*string, componentName,
    imageVersion string, servicePort int, serviceType string) {
    componentLabel := map[string]*string{"component": jsii.String(componentName)}

    k8s.NewKubeDeployment(scope, jsii.String(fmt.Sprintf("%s-depl", componentName)), &k8s.KubeDeploymentProps{
        Metadata: &k8s.ObjectMeta{
            Name:      jsii.String(componentName),
            Namespace: ns.Metadata().Name(),
            Labels:    &appName,
        },
        Spec: &k8s.DeploymentSpec{
            Selector: &k8s.LabelSelector{
                MatchLabels: &componentLabel,
            },
            Template: &k8s.PodTemplateSpec{
                Metadata: &k8s.ObjectMeta{
                    Labels: &componentLabel,
                },
                Spec: &k8s.PodSpec{
                    TerminationGracePeriodSeconds: jsii.Number(5),
                    Containers: &[]*k8s.Container{
                        {
                            Name:            jsii.String("server"),
                            Image:           jsii.String(fmt.Sprintf("ghcr.io/podtato-head/%s:%s", componentName, imageVersion)),
                            ImagePullPolicy: jsii.String("Always"),
                            Ports: &[]*k8s.ContainerPort{
                                {
                                    ContainerPort: jsii.Number(9000),
                                },
                            },
                            Env: &[]*k8s.EnvVar{
                                {
                                    Name:  jsii.String("PORT"),
                                    Value: jsii.String("9000"),
                                },
                            },
                        },
                    },
                },
            },
        },
    })

    k8s.NewKubeService(scope, jsii.String(fmt.Sprintf("%s-svc", componentName)), &k8s.KubeServiceProps{
        Metadata: &k8s.ObjectMeta{
            Name:      jsii.String(componentName),
            Namespace: ns.Metadata().Name(),
            Labels:    &appName,
        },
        Spec: &k8s.ServiceSpec{
            Selector: &componentLabel,
            Ports: &[]*k8s.ServicePort{
                {
                    Name:       jsii.String("http"),
                    Port:       jsii.Number(float64(servicePort)),
                    Protocol:   jsii.String("TCP"),
                    TargetPort: k8s.IntOrString_FromNumber(jsii.Number(float64(9000))),
                },
            },
            Type: jsii.String(serviceType),
        },
    })
}

func PodtatoHeadChart(scope constructs.Construct, id string, props *PodtatoHeadProps) cdk8s.Chart {
    var cprops cdk8s.ChartProps
    if props != nil {
        cprops = props.ChartProps
    }
    chart := cdk8s.NewChart(scope, jsii.String(id), &cprops)

    appLabels := map[string]*string{"app": jsii.String("podtato-head")}

    namespace := k8s.NewKubeNamespace(chart, jsii.String("podtato-kubectl"), &k8s.KubeNamespaceProps{
        Metadata: &k8s.ObjectMeta{
            Name: jsii.String("podtato-kubectl"),
        },
    })
    for _, podtatoPart := range props.PodtatoParts {
        buildPodtatoHeadComponent(chart, namespace, appLabels, podtatoPart.PartName,
            podtatoPart.ImageVersion, podtatoPart.ServicePort, podtatoPart.ServiceType)
    }

    return chart
}

TL;DR

I would give the whole no-yaml way of defining our workload a try. Find out what fits the best inside your team/project or even whole company. It helps us to accelerate the whole shift left movement in embeding this subjects into a domain we are already very well in.

Le code -> github.com/dirien/no-yaml