Paul BecotteAdmin

Combining Jenkins With Earthbuild and a Shared Buildkit Daemon

At my day job, we have been using EarthBuild (a fork of Earthly, which has been discontinued) to run the majority of our CI/CD workflows. We are responsible for hundreds of build pipelines running on our shared infrastructure. While this tool has a significant learning curve, it addresses a number of issues that I have seen numerous times as someone who is the "ci/cd expert"

  • Allows "shared functions" so that things like "use artifactory to install apt packages" or "install the company CA certs in the docker image" are easy to do correctly, instead of a shared image that never gets updated or copy and pasting the same 30 lines of code everywhere

  • Much better management of secrets in docker builds

  • The ability to share caches between executions of builds, or even between projects

  • A syntax to make modular Dockerfiles much easier to work with

  • The ability to run end-to-end integration tests entirely within the tool, so the same command can work both locally and in CI

We have learned a lot and written some common code that we believe makes the whole process much smoother.

Jenkins Pipelines in Kubernetes

We run all our Jenkins pipelines in Kubernetes. Instead of provisioning each build pod with tons of CPU and RAM, or building bespoke images for every project, we now use a shared pod spec. That pod spec includes:

  • A Jenkins agent

  • A Docker daemon (running in a sidecar)

  • The Earthbuild CLI

It was easy to wrap this into a Jenkins shared library function. It:

  1. Sets up the pod spec

  2. Configures Earthbuild to point at our shared BuildKit daemon

  3. Sets up credentials and other per-job config

This has saved us a ton of boilerplate. A lot of Jenkinsfiles would bake down to single line earthly commands in each stage.

def call(Closure body) {
    pod {
        creds {
            doCheckout {
                sh(script: '''
                    echo "${ARTIFACTORY_PSW}" | docker login -u "${ARTIFACTORY_USR}" --password-stdin "$BASE_REGISTRY"
                ''', returnStdout: true)
                
                echo "BuildKit Pod List:\n${env.POD_LIST}"
            
                // Use a hash of the Jenkins job-name to assign the build to a specific buildkit
                // instance
                script {
                    def podList = env.POD_LIST.split()
                    def jobName = env.JOB_NAME ?: "unknown-job"
                    def hash = jobName.hashCode() & 0x7FFFFFFF  // Ensure positive integer
                    def idx = hash % podList.size()
                    def selectedPod = podList[idx]
                    echo "Selected pod: ${selectedPod}"
                    env.BUILDKIT_POD = selectedPod
                }

                sh(script: """
                    earthly account logout
                    earthly config git "{our.bitbucket.com: {auth: 'ssh', port: 7999, user: 'git', strict_host_key_checking : false}}"
                    earthly config global.buildkit_host tcp://${env.BUILDKIT_POD}.buildkit.buildkit.svc.cluster.local:8372
                    earthly config global.tls_enabled false
                """, returnStdout: true)
                withNewSpan(label: 'earthly-step'){
                    body()
                }
            }
        }
    }
}

// Jenkins sets these variables, so we can use them
// in things like versioning schemes or sonarqube scans
def doCheckout(Closure body) {
    def scmVars
    scmVars = checkout scm
    vars = ["BRANCH_NAME=${env.BRANCH_NAME}"]
    if ( env.CHANGE_BRANCH ) {
        vars << "CHANGE_BRANCH=${env.CHANGE_BRANCH}"
        vars << "CHANGE_TARGET=${env.CHANGE_TARGET}"
        vars << "CHANGE_ID=${env.CHANGE_ID}"
    }
    withEnv([
        "GIT_COMMIT=${scmVars.GIT_COMMIT}",
        "EARTHLY_BUILD_ARGS=${vars.join(',')}",
    ]) {
        body()
    }
}

def pod(Closure body) {
    podTemplate(
        yamlMergeStrategy: merge(),
        yaml: buildTemplate(),
        showRawYaml: false
    ) {
        node(POD_LABEL) {
           container('netshoot') {
              script {
                  def result = sh(
                      script: '''
                          nslookup -type=SRV buildkit.buildkit.svc.cluster.local \
                          | awk '/service =/ { print $NF }' \
                          | sed 's/\\.buildkit\\.buildkit\\.svc\\.cluster\\.local\\.$//' \
                          | sort -u
                      ''',
                      returnStdout: true
                  ).trim()
                  // Save to environment variable for later stages
                  env.POD_LIST = result
              }
            }
            container("docker") {
                body()
            }
        }
    }
}

def creds(Closure body) {
    sshagent(['bitbucket-ssh-credentials']) {
        withCredentials([
             usernamePassword(credentialsId: "artifactory", passwordVariable: "ARTIFACTORY_PSW", usernameVariable: "ARTIFACTORY_USR"),
             usernamePassword(credentialsId: 'bitbucket', usernameVariable: 'BITBUCKET_USR', passwordVariable: 'BITBUCKET_PSW'),
             string(credentialsId: 'sonarqube_token', variable: 'SONAR_TOKEN'),
        ]) {

          secret_names = [
				"ARTIFACTORY_USR",
				"ARTIFACTORY_PSW",
				"BITBUCKET_USR",
				"BITBUCKET_PSW",
				"SONAR_TOKEN",
			]
          existing_secrets = env.EARTHLY_SECRETS ? env.EARTHLY_SECRETS.split(",").collect { it.trim() }.toSet() : [] as Set

          for (name in secret_names) {
            existing_secrets.add(name)
          }
          earthy_secrets = existing_secrets.join(",")

      withEnv([
			 "EARTHLY_SECRETS=$earthy_secrets"
      ]){
        secretFiles {
              body()
             }
          }
       }
    }
}

// We mount a kubernetes netrc file in the pods for artifactory credentials
// most of our shared earthfiles use it to install packages
def secretFiles(Closure body) {
    withEnv([
        'EARTHLY_SECRET_FILES=netrc=/credentials/netrc',
    ]) {
        body()
    }
}


def buildTemplate() {
    return """
    apiVersion: v1
    kind: Pod
    spec:
      containers:
        - name: jnlp
          securityContext:
            runAsUser: 0
        - name: docker
          image: earthly/earthly:v0.8.15
          imagePullPolicy: IfNotPresent
          command:
            - cat
          tty: true
          securityContext:
            runAsUser: 0
          env:
            - name: DOCKER_HOST
              value: 'tcp://localhost:2376'
            - name: DOCKER_TLS_VERIFY
              value: '1'
            - name: DOCKER_CERT_PATH
              value: /certs/client
          volumeMounts:
            - name: dind-certs
              mountPath: /certs
            - name: art-saas-creds
              mountPath: "/credentials"
        - name: dind
          image: docker:24.0.5-dind
          imagePullPolicy: IfNotPresent
          securityContext:
            privileged: true
            runAsUser: 0
          args:
            - '--experimental'
          resources:
            limits:
              memory: 4Gi
            requests:
              cpu: 2000m
              memory: 4Gi
          volumeMounts:
            - name: dind-storage
              mountPath: /var/lib/docker
            - name: dind-certs
              mountPath: /certs
        - name: netshoot
          image: mktxv-docker-prod-virtual.artifacts.tools.marketaxess.com/nicolaka/netshoot
          imagePullPolicy: IfNotPresent
          command:
            - cat
          tty: true
          securityContext:
            runAsUser: 0
      volumes:
        - name: dind-storage
          emptyDir: {}
        - name: dind-certs
          emptyDir: {}
        - name: art-saas-creds
          secret:
            secretName: artifactory-saas-netrc
            items:
            - key: netrc
              path: netrc
  """
}

Disk IOPS Issues

The biggest challenge with BuildKit wasn't CPU or memory—it was disk.

BuildKit is extremely disk-intensive, especially when used in parallel across multiple builds. Our Kubernetes cluster runs on AWS, and our first attempt used EBS-backed PVCs to store the BuildKit state.

The result? IOPS starvation. Towards the end of the day when our cluster got extra busy (everyone trying to run get their PR builds in for the 3pm QA deploy), builds started really starting to drag out. This was evidenced by very long "pauses" in the build output, where steps seemed to be doing nothing in particular for long periods. While troubleshooting, AWS EBS volumes have a metric for throughput/IOPS throttle, and we could see this metric was high while we were having issues.

In a past life, the IOPS of EBS volumes was automatically tied to the size of the disk, so I had learned to always make the disks large. However, modern volume types give you more control. To fix this, we provisioned dedicated gp3 volumes ahead of time with the maximum throughput and IOPS supported (16,000 IOPS and 1,000 MB/s), then bound them explicitly to PVCs. BuildKit performance improved dramatically after that. We actually found that this change made much more difference than adding more CPU or RAM to the container, or even scaling to multiple buildkit instances.

Full Terraform Example

We use the following Terraform to provision BuildKit in Kubernetes. This includes:

  • A high-throughput StorageClass

  • Pre-provisioned EBS volumes and bound PVs/PVCs

  • A BuildKit StatefulSet

  • A headless Service for gRPC communication

resource "kubernetes_namespace" "buildkit" {
  metadata {
    name = "buildkit"
  }
}

# This was needed because the docker image hardcodes the buildkit network IP
# range, and it happened to be the same as my eks VPC, so we provided
# a custom copy. We also experimented with changing the garbage collection
# settings this way, but didn't find any that made a big difference
resource "kubernetes_config_map" "docker_wrapper" {
  metadata {
    name = "docker-wrapper"
    namespace = kubernetes_namespace.buildkit.metadata.0.name
  }
  data = {
    "dockerd-wrapper.sh" = "${file(\"${path.module}/earthly/dockerd-wrapper.sh\")}"
  }
}

# I have since learned that I could have added the throughput to the storage class
# instead of creating the volumes directly
resource "kubernetes_storage_class" "buildkit" {
  metadata {
    name = "buildkit"
  }
  storage_provisioner = "ebs.csi.aws.com"
  parameters = {
    "csi.storage.k8s.io/fstype" = "ext4"
    encrypted = "true"
    type = "gp3"
  }
  reclaim_policy = "Delete"
  mount_options = [
    "context=\"system_u:object_r:local_t:s0\"",
  ]
}

resource "aws_ebs_volume" "buildkit" {
  count = var.buildkit_replicas
  availability_zone = var.az
  size              = 1000
  type              = "gp3"
  iops              = 16000
  throughput        = 1000
  tags = {
    Name = "buildkit-volume-${count.index}"
  }
}

resource "kubernetes_persistent_volume" "buildkit" {
  count = var.buildkit_replicas
  metadata {
    name = "buildkit-volume-${count.index}"
  }
  spec {
    capacity = {
      storage = "1000Gi"
    }
    access_modes                     = ["ReadWriteOnce"]
    persistent_volume_reclaim_policy = "Retain"
    mount_options = [
      "context=\"system_u:object_r:local_t:s0\"",
    ]
    node_affinity {
      required {
        node_selector_term {
          match_expressions {
            key      = "topology.ebs.csi.aws.com/zone"
            operator = "In"
            values   = [var.az]
          }
        }
      }
    }
    storage_class_name = kubernetes_storage_class.buildkit.metadata.0.name
    persistent_volume_source {
      csi {
        driver        = "ebs.csi.aws.com"
        fs_type       = "ext4"
        volume_handle = aws_ebs_volume.buildkit[count.index].id
      }
    }
  }
}

resource "kubernetes_persistent_volume_claim" "buildkit" {
  count            = var.buildkit_replicas
  wait_until_bound = false
  metadata {
    name      = "buildkit-volume-buildkit-${count.index}"
    namespace = kubernetes_namespace.buildkit.metadata.0.name
  }
  spec {
    access_modes = ["ReadWriteOnce"]
    resources {
      requests = {
        storage = "1000Gi"
      }
    }
    storage_class_name = kubernetes_storage_class.buildkit.metadata.0.name
    volume_name        = kubernetes_persistent_volume.buildkit[count.index].metadata.0.name
  }
}

resource "kubernetes_stateful_set" "buildkit" {
  metadata {
    name      = "buildkit"
    namespace = kubernetes_namespace.buildkit.metadata.0.name
  }
  spec {
    pod_management_policy  = "Parallel"
    replicas               = var.buildkit_replicas
    revision_history_limit = 3
    service_name           = "buildkit"

    selector {
      match_labels = {
        k8s-app = "buildkit"
      }
    }

    template {
      metadata {
        labels = {
          k8s-app = "buildkit"
        }
      }
      spec {
        node_selector = {
          "karpenter.k8s.aws/instance-family" = "m7i"
          "karpenter.sh/capacity-type"       = "on-demand"
          "topology.kubernetes.io/zone"      = var.az
        }

		# The init container enables QEMU on the nodes so that
		# you can do cross-platform builds. Remove this if thats
		# not necessary
		
        init_container {
          name  = "binfmt"
          image = "tonistiigi/binfmt:master"
          args  = ["--install", "all"]
          security_context {
            privileged = true
          }
        }
        
        container {
          name  = "buildkitd"
          image = "earthly/buildkitd:v0.8.16-ticktock"
          security_context {
            privileged               = true
            run_as_user             = 0
            allow_privilege_escalation = true
            capabilities {
              add = [
                "SYS_ADMIN",
                "SETGID",
                "SETUID",
                "SYS_PTRACE",
                "SYS_CHROOT",
                "DAC_OVERRIDE",
                "SETPCAP",
                "DAC_READ_SEARCH",
                "BPF",
                "PERFMON",
                "SYS_RESOURCE",
                "NET_RAW",
                "CHOWN",
                "NET_ADMIN",
              ]
            }
          }
          volume_mount {
            mount_path = "/tmp/earthly"
            name       = "buildkit-volume"
          }
          volume_mount {
            mount_path = "/var/earthly/dockerd-wrapper.sh"
            name       = "dockerd-wrapper"
            sub_path   = "dockerd-wrapper.sh"
          }
          resources {
            requests = {
              cpu    = "${var.buildkit_cpu}"
              memory = "${var.buildkit_cpu * 4}Gi"
            }
            limits = {
              memory = "${var.buildkit_cpu * 4}Gi"
            }
          }
          env {
            name = "HOST_IP"
            value_from {
              field_ref {
                field_path = "status.hostIP"
              }
            }
          }
          env {
            name = "POD_IP"
            value_from {
              field_ref {
                field_path = "status.podIP"
              }
            }
          }
          env {
            name  = "BUILDKIT_TCP_TRANSPORT_ENABLED"
            value = "true"
          }
          env {
            name  = "BUILDKIT_TLS_ENABLED"
            value = "false"
          }
          env {
            name  = "BUILDKIT_MAX_PARALLELISM"
            value = "100"
          }
          env {
            name  = "CACHE_SIZE_PCT"
            value = "75"
          }
          env {
            name  = "BUILDKIT_DEBUG"
            value = "true"
          }
          env {
            name  = "BUILDKIT_PPROF_ENABLED"
            value = "true"
          }
        }
        volume {
          name = "dockerd-wrapper"
          config_map {
            name         = kubernetes_config_map.docker_wrapper.metadata.0.name
            default_mode = "0777"
          }
        }
      }
    }
    volume_claim_template {
      metadata {
        name      = "buildkit-volume"
        namespace = kubernetes_namespace.buildkit.metadata.0.name
      }
      spec {
        access_modes = ["ReadWriteOnce"]
        resources {
          requests = {
            storage = "1000Gi"
          }
        }
        storage_class_name = kubernetes_storage_class.buildkit.metadata.0.name
      }
    }
  }
  depends_on = [kubernetes_persistent_volume_claim.buildkit]
}

# Need the headless service so that we can pin specific builds
# to specific instances of buildkit for caching
resource "kubernetes_service" "buildkit" {
  metadata {
    name      = "buildkit"
    namespace = kubernetes_namespace.buildkit.metadata.0.name
  }
  spec {
    cluster_ip = "None"
    selector = {
      k8s-app = "buildkit"
    }
    session_affinity = "ClientIP"
    session_affinity_config {
      client_ip {
        timeout_seconds = 3000
      }
    }
    port {
      name        = "grpc"
      port        = 8372
      target_port = 8372
      protocol    = "TCP"
    }
    port {
      name        = "debug"
      port        = 6060
      target_port = 6060
      protocol    = "TCP"
    }
  }
}