blog.dsoderlund.consulting

cdks8s through ArgoCD

Automatic deployment of kubernetes manifests described by cdk8s

cdk8s is a tool from AWS to be able to deal with kubernetes manifests in an imperative way which for a lot of people accelerates their investment in learning about and using kubernetes. Though not perhaps inline with the declarative nature of traditional gitops approaches, it is not really that different from jsonnet, helm and/or kustomize which I think simplifies a lot in terms of expressing not only what we want deployed but also how to best manage changes to that state.

ArgoCD in short

Chances are if you are reading this you already know a bit about ArgoCD. It allows us to package kubernetes manifests in “applications” which helps to compartmentalize things that you are managing inside kubernetes as well as giving a cli and a UI in that abstraction level. For each application we can choose how we want ArgoCD to observe and synchronize the declared state of our application from git to kubernetes.

cdk8s in short

cdk8s and similar tools allows you to generate the kubernetes json or yaml in a way that is a bit more advanced than kustomize. Where as helm is a way to package apps for different types of consumption, and kustomize allows you to manipulate simple yaml further, cdk8s is a more heavy weight ground up yaml generation in different high level languages.

In my example I am generating a deployment and a service with typescript, while also adding some extra npm packages for string manipulation.

An example

// A typescript app that when run through `cdk8s synth` becomes a deployment and a service
import { Construct } from "constructs";
import { App, Chart, ChartProps } from "cdk8s";
import {
  IntOrString,
  KubeDeployment,
  KubeService,
  Quantity,
} from "./imports/k8s";
import { kebabCase } from "lodash";

export class MyChart extends Chart {
  constructor(
    scope: Construct,
    id: string,
    props: ChartProps = { disableResourceNameHashes: true }
  ) {
    super(scope, id, props);

    const label = {
      app: "cdk8s-demo",
      demo: kebabCase("knowledge sharing"),
    };
    new KubeDeployment(this, "deployment", {
      spec: {
        selector: { matchLabels: label },
        replicas: 1,
        template: {
          metadata: { labels: label },
          spec: {
            containers: [
              {
                name: "echoserver",
                image: "ealen/echo-server:latest",
                ports: [{ containerPort: 80 }],
                resources: {
                  limits: {
                    cpu: Quantity.fromString("0.5"),
                    memory: Quantity.fromString("256Mi"),
                  },
                  requests: {
                    cpu: Quantity.fromString("10m"),
                    memory: Quantity.fromString("10Mi"),
                  },
                },
              },
            ],
          },
        },
      },
    });
    new KubeService(this, "service", {
      spec: {
        type: "ClusterIP",
        ports: [{ port: 80, targetPort: IntOrString.fromNumber(80) }],
        selector: label,
      },
    });
  }
}

const app = new App();
new MyChart(app, "cdk8s-demo");
app.synth();

After cdk8s synth which in the repo would be run as npm run synth there will be a file in the dist folder that looks like this. It is ready for deployment with kubectl apply

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cdk8s-demo-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cdk8s-demo
      demo: knowledge-sharing
  template:
    metadata:
      labels:
        app: cdk8s-demo
        demo: knowledge-sharing
    spec:
      containers:
        - image: ealen/echo-server:latest
          name: echoserver
          ports:
            - containerPort: 80
          resources:
            limits:
              cpu: "0.5"
              memory: 256Mi
            requests:
              cpu: 10m
              memory: 10Mi
---
apiVersion: v1
kind: Service
metadata:
  name: cdk8s-demo-service
spec:
  ports:
    - port: 80
      targetPort: 80
  selector:
    app: cdk8s-demo
    demo: knowledge-sharing
  type: ClusterIP

Adding a cdk8s plugin to ArgoCD

While learning about cdk8s I was surprised to learn that it didn’t work out of the box with ArgoCD, and that I couldn’t find any pre-made plugins.

I did however come across this great post about cdk8s by Max Brenner and also their repository on how to run cdk8s in a container.

Dockerfile

From there I built my own version of the typescript container such that it would work without running as root which ArgoCD plugins are not allowed to do for good reason.

# docker.io/dsoderlund/cdk8s:typescript
FROM node:alpine

RUN yarn global add cdk8s-cli && yarn cache clean
RUN mkdir /files 
RUN mkdir /home/node/.npm-cache
RUN chown -R 999:0 /home/node/.npm-cache
WORKDIR /files

ADD entrypoint-typescript.sh /entrypoint.sh

ENV NPM_CONFIG_CACHE=/home/node/.npm-cache
ENTRYPOINT ["/entrypoint.sh"]

The entrypoint.sh script allows you to run this from the command line and get it to perform the steps needed to work with write back to a volume you mount to docker, in the case of the plugin we will override this command.

Plugging in as an argo-repo-server sidecar

So the idea here is that we want ArgoCD repository server to render the yaml for us by invoking cdk8s synth for an app just like it does for helm, kustomize, or plain yaml. ArgoCD should do this if it can tell that it is seeing a cdk8s-typescript style application. This will be evident by the presence of the file ./imports/k8s.ts.

The execution has three parts, init, command, and discover.

  • Init will run before anything else to make preparations. npm install will make sure the container has everything needed to perform the cdk8s synth step. This will be cached in the container and kept for future ArgoCD synchs of the app.

  • Command executes the synth, ignores any direct output, and then reads the resulting file(s) from the dist folder.

  • Discover helps repo-server know that this is infact a cdk8s-typescript style application and that this plugin applies.

There is a working example you can clone and run in my reference platform github repo.

This is an excerpt of that working example that highlights the parts that configures and injects the plugin.

# ... Removed for brevity, imagine an ArgoCD helm values declaration.
configs:
  cmp:
    create: true
    plugins:
      cdk8s-typescript:
        init:
          command: ["sh", "-c"]
          args:
            - >
              echo "init cdk8s-typescript" &&
              npm install
        generate:
          command: ["sh", "-c"]
          args:
            - >
              cdk8s synth > /dev/null &&
              cat dist/*
        discover:
          fileName: "./imports/k8s.ts"
repoServer:
  extraContainers:
    - name: cdk8s-typescript
      command:
        - "/var/run/argocd/argocd-cmp-server"
      image: docker.io/dsoderlund/cdk8s:typescript
      securityContext:
        runAsNonRoot: true
        runAsUser: 999
      volumeMounts:
        - mountPath: /tmp
          name: cmp-tmp
        - mountPath: /var/run/argocd
          name: var-files
        - mountPath: /home/argocd/cmp-server/plugins
          name: plugins
        - mountPath: /home/argocd/cmp-server/config/plugin.yaml
          name: argocd-cmp-cm
          subPath: cdk8s-typescript.yaml
  volumes:
    - name: argocd-cmp-cm
      configMap:
        name: argocd-cmp-cm
    - name: cmp-tmp
      emptyDir: {}

Results when using the plugin

Here is what the running app looks like through ArgoCD.

ArgoCD documentation and further reading

Here is the docs for how these config management plugins works. I also recommend reading this and this article which though using different approaches and a bit different end results than what I am after, do a great job in explaining what all the different parts are.

The point of all this

The act of making sure an application or piece of infrastructure is deployed as desired is the key of gitops, it should not have too strong oppinions on how to express that desired state as long as it can be rendered into clear, straightforward, nonambiguous yaml.

Thus if we are managing a gitops solution we can create a great experience in managing both applications and infrastructure with rich high level tools without sacrificing the reconciliation loop of kubernetes.