Giter Club home page Giter Club logo

agent-stack-k8s's Introduction

Buildkite Agent Stack for Kubernetes

Build status

Table of Contents

Overview

A Kubernetes controller that runs Buildkite steps as Kubernetes jobs.

How does it work

The controller uses the Buildkite GraphQL API to watch for scheduled work that uses the kubernetes plugin.

When a job is available, the controller will create a pod to acquire and run the job. It converts the PodSpec in the kubernetes plugin into a pod by:

  • adding an init container to:
    • copy the agent binary onto the workspace volume
  • adding a container to run the buildkite agent
  • adding a container to clone the source repository
  • modifying the user-specified containers to:
    • overwrite the entrypoint to the agent binary
    • run with the working directory set to the workspace

The entrypoint rewriting and ordering logic is heavily inspired by the approach used in Tekton.

Architecture

sequenceDiagram
    participant bc as buildkite controller
    participant gql as Buildkite GraphQL API
    participant bapi as Buildkite API
    participant kubernetes
    bc->>gql: Get scheduled builds & jobs
    gql-->>bc: {build: jobs: [{uuid: "abc"}]}
    kubernetes->>pod: start
    bc->>kubernetes: watch for pod completions
    bc->>kubernetes: create pod with agent sidecar
    kubernetes->>pod: create
    pod->>bapi: agent accepts & starts job
    pod->>pod: run sidecars
    pod->>pod: agent bootstrap
    pod->>pod: run user pods to completion
    pod->>bapi: upload artifacts, exit code
    pod->>pod: agent exit
    kubernetes->>bc: pod completion event
    bc->>kubernetes: cleanup finished pods
Loading

Installation

Requirements

Deploy with Helm

The simplest way to get up and running is by deploying our Helm chart:

helm upgrade --install agent-stack-k8s oci://ghcr.io/buildkite/helm/agent-stack-k8s \
    --create-namespace \
    --namespace buildkite \
    --set config.org=<your Buildkite org slug> \
    --set agentToken=<your Buildkite agent token> \
    --set graphqlToken=<your Buildkite GraphQL-enabled API token>

If you are using Buildkite Clusters to isolate sets of pipelines from each other, you will need to specify the cluster's UUID in the configuration for the controller. This may be done using a flag on the helm command like so: --set config.cluster-uuid=<your cluster's UUID>, or an entry in a values file.

# values.yaml
config:
  cluster-uuid: beefcafe-abbe-baba-abba-deedcedecade

The cluster's UUID may be obtained by navigating to the clusters page, clicking on the relevant cluster and then clicking on "Settings". It will be in a section titled "GraphQL API Integration".

Note

Don't confuse the Cluster UUID with the UUID for the Queue. See the docs for an explanation.

We're using Helm's support for OCI-based registries, which means you'll need Helm version 3.8.0 or newer.

This will create an agent-stack-k8s installation that will listen to the kubernetes queue. See the --tags option for specifying a different queue.

Options

Usage:
  agent-stack-k8s [flags]
  agent-stack-k8s [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  lint        A tool for linting Buildkite pipelines
  version     Prints the version

Flags:
      --agent-token-secret string                  name of the Buildkite agent token secret (default "buildkite-agent-token")
      --buildkite-token string                     Buildkite API token with GraphQL scopes
      --cluster-uuid string                        UUID of the Buildkite Cluster. The agent token must be for the Buildkite Cluster.
  -f, --config string                              config file path
      --debug                                      debug logs
  -h, --help                                       help for agent-stack-k8s
      --image string                               The image to use for the Buildkite agent (default "ghcr.io/buildkite/agent:3.75.1")
      --image-pull-backoff-grace-period duration   Duration after starting a pod that the controller will wait before considering cancelling a job due to ImagePullBackOff (e.g. when the podSpec specifies container images that cannot be pulled) (default 30s)
      --job-ttl duration                           time to retain kubernetes jobs after completion (default 10m0s)
      --max-in-flight int                          max jobs in flight, 0 means no max (default 25)
      --namespace string                           kubernetes namespace to create resources in (default "default")
      --org string                                 Buildkite organization name to watch
      --poll-interval duration                     time to wait between polling for new jobs (minimum 1s); note that increasing this causes jobs to be slower to start (default 1s)
      --profiler-address string                    Bind address to expose the pprof profiler (e.g. localhost:6060)
      --prohibit-kubernetes-plugin                 Causes the controller to prohibit the kubernetes plugin specified within jobs (pipeline YAML) - enabling this causes jobs with a kubernetes plugin to fail, preventing the pipeline YAML from having any influence over the podSpec
      --tags strings                               A comma-separated list of agent tags. The "queue" tag must be unique (e.g. "queue=kubernetes,os=linux") (default [queue=kubernetes])

Use "agent-stack-k8s [command] --help" for more information about a command.

Configuration can also be provided by a config file (--config or CONFIG), or environment variables. In the examples folder there is a sample YAML config and a sample dotenv config.

Externalize Secrets

You can also have an external provider create a secret for you in the namespace before deploying the chart with helm. If the secret is pre-provisioned, replace the agentToken and graphqlToken arguments with:

--set agentStackSecret=<secret-name>

The format of the required secret can be found in this file.

Other Installation Methods

You can also use this chart as a dependency:

dependencies:
- name: agent-stack-k8s
  version: "0.5.0"
  repository: "oci://ghcr.io/buildkite/helm"

or use it as a template:

helm template oci://ghcr.io/buildkite/helm/agent-stack-k8s -f my-values.yaml

Available versions and their digests can be found on the releases page.

Sample Buildkite Pipelines

For simple commands, you merely have to target the queue you configured agent-stack-k8s with.

steps:
- label: Hello World!
  command: echo Hello World!
  agents:
    queue: kubernetes

For more complicated steps, you have access to the PodSpec Kubernetes API resource that will be used in a Kubernetes Job. For now, this is nested under a kubernetes plugin. But unlike other Buildkite plugins, there is no corresponding plugin repository. Rather, this is syntax that is interpreted by the agent-stack-k8s controller.

steps:
- label: Hello World!
  agents:
    queue: kubernetes
  plugins:
  - kubernetes:
      podSpec:
        containers:
        - image: alpine:latest
          command:
          - echo
          - Hello World!

Note that in a podSpec, a command should be YAML list that will be combined into a single command for a container to run.

Almost any container image may be used, but it MUST have a POSIX shell available to be executed at /bin/sh.

It's also possible to specify an entire script as a command

steps:
- label: Hello World!
  agents:
    queue: kubernetes
  plugins:
  - kubernetes:
      podSpec:
        containers:
        - image: alpine:latest
          command:
          - |-
            set -euo pipefail

            echo Hello World! > hello.txt
            cat hello.txt | buildkite-agent annotate

If you have a multi-line command, specifying the args as well could lead to confusion, so we recommend just using command.

More samples can be found in the integration test fixtures directory.

Cloning repos via SSH

To use SSH to clone your repos, you'll need to add a secret reference via an EnvFrom to your pipeline to specify where to mount your SSH private key from. Place this object under a gitEnvFrom key in the kubernetes plugin (see the example below).

You should create a secret in your namespace with an environment variable name that's recognised by docker-ssh-env-config. A script from this project is included in the default entrypoint of the default buildkite/agent Docker image. It will process the value of the secret and write out a private key to the ~/.ssh directory of the checkout container.

However this key will not be available in your job containers. If you need to use git ssh credentials in your job containers, we recommend one of the following options:

  1. Use a container image that's based on the default buildkite/agent docker image and preserve the default entrypoint by not overriding the command in the job spec.
  2. Include or reproduce the functionality of the ssh-env-config.sh script in the entrypoint for your job container image

Example secret creation for ssh cloning

You most likely want to use a more secure method of managing k8s secrets. This example is illustrative only.

Supposing a SSH private key has been created and its public key has been registered with the remote repository provider (e.g. GitHub).

kubectl create secret generic my-git-ssh-credentials --from-file=SSH_PRIVATE_DSA_KEY="$HOME/.ssh/id_ecdsa"

Then the following pipeline will be able to clone a git repository that requires ssh credentials.

steps:
  - label: build image
    agents:
      queue: kubernetes
    plugins:
      - kubernetes:
          gitEnvFrom:
            - secretRef:
                name: my-git-ssh-credentials # <----
          podSpec:
            containers:
              - image: gradle:latest
                command: [gradle]
                args:
                  - jib
                  - --image=ttl.sh/example:1h

Cloning repos via HTTPS

To use HTTPS to clone private repos, you can use a .git-credentials file stored in a secret, and refer to this secret using the gitCredentialsSecret checkout parameter.

By default, this secret is only attached, and Git is only configured to use it, within the checkout container. It will not necessarily be available in your job containers. If you need the .git-credentials file inside the other containers as well, you can add a volume mount for the git-credentials volume, and configure Git to use the file within it (e.g. with git config credential.helper 'store --file ...')

Example secret creation for HTTPS cloning

Once again, this example is illustrative only.

First, create a Kubernetes secret containing the key .git-credentials, formatted in the manner expected by the store Git credendial helper:

kubectl create secret generic my-git-credentials --from-file='.git-credentials'="$HOME/.git-credentials"

Then you can use the checkout/gitCredentialsSecret (in your pipeline) or default-checkout-params/gitCredentialsSecret (in values.yaml) to reference the secret volume source:

# pipeline.yaml
steps:
  - label: build image
    agents:
      queue: kubernetes
    plugins:
      - kubernetes:
          checkout:
            gitCredentialsSecret:
              secretName: my-git-credentials # <----
          podSpec:
            ...
# values.yaml
...
default-checkout-params:
  gitCredentialsSecret:
    secretName: my-git-credentials
...

If you wish to use a different key within the secret than .git-credentials, you can project it to .git-credentials by using items within gitCredentialsSecret.

# values.yaml
...
default-checkout-params:
  gitCredentialsSecret:
    secretName: my-git-credentials
    items:
    - key: funky-creds
      path: .git-credentials
...

Pod Spec Patch

Rather than defining the entire Pod Spec in a step, there is the option to define a strategic merge patch in the controller. Agent Stack K8s will first generate a K8s Job with a PodSpec from a Buildkite Job and then apply the patch in the controller. It will then apply the patch specified in its config file, which is derived from the value in the helm installation. This can replace much of the functionality of some of the other fields in the plugin, like gitEnvFrom.

Eliminate gitEnvFrom

Here's an example demonstrating how one would eliminate the need to specify gitEnvFrom from every step, but still checkout private repositories.

First, deploy the helm chart with a values.yaml file.

# values.yaml
agentStackSecret: <name of predefined secrets for k8s>
config:
  org: <your-org-slug>
  pod-spec-patch:
    containers:
    - name: checkout         # <---- this is needed so that the secret will only be mounted on the checkout container
      envFrom:
      - secretRef:
          name: git-checkout # <---- this is the same secret name you would have put in `gitEnvFrom` in the kubernetes plugin

You may use the -f or --values arguments to helm upgrade to specify a values.yaml file.

helm upgrade --install agent-stack-k8s oci://ghcr.io/buildkite/helm/agent-stack-k8s \
    --create-namespace \
    --namespace buildkite \
    --values values.yaml \
    --version <agent-stack-k8s version>

Now, with this setup, we don't even need to specify the kubernetes plugin to use Agent Stack K8s with a private repo

# pipelines.yaml
agents:
  queue: kubernetes
steps:
- name: Hello World!
  commands:
  - echo -n Hello!
  - echo " World!"

- name: Hello World in one command
  command: |-
    echo -n Hello!
    echo " World!"

Custom Images

You can specify a different image to use for a step in a step level podSpecPatch. Previously this could be done with a step level podSpec.

# pipelines.yaml
agents:
  queue: kubernetes
steps:
- name: Hello World!
  commands:
  - echo -n Hello!
  - echo " World!"
  plugins:
  - kubernetes:
      podSpecPatch:
      - name: container-0
        image: alpine:latest

- name: Hello World from alpine!
  commands:
  - echo -n Hello
  - echo " from alpine!"
  plugins:
  - kubernetes:
      podSpecPatch:
      - name: container-0      # <---- You must specify this as exactly `container-0` for now.
        image: alpine:latest   #       We are experimenting with ways to make it more ergonomic

Default Resources

In the helm values, you can specify default resources to be used by the containers in Pods that are launched to run Jobs.

# values.yaml
agentStackSecret: <name of predefend secrets for k8s>
config:
  org: <your-org-slug>
  pod-spec-patch:
    initContainers:
    - name: copy-agent
    requests:
      cpu: 100m
      memory: 50Mi
    limits:
      memory: 100Mi
    containers:
    - name: agent          # this container acquires the job
      resources:
        requests:
          cpu: 100m
          memory: 50Mi
        limits:
          memory: 1Gi
    - name: checkout       # this container clones the repo
      resources:
        requests:
          cpu: 100m
          memory: 50Mi
        limits:
          memory: 1Gi
    - name: container-0    # the job runs in a container with this name by default
      resources:
        requests:
          cpu: 100m
          memory: 50Mi
        limits:
          memory: 1Gi

and then every job that's handled by this installation of agent-stack-k8s will default to these values. To override it for a step, use a step level podSpecPatch.

# pipelines.yaml
agents:
  queue: kubernetes
steps:
- name: Hello from a container with more resources
  command: echo Hello World!
  plugins:
  - kubernetes:
      podSpecPatch:
        containers:
        - name: container-0    # <---- You must specify this as exactly `container-0` for now.
          resources:           #       We are experimenting with ways to make it more ergonomic
            requests:
              cpu: 1000m
              memory: 50Mi
            limits:
              memory: 1Gi

- name: Hello from a container with default resources
  command: echo Hello World!

Sidecars

Sidecar containers can be added to your job by specifying them under the top-level sidecars key. See this example for a simple job that runs nginx as a sidecar, and accesses the nginx server from the main job.

There is no guarantee that your sidecars will have started before your job, so using retries or a tool like wait-for-it is a good idea to avoid flaky tests.

Extra volume mounts

In some situations, for example if you want to use git mirrors you may want to attach extra volume mounts (in addition to the /workspace one) in all the pod containers.

See this example, that will declare a new volume in the podSpec and mount it in all the containers. The benefit, is to have the same mounted path in all containers, including the checkout container.

Skipping checkout

For some steps, you may wish to avoid checkout (cloning a source repository). This can be done with the checkout block under the kubernetes plugin:

steps:
- label: Hello World!
  agents:
    queue: kubernetes
  plugins:
  - kubernetes:
      checkout:
        skip: true # prevents scheduling the checkout container

Overriding flags for git clone/fetch

git clone and git fetch flags can be overridden per-step (similar to BUILDKITE_GIT_CLONE_FLAGS and BUILDLKITE_GIT_FETCH_FLAGS env vars) with the checkout block also:

steps:
- label: Hello World!
  agents:
    queue: kubernetes
  plugins:
  - kubernetes:
      checkout:
        cloneFlags: -v --depth 1
        fetchFlags: -v --prune --tags

Validating your pipeline

With the unstructured nature of Buildkite plugin specs, it can be frustratingly easy to mess up your configuration and then have to debug why your agent pods are failing to start. To help prevent this sort of error, there's a linter that uses JSON schema to validate the pipeline and plugin configuration.

This currently can't prevent every sort of error, you might still have a reference to a Kubernetes volume that doesn't exist, or other errors of that sort, but it will validate that the fields match the API spec we expect.

Our JSON schema can also be used with editors that support JSON Schema by configuring your editor to validate against the schema found here.

Securing the stack

Prohibiting the kubernetes plugin (v0.13.0 and later)

Suppose you want to enforce the podSpec used for all jobs at the controller level, and prevent users from setting or overriding that podSpec (or various other parameters) through use of the kubernetes plugin. This can be achieved with prohibit-kubernetes-plugin, either as a controller flag or within the config values.yaml:

# values.yaml
...
config:
  prohibit-kubernetes-plugin: true
  pod-spec-patch:
    # Override the default podSpec here.
  ...

With prohibit-kubernetes-plugin enabled, any job containing the kubernetes plugin will fail.

How to setup agent hooks

This section explains how to setup agent hooks when running Agent Stack K8s. In order for the agent hooks to work, they must be present on the instances where the agent runs.

In case of agent-stack-k8s, we need these hooks to be accessible to the kubernetes pod where the checkout and command containers will be running. Best way to make this happen is to create a configmap with the agent hooks and mount the configmap as volume to the containers.

Here is the command to create configmap which will have agent hooks in it:

kubectl create configmap buildkite-agent-hooks --from-file=/tmp/hooks -n buildkite

We have all the hooks under directory /tmp/hooks and we are creating configmap with name buildkite-agent-hooks in buildkite namespace in the k8s cluster.

Here is how to make these hooks in configmap available to the containers. Here is the pipeline config for setting up agent hooks:

steps:
- label: ':pipeline: Pipeline Upload'
  agents:
    queue: kubernetes
  plugins:
  - kubernetes:
      extraVolumeMounts:
        - mountPath: /buildkite/hooks
          name: agent-hooks
      podSpec:
        containers:
        - command:
          - echo
          - hello-world
          image: alpine:latest
          env:
          - name: BUILDKITE_HOOKS_PATH
            value: /buildkite/hooks
        volumes:
          - configMap:
              defaultMode: 493
              name: buildkite-agent-hooks
            name: agent-hooks

There are 3 main aspects we need to make sure that happen for hooks to be available to the containers in agent-stack-k8s.

  1. Define env BUILDKITE_HOOKS_PATH with the path agent and checkout containers will look for hooks

           env:
           - name: BUILDKITE_HOOKS_PATH
             value: /buildkite/hooks
    
  2. Define VolumeMounts using extraVolumeMounts which will be the path where the hooks will be mounted to with in the containers

          extraVolumeMounts:
         - mountPath: /buildkite/hooks
           name: agent-hooks
    
  3. Define volumes where the configmap will be mounted

            volumes:
           - configMap:
               defaultMode: 493
               name: buildkite-agent-hooks
             name: agent-hooks
    

    Note: Here defaultMode 493 is setting the Unix permissions to 755 which enables the hooks to be executable. Also another way to make this hooks directory available to containers is to use hostPath mount but it is not a recommended approach for production environments.

Now when we run this pipeline agent hooks will be available to the container and will run them.

Key difference we will notice with hooks execution with agent-stack-k8s is that environment hooks will execute twice, but checkout-related hooks such as pre-checkout, checkout and post-checkout will only be executed once in the checkout container. Similarly the command-related hooks like pre-command, command and post-command hooks will be executed once by the command container(s).

If the env BUILDKITE_HOOKS_PATH is set at pipeline level instead of container like shown in above pipeline config then hooks will run for both checkout container and command container(s).

Here is the pipeline config where env BUILDKITE_HOOKS_PATH is exposed to all containers in the pipeline:

steps:
- label: ':pipeline: Pipeline Upload'
  env:
    BUILDKITE_HOOKS_PATH: /buildkite/hooks
  agents:
    queue: kubernetes
  plugins:
  - kubernetes:
      extraVolumeMounts:
        - mountPath: /buildkite/hooks
          name: agent-hooks
      podSpec:
        containers:
        - command:
          - echo
          - hello-world
          image: alpine:latest
        volumes:
          - configMap:
              defaultMode: 493
              name: buildkite-agent-hooks
            name: agent-hooks

This is because agent-hooks will be present in both containers and environment hook will run in both containers. Here is how the build output will look like:

Running global environment hook
Running global pre-checkout hook
Preparing working directory
Running global post-checkout hook
Running global environment hook
Running commands
Running global pre-exit hook

In scenarios where we want to skip checkout when running on agent-stack-k8s, it will cause checkout-related hooks such as pre-checkout, checkout and post-checkout not to run because checkout container will not be present when skip checkout is set.

Here is the pipeline config where checkout is skipped:

steps:
- label: ':pipeline: Pipeline Upload'
  env:
    BUILDKITE_HOOKS_PATH: /buildkite/hooks
  agents:
    queue: kubernetes
  plugins:
  - kubernetes:
      checkout:
        skip: true
      extraVolumeMounts:
        - mountPath: /buildkite/hooks
          name: agent-hooks
      podSpec:
        containers:
        - command:
          - echo
          - hello-world
          image: alpine:latest
        volumes:
          - configMap:
              defaultMode: 493
              name: buildkite-agent-hooks
            name: agent-hooks

Now, if we look at the build output below, we can see that it only has environment and pre-exit that ran and no checkout-related hooks, unlike the earlier build output where checkout was not skipped.

Preparing working directory
Running global environment hook
Running commands
Running global pre-exit hook

Debugging

Use the log-collector script in the utils folder to collect logs for agent-stack-k8s.

Prerequisites

  • kubectl binary
  • kubectl setup and authenticated to correct k8s cluster

Inputs to the script

k8s namespace where you deployed agent stack k8s and where you expect their k8s jobs to run.

Buildkite job id for which you saw issues.

Data/logs gathered:

The script will collect kubectl describe of k8s job, pod and agent stack k8s controller pod.

It will also capture kubectl logs of k8s pod for the Buildkite job, agent stack k8s controller pod and package them in a tar archive which you can send via email to [email protected].

Open questions

  • How to deal with stuck jobs? Timeouts?
  • How to deal with pod failures (not job failures)?
    • Report failure to buildkite from controller?
    • Emit pod logs to buildkite? If agent isn't starting correctly
    • Retry?

agent-stack-k8s's People

Contributors

42atomys avatar areitz avatar artem-zinnatullin avatar benmoss avatar c2h5oh avatar clbx avatar dependabot[bot] avatar drjosh9000 avatar jiaquan1 avatar jmcshane avatar kingatlas avatar mkmrgn avatar moskyb avatar nsuma8989 avatar patrobinson avatar relu avatar sj26 avatar sorchaabel avatar thephw avatar triarius avatar wolfeidau avatar zhming0 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

agent-stack-k8s's Issues

Support multiple queue tags

When launching the controller with multiple queue tags it doesn't pick up any job from the pipeline resulting in pipelines in stuck waiting for agent.

Version 0.7.0
Startup log: ( org value is masked )
2024-01-18T08:26:10.909Z INFO controller/controller.go:122 configuration loaded {"config": {"agent-token-secret": "dev-eks-buildkite-agent-stack-secrets", "debug": true, "image": "ghcr.io/buildkite/agent-stack-k8s/agent:0.7.0", "job-ttl": "1h0m0s", "max-in-flight": 0, "namespace": "buildkite", "org": "mytestorg", "profiler-address": "", "cluster-uuid": "", "tags": ["queue=dev-eks", "queue=dev-cat-eks"]}} 2024-01-18T08:26:11.011Z INFO monitor monitor/monitor.go:113 started {"org": "mytestorg"}

configure agent images without updating controller with the "image" value

I'd like to add custom hooks to the agent image without updating the controller pod. I notice that when I deploy with --set image=my-custom-image:latest, the controller gets redeployed with my image. This was unexpected based on the help text.

--image string The image to use for the Buildkite agent (default "ghcr.io/buildkite/agent-k8s:latest")

Is that behavior intentional? And is there a recommended approach for customizing the agent images?

Only set necessary environment variables on system containers

Right now we're having the checkout container copy the ~/.ssh directory and set permissions on it, taking advantage of the fact that it previously didn't have a command phase.

The problem is that a common pattern for when we're using Alpine Linux containers is to set BUILDKITE_SHELL to /bin/sh, because Alpine doesn't come out of the box with bash. With the way that the environment gets set in each of these containers, this means that BUILDKITE_SHELL then gets set to that on the checkout container, resulting in this weird error of

/bin/sh: can't open 'trap 'kill -- $' INT TERM QUIT; cp -r ~/.ssh /workspace/.ssh && chmod -R 777 /workspace': No such file or directory

One way of solving this would be to just not set BUILDKITE_SHELL on the checkout container, we already do some kind of deny-listing like this with other parts of the environment.

Another way would be to find the minimum subset of variables that the system containers need (agent, checkout, artifact upload), and only give the entire environment to the command/sidecar containers.

A third way we could solve this would be to move this logic into the agent itself, rather than using the command step like this.

A way that users can work around this is to set the env only on the specific Alpine container, but I'm thinking that the current default when you set env at the step level might be surprising behavior for some.

Rename images

Right now it's slightly confusing:

ghcr.io/buildkite/helm/agent-stack-k8s
ghcr.io/buildkite/agent-k8s
ghcr.io/buildkite/agent-stack-k8s

How about:

ghcr.io/buildkite/agent-stack-k8s/helm
ghcr.io/buildkite/agent-stack-k8s/agent
ghcr.io/buildkite/agent-stack-k8s/controller

Enable pprof API

Expose this over a local port for troubleshooting. I've seen the controller seemingly get stuck and the logs not reveal anything useful.

Agent should log other container statuses

If we give the agent access via rbac to its own pod, it can use the Kubernetes API to query the status of other containers in its pod. This would let us log container configuration errors, like missing secrets/configmaps, or image pull errors.

This might mean the agent takes a dependency on client-go.

Publish new chart

0.5.1 does not support overriding token - please release the new version!

Max-in-flight is fuzzy

We're using a shared informer to calculate the number of jobs in flight and keep it below a certain threshold (max-in-flight).

The field we're looking at to distinguish in-progress jobs from completed jobs is currently job.Status.Active, which results in a bug that when jobs are first created and haven't yet been reconciled by the Job controller, active is 0. #88 fixes this.

The additional problem is that even with this fix, it's possible for the monitor thread to get blocked trying to push another job onto the channel we're using as a queue between the monitor and the scheduler.

  • monitor: get 2 jobs from api server
  • monitor: check current-in-flight, find 0
  • monitor: push job 1 on queue
  • scheduler: pull job 1 off queue
  • monitor: check current-in-flight, find 0
  • monitor: push job 2 on queue
  • scheduler: create job 1
  • scheduler: pull job 2 off queue
  • scheduler create job 2

I think in order to fix this problem we're going to have to slightly rethink how we do max-in-flight calculations. I think we need to do some number tracking on our own, and not just push it all to the client. We can use the informer to be notified of job completions, but we can keep track of job creations ourselves. I think it'd likely be simplest if we move the max-in-flight calculation to the scheduler completely, and let the monitor just be limited by the bounded channel.

Prevent node pool autoscaler from evicting jobs in progress

I've noticed that GKE node pool autoscaler will evict jobs in progress if autoscaling policy is set to optimize-utilization. Default setting (balanced) doesn't have that problem.

Based on GKE autoscaling policy docs this can be prevented by adding cluster-autoscaler.kubernetes.io/safe-to-evict: "false" annotation.

This is not a GKE specific thing:
https://kubernetes.io/docs/reference/labels-annotations-taints/#cluster-autoscaler-kubernetes-io-safe-to-evict

[bug] artifacts dont upload on failed step

steps:
  - label: build image
    artifact_paths:
      - "myfile"
    agents:
      queue: kubernetes
    plugins:
      - kubernetes:
          podSpec:
            containers:
              - image: alpine:latest
                command: [echo]
                args:
                - false
                - exit 1

On completion, the entire pod is torn down, rather than exiting its own container and continuing to go into the artifact container
That means that no artifacts are uploaded

Only rebuild the agent if it's changed

Right now we're building the agent on every commit, but we could be smarter and only build it if it's changed.

I think we might want to tag the image with the commit of the agent repo, so that then we can find the already-built image for it in the case that it hasn't changed.

Caching/mirroring for large Git repositories

The current agent approach would be challenging for large repositories due to the time to clone. Can we create a mechanism for caching the repo within the cluster so that jobs using that repository will start more quickly?

Check solutions in the existing k8s ecosystem

Use pagination with polling for jobs

Right now we only look at the first 100 scheduled builds, which is a pretty good simple approach, since as the builds are started we should see new ones appear on the next time we poll. It seems like it might be more performant though to iterate through the pages instead of waiting for the next loop in order to schedule all possible builds.

agentStackSecret still requires agentToken and graphqlToken

Seems like helm values schema check doesn't let me use agentStackSecret instead of agentToken and graphqlToken.

Error: UPGRADE FAILED: values don't meet the specifications of the schema(s) in the following chart(s):
agent-stack-k8s:
- agentToken: String length must be greater than or equal to 1
- graphqlToken: String length must be greater than or equal to 1

Workaround: pass any value to pass the values schema validation.

Underlying parse error does not print if using gitEnvFrom for Git credentials

In testing agent-stack-k8s, some of my podSpecs I wrote ended up being incorrectly formatted. When this happens, agent-stack-k8s tries to replace the container with a build failure job which just echoes the underlying error to the console, as defined in https://github.com/buildkite/agent-stack-k8s/blob/main/internal/controller/scheduler/scheduler.go#L433-L447.

However, when using gitEnvFrom for SSH credentials to checkout git, the underlying error gets masked, and instead the console just shows failure to checkout the Git repo (failing after 3 retries), and the underlying error is never printed. This is because the checkout container (nor any other container) do not get the gitEnvFrom attached to it when there a build failure. Thus checkout fails and the container echoing the error never prints.

The only workaround to this is to run the job, then identify and examine the pod it creates while it is in flight, and examine the pod manifest looking for the error text in the BUILDKITE_COMMAND environment variable. This is obviously not optimal.

Allow users to customize workspace path

We mount the shared container workspace to /workspace, but that might cause collisions for users. We should choose something less generic (/buildkite?) and offer a way to override it.

Make checkout configurable

  • allow disabling checkout
  • allow setting checkout-specific env vars (BUILDKITE_GIT_CLONE_FLAGS, BUILDKITE_GIT_FETCH_FLAGS) - you'd normally do it using a plugin but since this plugins also does checkout you can't

Cleanup jobs that are canceled

We sometimes seem jobs that get stuck for some reason (misconfiguration, etc) that causes them to get into a unrecoverable state. If a user hits "cancel" on the Buildkite UI, we should notice this and delete the job from Kubernetes.

Most of the time the agent will see the cancellation and do cleanup, but there are still times when the agent has failed or hasn't started that the job can be orphaned.

We could do this with a background task that periodically fetches builds we are tracking and checks their status on the Buildkite API.

Deadlock in rate limiter

#90 introduced a different way of managing max-in-flight. We saw that the old way was buggy and would allow more jobs in flight than the user specified, because there's lag in between the job being created and the cached informer learning about it.

The new rate limiter has a couple bugs I noticed, but the worst is a deadlock. This is the stack dump, basically it's stuck waiting on a completion, holding the mutex, while at the same time it's trying to add a new job.

Not sure I can totally reason about how completions is at capacity, but at least one reason is that we're not handling OnAdd, which means that when the controller starts it ignores any jobs that are already running. We then get completions for those jobs when they finish/delete, meaning we might have more completions than we have jobs in flight.

Another problem is the check for job.Status.CompletionTime, you only get that if a job "completes", aka finishes successfully.

The completion time is only set when the job finishes successfully
It seems there's no single way to find if a job just finished, we need to look at multiple fields, or multiple conditions, to get both success or failure.

Another problem is that if the limiter sends a job on to the scheduler but the scheduler errors when trying to create it, it will never get removed from the in-flight map, since we have no way of learning about creating failures.


Basically, a bunch of problems. I think the right solution is to

  • Change the API to just be a regular middleware pattern, like http.Handler. Gets rid of the nonsense of channels on the external API, and allows errors to bubble back up through the Limiter
  • Just do a len(inFlight) to check if we're at max-in-flight, and if so we can just drop the Job and let it be requeued later when we poll the API again. Get rid of the completions channel and its over complicated attempt to do this rate-limiting.
  • Fix up the job completion logic, that part is pretty straightforward

Report job failures for other kinds of failure

#113 gives us better visibility into pod failures like invalid YAML or invalid Kubernetes objects

This issue is an umbrella issue for thinking about other failure states that user might be frustrated by:

  • Insufficient capacity on the cluster, resource requirements that can't be met

Jobs that ran into OOM issues appear as still running in buildkite UI

For example, a container-0 job ran into this OOM so it has already exited:

 - containerID: containerd://868661c9da807af9428729518d1c95a52c1bb5efac68df8799cd6b24b475125c
    image: docker.io/library/golang:1.20-alpine
    imageID: docker.io/library/golang@sha256:59fc0dc542a38bb5b94cd1529e5f4663b4e7cc2f4a6c352b826dafe00d820031
    lastState: {}
    name: container-0
    ready: false
    restartCount: 0
    started: false
    state:
      terminated:
        containerID: containerd://868661c9da807af9428729518d1c95a52c1bb5efac68df8799cd6b24b475125c
        exitCode: 137
        finishedAt: "2023-07-14T10:40:18Z"
        reason: OOMKilled
        startedAt: "2023-07-14T10:31:49Z"

But in the buildkite UI it still appears running:

image

Maybe need a way for the controller to detect OOM events in the jobs to clean them up?

Support externally created secrets

Currently, the agent-stack-k8s requires that the secret material for both agentToken and graphqlToken are provided to helm, for helm to templatize the data into a Secret object. This is incompatible with other secret management solutions like External Secrets Operator. In these solutions, you would want to tell helm where to look for the secret and such, and allow the operator to create the actual Secret object. And very specifically for us, doing so would imply committing those rendered files to git, as we leverage a workflow as outlined in Robbie's talk at Unblock a few year ago

Example

As an example for this workflow, you might have in your chart, that leverages agent-stack-k8s as a subchart, a template like

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: template
spec:
  # ...
  target:
    name: my-secret
    creationPolicy: Owner
  data:
  - secretKey: agent-token
    remoteRef:
      key: /agent-token

The ESO will come in and fetch the secret from an external store (vault, AWS Secrets Manager) and create my-secret Secret from this template .

You could then reference it in a helm chart with

secret:
  create: false
  existingSecret: my-secret
  agentTokenKey: agent-token

Prior Art

This is effectively an ask to reproduce the work in buildkite/charts#96 into the new chart

Acceptance Criteria i.e. TLDR

Secrets for agentToken and graphqlToken can be specified via a combination of an externally created secret name, and a key within that Secret

Plugins don't work for clusters backed by containerd runtime

Update: I now realize the issue I was experiencing was with the execution of another plugin, and not something agent-stack-k8s was doing.

Closing. Sorry for the trouble.

Original issue content We're running into a roadblock attempting to migrate our workload to use the k8s agent stack.

Specifically, buildkite attempts to load the plugin by performing a docker pull, but there's no docker daemon since our kubernetes runtime is containerd.

Env:

Kubernetes Environment: Amazon EKS
Runtime: containerd

Error:

Found tag v1.11.0, pulling from docker hub
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
Command failed with exit status: 1
Suppressing failure so pipeline can continue (if you do not want this behaviour, set fail_on_error to true)

pipeline config:

agents:
  queue: kubernetes

steps:
  - label: "example"
    artifact_paths: ["golangci-lint.xml"]
    plugins:
      - kubernetes:
          gitEnvFrom:
            - secretRef: { name: agent-stack-k8s }
          podSpec:
            containers:
              - image: ghcr.io/buildkite/agent-stack-k8s/agent:latest
                command: [bash]
                args:
                  - -ec
                  - "'echo test >> golangci-lint.xml'"
      - bugcrowd/test-summary#v1.11.0:
          inputs:
            - label: golangci-lint
              artifact_path: artifacts/golangci-lint.xml
              type: checkstyle
          formatter:
            type: details
          run_without_docker: true

What are our options here?

Agent should wait for container readiness

Right now we just wait for sidecars to connect before running the main command container, but this means that there's still a race condition between the command and sidecar processes. You end up needing retries in your code to handle the fact that the sidecars may not be ready yet.

We can follow the Tekton approach of supporting container probes and waiting on them instead:
https://github.com/tektoncd/pipeline/blob/main/cmd/entrypoint/README.md#waiting-for-sidecars

[bug] post-checkout hooks are inoperable

Because there isn't a checkout step (it's actually a command step under the hood), any configured post checkout hooks won't execute because there isn't an official checkout

Canceled jobs in the UI

While running a lot of parallel jobs I have some jobs that doesn't start at all showing up in the UI as "Canceled". I managed to reduce the number of canceled jobs by:

  1. Increasing registry QPS on the kubelet
  2. Moving the docker image to a private registry
  3. Pulling the image at the kubernetes nodes startup ( managed by karpenter on EKS )

Sometimes I still get Canceled jobs in the UI and the pods logs this:

2023-12-14 11:30:14 DEBUG  Loaded config agent_version=3.52.0 agent_build=6806
2023-12-14 11:30:14 DEBUG  Enabled experiment "kubernetes-exec" agent_version=3.52.0 agent_build=6806
# Using experimental Kubernetes support
🚨 Error: Failed to start kubernetes client: error connecting to kubernetes runner: dial unix /workspace/buildkite.sock: connect: no such file or directory

Describing the job it says:

Events:
  Type     Reason            Age    From            Message
  ----     ------            ----   ----            -------
  Normal   SuccessfulCreate  7m53s  job-controller  Created pod: buildkite-018c6813-a298-4984-8707-ad9a72dd306e-6mfjh
  Normal   SuccessfulDelete  3m28s  job-controller  Deleted pod: buildkite-018c6813-a298-4984-8707-ad9a72dd306e-6mfjh
  Warning  DeadlineExceeded  3m28s  job-controller  Job was active longer than specified deadline

I noticed that activeDeadlineSecondsΒ is 1 and backoffLimit is 0. These settings should be configurable to allow the Pod to retry in case of image pull issues etc but I'm not sure if it's relevant for this problem.

[bug] global environment int variables cause golang error

If global environment variables are set in the top level pipeline file as an int, it'll cause a golang error and won't be processed

env:
  myenv: 1

steps:
  - label: build image
    agents:
      queue: kubernetes
    plugins:
      - kubernetes:
          podSpec:
            containers:
              - image: alpine:latest
                command: [echo]
                env:
                  - name: DOCKER_HOST
                     value: tcp://localhost:2375
                args:
                - "Hello, world!"

Also, the env variables should be merged, so any global envs should be merged into the local step envs

Plugin overwrites entrypoints

Kaniko image has entrypoint /kaniko/executor, see https://github.com/GoogleContainerTools/kaniko/blob/main/deploy/Dockerfile#L100

but:

- label: builder    
    plugins:
      - kubernetes:
          podSpec:
            containers:
              - image: gcr.io/kaniko-project/executor:debug
                args:
                  - "--context=/workspace/[...removed...]"
                  - "--dockerfile=Dockerfile"
                  - "--destination=my-image"

gets me:

trap 'kill -- $$' INT TERM QUIT; --context=/workspace/[...removed...] --dockerfile=Dockerfile --destination=my-image
/bin/sh: --context=/workspace/[...removed...]: not found

this works:

- label: builder    
    plugins:
      - kubernetes:
          podSpec:
            containers:
              - image: gcr.io/kaniko-project/executor:debug
                command: [/kaniko/executor]
                args:
                  - "--context=/workspace/[...removed...]"
                  - "--dockerfile=Dockerfile"
                  - "--destination=my-image"

Again, this should at the very least be documented.

Pass SSH secrets to all containers

If a user wants to use a plugin that requires auth to clone it, or if they want to do anything else that would require their ssh secret in a non-checkout container.

We should just pass the secret to all containers.

cc @titilambert

Fetching SSH creds for git extemely unreliable in a seemingly random way

Changes to podSpec result in unpredictable checkout failures, example:

steps:
  - label: ':pipeline: Pipeline Setup'
    agents:
      queue: my-ci
    plugins:
      - kubernetes:
          gitEnvFrom:
            - secretRef:
                name: buildkite-agent-ssh
          podSpec:
            containers:
              - image: 'buildkite/agent:latest'
                command:
                  - buildkite-agent
                args:
                  - pipeline upload

Works perfectly - 100% success rate

Adding an extra container results in 100% failure rate on checkout stage - missing creds - before any of the containers in spec are started. I have managed to trigger this by modifications as small as an additional space between args

steps:
  - label: ':pipeline: Pipeline Setup'
    agents:
      queue: my-ci
    plugins:
      - kubernetes:
          gitEnvFrom:
            - secretRef:
                name: buildkite-agent-ssh
          podSpec:
            containers:
              - image: 'buildkite/agent:latest'
                command:
                  - buildkite-agent
                args:
                  - pipeline upload
              - image: 'buildkite/agent:latest'
                command:
                  - /bin/bash
                args:
                  - echo $${BUILDKITE_BRANCH}

Adding

env:
    GIT_SSH_COMMAND: ssh -vvv

yields

[...]
debug1: Connection established.
debug1: identity file /root/.ssh/id_rsa type 0
debug1: identity file /root/.ssh/id_rsa-cert type -1
[...]

for the first example

[...]
debug1: Connection established.
debug1: identity file /root/.ssh/id_rsa type -1
debug1: identity file /root/.ssh/id_rsa-cert type -1
[...]

for the second one

With

env:
    GIT_SSH_COMMAND: ssh -i /workspace/.ssh -vvv 

second example reports that /workspace/.ssh doesn't exist

With

env:
    BUILDKITE_GIT_CLONE_FLAGS: "--depth=1"

That flag is passed to git command in first example, but not in the second.

Step green if a plugins crashed

I just got an example where my step was green even if the plugin crashed because of a missing permission

The step should be red if there is this kind of issue

Support resource requests/limits on checkout and agent containers

agent-stack-k8s allows us to set the resource requests/limits (e.g. cpu and memory) for the controller that launches pods, but does not provide a way to specify those requests/limits for the Buildkite agent pods it launches. Implicitly, you can provide resources for pods launched by the podSpec specified in a step, but not for the pods that the controller launches for checking out the Git repo or for monitoring the pods it launched.

This is especially needed if we are running the commands directly in the agent container (e.g. with no podSpec, just using a regular command step), where our commands could run some more cpu/memory intensive operations (in combination with our own custom agent containers with our own libraries installed). Proper requests/limits will allow us to combine agent-stack-k8s with an autoscaler like Karpenter to dynamically size our Buildkite compute appropriately.

Even better would be supporting different container-level resource limits/requests depending on if the agent is running the command block directly versus just using a podSpec and launching separate containers (where we can provide our own requests/limits). Even better on top of that would be having a concept of default requests/limits for containers specified in a podSpec.

Job does not get annotations from configuration

Somewhere in the Build function in the scheduler, annotations that are passed in via the plugin configuration are being lost.

For example I have a configuration of:

name: "test pipeline"
agents:
   queue: kubernetes
plugins:
- kubernetes:
     metadata:
        annotations:
              example.com/annotation-test: "value"
     podSpec:
       containers:
       - name: app
          image: debian
          command: ["echo", "hello world"]

The resultant job will not have the annotation provided, but will have the buildkite build-url and job-url annotations

I think they're being overwritten somewhere in here:

w.k8sPlugin.Metadata.Labels[api.UUIDLabel] = w.job.Uuid
w.k8sPlugin.Metadata.Labels[api.TagLabel] = api.TagToLabel(w.job.Tag)
w.k8sPlugin.Metadata.Annotations[api.BuildURLAnnotation] = w.envMap["BUILDKITE_BUILD_URL"]
w.annotateWithJobURL()
kjob.Labels = w.k8sPlugin.Metadata.Labels
kjob.Spec.Template.Labels = w.k8sPlugin.Metadata.Labels
kjob.Annotations = w.k8sPlugin.Metadata.Annotations
kjob.Spec.Template.Annotations = w.k8sPlugin.Metadata.Annotations
kjob.Spec.BackoffLimit = pointer.Int32(0)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    πŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. πŸ“ŠπŸ“ˆπŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❀️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.