This is an automated email from the ASF dual-hosted git repository. shahar1 pushed a commit to branch gha-arc-rebase in repository https://gitbox.apache.org/repos/asf/airflow-ci-infra.git
commit aec106759990dbfe529c1c8b4fe4cfb1a0cfd3df Author: Shahar Epstein <[email protected]> AuthorDate: Thu Sep 17 13:00:36 2026 +0300 Replace static node groups with Karpenter and ARC runner scale sets The previous design (cluster-autoscaler, six managed node groups, legacy ARC with cert-manager) could not scale from zero without ASG tag workarounds and had no spot-price-aware instance selection. The CodeBuild experiment measured ~$104 per full Tests (AMD) run against a ~$15k/yr CI budget. Karpenter with EKS Pod Identity and ARC's supported scale-set mode brings the same run to ~$12-13 with one NodePool per architecture and no cert-manager or EBS CSI dependency. The design rationale lives in docs/eks-runner-architecture.md and ADR 0001; the runner image now matches the host contract airflow's CI graph expects. --- Airflow_CI.png | Bin 80422 -> 0 bytes CONTEXT.md | 47 ++++ README.rst | 122 +++++------ ...ks-standard-mode-with-self-managed-karpenter.md | 40 ++++ docs/eks-runner-architecture.md | 244 +++++++++++++++++++++ helm/{ci-helmfile.yaml => ci-helmfile.yaml.gotmpl} | 15 +- .../templates/namespaces/{infra.yaml => arc.yaml} | 9 +- helm/cluster-config/templates/namespaces/ci.yaml | 26 --- helm/infra-helmfile.yaml | 42 ++-- .../Chart.yaml} | 9 +- .../templates/ec2nodeclass.yaml | 71 ++++++ .../templates/nodepool-amd-shared.yaml | 65 ++++++ .../templates/nodepool-amd.yaml | 63 ++++++ .../templates/nodepool-arm-shared.yaml | 65 ++++++ .../templates/nodepool-arm.yaml | 63 ++++++ .../values.yaml} | 30 +-- helm/values/cert-manager.yaml | 20 -- helm/values/gha-runner-scale-set-controller.yaml | 5 +- .../gha-runner-scale-sets/common.yaml.gotmpl | 137 ++++++++++-- helm/values/gha-runner-scale-sets/runners.yaml | 97 +++++--- .../{cluster-autoscaler.yaml => karpenter.yaml} | 28 +-- runner/Dockerfile | 88 +++++++- terraform/README.rst | 26 ++- terraform/eks/.terraform.lock.hcl | 145 ++++++------ terraform/eks/asg_tags.tf | 67 ------ terraform/eks/eks.tf | 233 ++++---------------- terraform/eks/iam.tf | 134 ----------- terraform/eks/{vpc.tf => karpenter.tf} | 38 ++-- terraform/eks/{vpc.tf => outputs.tf} | 51 ++--- terraform/eks/provider.tf | 13 +- terraform/eks/runner_pod_identity.tf | 113 ++++++++++ terraform/eks/state.tf | 16 +- terraform/eks/variables.tf | 48 +--- terraform/eks/version.tf | 5 +- terraform/eks/vpc.tf | 22 +- 35 files changed, 1376 insertions(+), 821 deletions(-) diff --git a/Airflow_CI.png b/Airflow_CI.png deleted file mode 100644 index 82e5043..0000000 Binary files a/Airflow_CI.png and /dev/null differ diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..901412a --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,47 @@ +# Airflow CI Infrastructure + +Self-hosted GitHub Actions runner infrastructure (EKS + ARC + Karpenter) that offloads +selected Apache Airflow CI workloads from GitHub-hosted runners to AWS. + +## Language + +### Workload categories + +**Trusted run**: +A workflow run whose code ref is not attacker-controllable: `schedule`, `push`, or +`workflow_dispatch` events on `apache/airflow` itself. The only category eligible for +self-hosted runners in phase 1. +_Avoid_: using "trusted" for committer-authored PRs — those are Committer runs. + +**Canary run**: +A full-matrix CI run of main, triggered by cron (`schedule`) or by a push to main. +"Scheduled canary" and "push canary" distinguish the trigger when it matters. + +**Committer run**: +A CI run of a PR authored by someone with merge access (regardless of whether it comes +from a fork or a branch). Not a Trusted run — the PR code is still unreviewed; serving +these from self-hosted runners is a separate, unresolved decision. +_Avoid_: trusted PR, member run. + +**Fork PR run**: +A CI run of a PR from a contributor without merge access. Never eligible for +self-hosted runners. + +### Infrastructure + +**Runner scale set**: +An ARC unit of runner capacity targeted by a single `runs-on` label (e.g. +`airflow-runner-amd-xl`). One helm release per scale set. +_Avoid_: runner deployment (legacy ARC term), runner pool. + +**Runner class**: +The size tier of a scale set, expressed as pod resource requests (CPU/memory), not as a +node type. Karpenter chooses the actual instance. + +**System node group**: +The small always-on EKS managed node group hosting the controllers (ARC, Karpenter, +listeners). Everything else runs on Karpenter-provisioned nodes. + +**Stash**: +The S3-backed mechanism for passing artifacts between jobs of a run (drop-in for the +`apache/infrastructure-actions/stash` actions). diff --git a/README.rst b/README.rst index 729c657..f3c1e43 100644 --- a/README.rst +++ b/README.rst @@ -53,7 +53,7 @@ you need to follow the steps below: 3. Update kubeconfig to access the EKS cluster:: - AWS_PROFILE=airflow aws eks --region us-east-2 update-kubeconfig --name airflow --alias airflow + AWS_PROFILE=airflow aws eks --region us-east-2 update-kubeconfig --name airflow-ci --alias airflow-ci This command will update the kubeconfig file located at `~/.kube/config` to include the EKS cluster. You can verify that the cluster is added by running `kubectl get nodes`. @@ -65,82 +65,82 @@ you need to follow the steps below: Airflow CI infrastructure architecture -------------------------------------- -.. image:: Airflow_CI.png - :width: 100% - :align: center - :alt: Airflow CI infrastructure architecture -Manage the infrastructure resources -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The design is documented in `docs/eks-runner-architecture.md <docs/eks-runner-architecture.md>`_. +In short: an EKS cluster (standard mode) with a small always-on system node group runs +`Actions Runner Controller <https://github.com/actions/actions-runner-controller>`_ (runner +scale sets mode) and `Karpenter <https://karpenter.sh>`_; runner pods are ephemeral +docker-in-docker pods, one job per pod, on spot nodes that Karpenter provisions on demand +and reclaims when idle. -We use Terraform to manage the EKS cluster and other AWS resources, and Helm (with helmfile) to manage the -Kubernetes resources inside the cluster. +Deploying from scratch +~~~~~~~~~~~~~~~~~~~~~~ -For Terraform, you can find the detailed documentation here: `Terraform Documentation <terraform/README.rst>`_ +We use Terraform for the AWS resources and Helm (with helmfile) for everything inside the +cluster. The order matters on a fresh account: -And for Helm, you mainly need two commands: +1. **Terraform** — see `Terraform Documentation <terraform/README.rst>`_:: -.. code-block:: bash + cd terraform/eks + terraform init + terraform plan -out=terraform.tfplan + terraform apply terraform.tfplan - # diff the infra helmfile to compare the current state with the desired state - helmfile -f helm/infra-helmfile.yaml diff +2. **kubeconfig**:: - # apply the helmfiles to deploy the desired state - helmfile -f helm/infra-helmfile.yaml apply + aws eks --region us-east-2 update-kubeconfig --name airflow-ci --alias airflow-ci - # same for ci helmfile - helmfile -f helm/ci-helmfile.yaml diff - helmfile -f helm/ci-helmfile.yaml apply +3. **Infra helmfile** (namespaces, Karpenter, NodePools):: -For Docker, we build a custom image for the runners, based on the official `Dockerfiles`_ provided by Github, but -with the `Airflow runner release`_ managed by `Ash`_ instead of the official one, and with python installed on the image -(check `setup-python issue`_ for more details). + helmfile -f helm/infra-helmfile.yaml diff + helmfile -f helm/infra-helmfile.yaml apply -To build docker image, you can run the following command: +4. **GitHub App secret** — create a GitHub App (for repository-level runners: + ``Administration: Read & write`` + ``Metadata: Read-only``; for organization-level + runners: ``Self-hosted runners: Read & write``), install it on the target + repo/org, then:: -.. code-block:: bash + kubectl create secret generic github-config --namespace arc-runners \ + --from-literal=github_app_id=<app-id> \ + --from-literal=github_app_installation_id=<installation-id> \ + --from-file=github_app_private_key=<path-to-private-key.pem> - export TARGETPLATFORM=linux/arm64 - export AIRFLOW_RUNNER_VERSION=2.304.0-airflow8 - docker build --platform $TARGETPLATFORM \ - --build-arg AIRFLOW_RUNNER_VERSION=$AIRFLOW_RUNNER_VERSION \ - -t public.ecr.aws/u9s5q9f7/airflow-gha-runner:$AIRFLOW_RUNNER_VERSION \ - runner --push +5. **CI helmfile** (ARC controller + runner scale sets):: -.. _Dockerfiles: https://github.com/actions/actions-runner-controller/tree/master/runner + helmfile -f helm/ci-helmfile.yaml.gotmpl diff + helmfile -f helm/ci-helmfile.yaml.gotmpl apply -.. _Airflow runner release: https://github.com/ashb/runner/tree/v2.304.0-airflow8 +The runner image extends the official ``ghcr.io/actions/actions-runner`` image with the +host tools airflow's CI expects (sudo, docker CLI + compose, python3, ...). Images are +**date-tagged, never ``:latest``** — the tag referenced in +``helm/values/gha-runner-scale-sets/runners.yaml`` must always be an explicit, reviewed +pin. Build and push:: -.. _Ash: https://github.com/ashb - -.. _setup-python issue: https://github.com/actions/setup-python/issues/705 + TAG=$(date +%Y%m%d) + docker build --platform linux/amd64 \ + -t ghcr.io/apache/airflow-ci-infra/actions-runner:${TAG} runner + docker push ghcr.io/apache/airflow-ci-infra/actions-runner:${TAG} + # then update the image tag in helm/values/gha-runner-scale-sets/runners.yaml Some important notes ~~~~~~~~~~~~~~~~~~~~ -There are some configuration parameters that you need to be aware of: - -- EKS node group instance type: - In our current architecture, we use different types of runners, and each set of runners is configured via a - RunnerDeployment. Instead of setting container resources, we define the node selectors to schedule the - runner pod on a specific node group, and we add an anti-affinity to ensure the pod uses all the node resources - without sharing them with other runners. In this case, we need to carefully choose the instance types because they - represent the runners' resources. - -- ARC sync period: - The ARC sync period is the duration in which the controller reconciles the desired runners count. Increasing - this period could lead to a delay in the runners count update (more time in the queue when needing a scale-up - and a waste of resources when needing a scale-down). Decreasing it could lead to a high load on the Github API. - -- Autoscaler conf: - Similar to the ARC sync period, the autoscaler `scan-interval` is the duration in which the autoscaler checks the - cluster resources and scales up or down the cluster. Additionally, two other important parameters are - `scale-down-unneeded-time` and `scale-down-delay-after-add`, which control the delay before taking the scale-down - decision when the node is underutilized and the delay of checking the scaling-down necessity after a scale-up - decision. - -- HorizontalRunnerAutoscaler scaleDownDelaySecondsAfterScaleOut: - This parameter represents the delay before the HorizontalRunnerAutoscaler (HRA) takes the scale-down decision after - adding a new runner. Currently, we use a small delay (30 seconds) because the K8S autoscaler waits for 5 minutes - before deleting the node. So, if we need a runner, the HRA will add a new one, and it will be scheduled in less - than 20 seconds. +- Runner sizing is done with pod **resource requests** (see + ``helm/values/gha-runner-scale-sets/runners.yaml``), not node selectors/anti-affinity: + Karpenter picks the cheapest spot instance that fits the request. The current single + ``airflow-runner-amd-xl`` class requests a whole 32-vCPU node per job. +- Scale-up path: job queued → ARC listener requests a runner pod → pod Pending → + Karpenter launches a node (~60-90s). Scale-down: pod finishes → node empty → + Karpenter consolidates it after 60s (``WhenEmpty`` — running jobs are never evicted). +- The dockerd inside each runner is configured (dind sidecar args in + ``helm/values/gha-runner-scale-sets/common.yaml.gotmpl``) with a registry mirror, + widened address pools and nofile ulimits — these fix real failures observed with + airflow's CI and should not be removed casually. +- Workflows target the runners with ``runs-on: <scale-set-name>``, e.g. + ``runs-on: airflow-runner-amd-xl``. +- **Spot policy (phase 1): spot-first for everything, deliberately.** A spot reclaim + kills the job mid-run — the job shows red with a "runner lost communication" + annotation; just re-run the failed jobs. Tripwire: if interruptions exceed ~2/week + (or kill the same canary twice in a row), pin the heavy runner class to on-demand + (``karpenter.sh/capacity-type`` requirement in the NodePool) once size classes are + split. diff --git a/docs/adr/0001-eks-standard-mode-with-self-managed-karpenter.md b/docs/adr/0001-eks-standard-mode-with-self-managed-karpenter.md new file mode 100644 index 0000000..28f7b23 --- /dev/null +++ b/docs/adr/0001-eks-standard-mode-with-self-managed-karpenter.md @@ -0,0 +1,40 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--> + +# EKS standard mode with self-managed Karpenter, not EKS Auto Mode + +Status: accepted (2026-07-25) + +The CI runner cluster runs EKS in standard mode with Karpenter installed and +managed by us, even though EKS Auto Mode is AWS's recommended default and would +remove Karpenter, AMI, and addon management entirely. Auto Mode nodes are +locked-down Bottlerocket appliances — SELinux enforcing, immutable read-only +root, no SSH/SSM, no node-level customization — and our runner pods are +privileged docker-in-docker workloads that run KinD (Kubernetes *inside* that +dind) and require node sysctls (`fs.inotify.*`, `nf_conntrack`, `fs.nr_open`); +this is precisely the workload class that fights a locked host, and it is +battle-tested on plain AL2023 with our own user data. Auto Mode also adds a +per-instance management fee (~10–12% of the instance price) to a +compute-dominated, budget-capped bill. + +Revisit if the workload ever stops needing privileged dind (e.g. jobs move to +rootless/kaniko-style builds and KinD is replaced), or if Auto Mode gains +supported node-level sysctl/privileged-workload customization at a fee that +undercuts our measured operational cost of self-managing Karpenter +(~a few hours per quarter). diff --git a/docs/eks-runner-architecture.md b/docs/eks-runner-architecture.md new file mode 100644 index 0000000..c9a71d8 --- /dev/null +++ b/docs/eks-runner-architecture.md @@ -0,0 +1,244 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--> + +# EKS-based GitHub Actions runners for Airflow CI — architecture + +*Status: proposal (2026-07). Supersedes the 2023/2024 design on this branch (PR #59). +All versions and prices below were checked against official sources on 2026-07-25.* + +## Why this exists + +Airflow's canary/scheduled CI competes for the shared ASF GitHub Actions concurrency +pool. An AWS CodeBuild-managed-runner experiment (June–July 2026) proved the mechanics +work but the economics don't: a full "Tests (AMD)" run costs **≈$104** on CodeBuild +(1,300 build-minutes on 36 vCPU machines at $0.0798/min), ≈$135k+/yr for scheduled +runs alone — far beyond the ~$15k/yr CI budget envelope. Measured per-job utilization +shows a **7.3× oversizing waste** (median job peaks at ~3 vCPU / 2.3 GiB on a +36 vCPU / 72 GiB box) on top of CodeBuild's ~6.6× per-vCPU price premium over EC2 spot. +EKS + spot brings the same run to **≈$7–13**. + +## Architecture at a glance + +```mermaid +flowchart LR + subgraph github["GitHub"] + actions["GitHub Actions service"] + workflow["apache/airflow workflows<br/>runs-on: airflow-runner-amd-xl"] + workflow -->|"queue job"| actions + end + + subgraph aws["AWS account · us-east-2"] + subgraph vpc["airflow-ci-vpc — public subnets, no NAT"] + subgraph eks["EKS cluster 'airflow-ci' v1.34 (standard mode)"] + subgraph system["System node group — 2× t4g.medium on-demand"] + controller["ARC controller<br/>(arc-systems)"] + listener["ARC listener<br/>per scale set"] + karpenter["Karpenter v1.11<br/>(kube-system)"] + end + subgraph arcrunners["arc-runners namespace"] + secret["Secret github-config<br/>(GitHub App creds)"] + runner["Ephemeral runner pod<br/>runner + privileged dind sidecar<br/>requests 28 vCPU / 56 Gi · 1 job"] + end + node["Spot node — cheapest of<br/>c/m/r 6th-7th gen ≥ 8xlarge<br/>AL2023 · 200 Gi gp3 · IMDS hop 1"] + end + end + sqs["SQS queue<br/>airflow-ci-karpenter<br/>(spot interruptions)"] + s3[("S3 stash bucket<br/>airflow-ci-runner-cache-us-east-2")] + end + + listener -->|"outbound long-poll<br/>(no inbound access)"| actions + listener -->|"job queued:<br/>request runner"| runner + secret -.-> listener + runner -.->|"Pending pod"| karpenter + karpenter -->|"launch / consolidate<br/>(WhenEmpty, 60s)"| node + runner ---|"scheduled on"| node + sqs --> karpenter + runner -->|"EKS Pod Identity<br/>(scoped S3 only)"| s3 +``` + +Terraform (`terraform/eks/`) creates everything under **AWS**; helmfile +(`helm/infra-helmfile.yaml`, `helm/ci-helmfile.yaml.gotmpl`) deploys everything inside +**EKS**; the `github-config` secret is created once by hand at bootstrap. + +- **Terraform** (this repo, `terraform/eks/`): VPC, EKS cluster, system node group, + Karpenter controller IAM (Pod Identity), node IAM role, spot-interruption SQS queue. +- **Helmfile** (this repo, `helm/`): Karpenter, ARC controller, runner scale sets, + Karpenter `NodePool`/`EC2NodeClass` CRs, namespaces. +- **Airflow repo**: `runs-on: airflow-runner-amd-xl` on the Tests (AMD) job graph + (experiment branch; later behind the existing `selective_checks` runner seam for + canary-only routing). + +## Key decisions (and what was rejected) + +### EKS standard mode + self-managed Karpenter — not EKS Auto Mode + +Auto Mode was seriously considered (it removes Karpenter/AMI/addon management) and +rejected for this workload: + +- Auto Mode nodes are locked-down Bottlerocket: SELinux enforcing, immutable root FS, + no SSH/SSM. CI runners are privileged docker-in-docker pods that run KinD + (Kubernetes-in-Docker-in-Docker) and need node sysctls (`fs.inotify.*`, + `nf_conntrack`, `fs.nr_open`) — exactly the class of workload that fights a locked + host. Standard AL2023 nodes with our own user data are the proven path. +- Auto Mode adds a per-instance management fee (~10–12% of the instance price) on a + budget-constrained, compute-dominated bill. +- We already need Helm/CRD management for ARC; Karpenter via the same helmfile is + marginal extra complexity. + +### Karpenter v1.11 — not cluster-autoscaler + 6 static node groups + +The previous design (this branch pre-rebase) used six per-size/arch managed node +groups, ASG label/taint tags for scale-from-zero (`asg_tags.tf`), and +cluster-autoscaler. Karpenter replaces all of it: + +- Spot-price-aware instance selection across families (the "instance diversity" + discussion in PR #59 becomes a one-line requirement list). +- Scale-from-zero without ASG tag gymnastics → `asg_tags.tf` deleted. +- Consolidation and native spot-interruption handling (SQS) built in. +- One `NodePool` instead of N node groups; an ARM pool later is ~10 lines. + +### ARC runner scale sets 0.14.x — still the supported approach in 2026 + +`gha-runner-scale-set` (Autoscaling Runner Scale Sets mode) remains GitHub's +maintained ARC mode (latest chart 0.14.2, 2026-05). Ephemeral runners, JIT +registration, no cert-manager/webhook dependency (that was legacy ARC). Auth via a +**GitHub App** (recommended over PAT): for repo-level runners it needs +*Administration: R/W* + *Metadata: R*; for org-level, *Self-hosted runners: R/W*. + +### Utilities: what's in, what's out + +| Utility | Verdict | Why | +|---|---|---| +| Karpenter | **In** | Node autoscaling; replaces CA + static node groups; spot handling. Cost: negligible (runs on system nodes). | +| ARC (scale sets) | **In** | The runner integration itself. | +| metrics-server | **Out** | ARC scales on the job queue, not resource metrics; no HPA anywhere. Add only if we later want `kubectl top`. | +| cert-manager | **Out** (removed from branch) | Only legacy-mode ARC needed it for admission webhooks. Scale-set mode doesn't. | +| EBS CSI driver | **Out** (removed from branch) | No PersistentVolumes: runner work dirs + dind storage are node-local ephemeral (200 GiB gp3 root via `EC2NodeClass`). | +| cluster-autoscaler | **Out** (replaced) | See Karpenter. | +| External Secrets Operator | **Out** | Exactly one static secret (GitHub App key). ESO adds a controller + still needs a backing store + IAM. `kubectl create secret` at bootstrap is simpler and rotated rarely. Revisit if secrets multiply. | +| AWS Load Balancer Controller | **Out** | Nothing is exposed; ARC listeners do outbound long-poll only. No Service/Ingress of type LB. | + +### Networking: public subnets, no NAT — kept deliberately + +Nodes launch in public subnets with public IPs (as in the original PR, which removed +the NAT gateway). A NAT gateway costs ~$395/yr + data processing; public IPv4 on +ephemeral nodes costs $0.005/h only while nodes exist (≈$40/yr at our duty cycle). +Security posture is acceptable because: security groups allow no inbound; runners +only make outbound calls (GitHub long-poll, image pulls); nodes use IMDSv2 with hop +limit 1, so **pods (i.e. CI jobs) cannot reach node IAM credentials**; and the +node role carries only EKS/ECR/CNI policies. + +### Compute: one runner class now, right-sizing later + +Measured mix of a full Tests (AMD) run (duration-weighted minutes by required vCPU +class): `2:787, 4:209, 8:179, 16:96, 32:22, 48:6`. The cost-optimal design is 2–3 +scale-set size classes, but that requires per-job `runs-on` routing in the Airflow +repo. To keep the Airflow diff minimal for the experiment, we start with a **single +class sized so no job regresses** vs the CodeBuild baseline (32+ vCPU nodes): + +- Scale set `airflow-runner-amd-xl`: requests **28 vCPU / 56 Gi** → Karpenter picks + the cheapest current-gen ≥8xlarge (c6a/c7a/m6a/m7a/r6a/r7a, 32 vCPU) spot node; + one runner pod per node (requests-based, no anti-affinity hack). +- Spot first with automatic on-demand fallback (`capacity-type: [spot, on-demand]`). + Consolidation is `WhenEmpty` only — running jobs are never evicted to repack. +- maxRunners 100 (measured burst peak: 77 concurrent on the reduced run, ~163 on a + full canary; raise when canary-scale traffic is routed here). + +Cost at measured volume: ~21.7 node-hours per full run × ~$0.55–0.60/h (8xlarge +spot) ≈ **$12–13/run** vs $104 on CodeBuild — before phase-2 right-sizing, which +takes it to ~$4–5. + +**ARM later: yes, worth it.** Graviton spot is 10–25% cheaper per vCPU and the ARM +canary exists. It's one extra `NodePool` (arch: arm64) + one scale set + an ARM +runner image tag. Deliberately out of scope until AMD is proven. + +### dind configuration carries the CodeBuild lessons + +The runner pods define an explicit dind sidecar (not the chart's default) so dockerd +starts with flags that fix real failures seen on CodeBuild: + +- `--registry-mirror=https://mirror.gcr.io` — Docker Hub anonymous pull limits on + shared cloud egress IPs (also covers in-container `testcontainers` pulls). +- `--default-address-pool base=172.16.0.0/14,size=24` — parallel breeze compose + networks exhaust Docker's default ~31 subnets. +- `--default-ulimit nofile=65536:1048576` — venv-heavy tests exhaust fds; soft cap + kept moderate to avoid the close()-loop spawn trap. +- cgroup-v2 delegation for KinD: `docker:dind`'s entrypoint handles this correctly + (the CodeBuild failure existed precisely because its dockerd was *not* dind). +- Node sysctls (`fs.inotify.max_user_watches/instances`, `nf_conntrack_tcp_be_liberal`, + `fs.nr_open`, `fs.file-max`) are set in `EC2NodeClass` user data. + +### Terraform layout: one root module, S3 backend + +Single root module at `terraform/eks/` (no premature module split — the AWS-side +footprint is ~5 resources plus two registry modules). Existing S3 backend +(`airflow-ci-tfstate` / DynamoDB `TerraformLocks`, us-east-2) is kept for the real +deployment; it still holds the **stale 2024 state** of the deleted cluster (the +`airflow-vpc` 10.1.0.0/16 VPC still exists in the account!) — reconcile before the +first real apply (import-or-destroy; see README). Local iteration uses a +`*_override.tf` local backend so `plan` runs without touching shared state. The +provider no longer hardcodes `profile = "airflow"` — use `AWS_PROFILE`/env, which +also works for a future GitHub Actions plan/apply workflow via OIDC role assumption. + +Versions: Terraform ≥ 1.9, AWS provider ~> 6.0, `terraform-aws-modules/eks` ~> 21.24 +(EKS 1.34, standard support to 2027-12), `terraform-aws-modules/vpc` ~> 6.6, +Karpenter 1.11.3, ARC chart 0.14.2. + +### Security & trust boundary + +- Runners serve **trusted refs only**: scheduled/canary runs and (later) explicitly + label-gated committer PRs. Fork PRs stay on GitHub-hosted runners. For the org + deployment, the scale sets sit in a dedicated runner group restricted to + apache/airflow with "public repositories: selected workflows" disabled. +- GitHub App over PAT: scoped, auditable, revocable; key lives only in the + `arc-runners` namespace secret. +- Ephemeral runners: one job per pod, pod deleted after the job — no state bleed. +- Jobs can't reach node IAM credentials (IMDS hop limit 1); the node role has no + access beyond EKS basics; the AWS account contains no Airflow production secrets. +- Cluster API endpoint is public + access entries (admin = cluster creator + + explicitly added PMC operators); no aws-auth ConfigMap. + +## Scaling behavior + +1. Job queued on GitHub → ARC listener (long-poll) sees it, requests a runner pod. +2. Pod is Pending (no capacity) → Karpenter launches the cheapest fitting spot + instance (~60–90 s to Ready including image pull). +3. Job runs in the ephemeral runner (dind sidecar provides Docker). +4. Pod completes and is deleted → node goes empty → Karpenter consolidates it away + after 60 s. Idle cluster = 2× t4g.medium + control plane ≈ **$1.6k/yr fixed**. +5. Spot interruption → SQS → Karpenter drains/replaces; the interrupted job fails + and is retried by the workflow's normal retry/re-run mechanisms (acceptable for + canary; pin the pool to on-demand if measured interruption rates hurt). + +## Costs (measured workload, official prices) + +| | CodeBuild (today) | EKS this design | EKS after right-sizing | +|---|---|---|---| +| Full Tests (AMD) run | $104 | ~$12–13 | ~$4–5 | +| Scheduled-only, 2 AMD + 2 ARM per day, /yr | ~$135k+ | ~$20k | ~$9–11k | +| Fixed (cluster + system nodes + misc) | — | ~$1.8–2.1k/yr | same | + +## What changes in the Airflow repo (experiment branch) + +Only the Tests (AMD) job graph: `runs-on` swapped from the CodeBuild label array to +`airflow-runner-amd-xl`, and the CodeBuild-specific host guards extended to cover +the ARC runner environment. Everything else (selective checks, breeze, tests) is +untouched. The long-term mechanism is the existing `selective_checks` runner seam +(static labels work with ARC, unlike CodeBuild's per-run label), flipped for canary +runs only. diff --git a/helm/ci-helmfile.yaml b/helm/ci-helmfile.yaml.gotmpl similarity index 76% rename from helm/ci-helmfile.yaml rename to helm/ci-helmfile.yaml.gotmpl index e1da5a0..c707c9f 100644 --- a/helm/ci-helmfile.yaml +++ b/helm/ci-helmfile.yaml.gotmpl @@ -20,19 +20,26 @@ environments: values: - ./values/gha-runner-scale-sets/runners.yaml +# helmfile v1 requires environments and releases in separate YAML documents. +--- releases: - name: actions-runner-controller chart: oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller - namespace: ci - version: 0.9.3 + namespace: arc-systems + version: 0.14.2 values: - ./values/gha-runner-scale-set-controller.yaml {{- range $key, $value := .Values.runnerScaleSets }} - name: {{ $key }} chart: oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set - namespace: ci - version: 0.9.3 + namespace: arc-runners + version: 0.14.2 + needs: + - arc-systems/actions-runner-controller + # AutoscalingRunnerSet CRD ships with the controller chart above — skip + # upfront validation so a fresh cluster can install in one pass. + disableValidationOnInstall: true values: - ./values/gha-runner-scale-sets/common.yaml.gotmpl {{- end }} diff --git a/helm/cluster-config/templates/namespaces/infra.yaml b/helm/cluster-config/templates/namespaces/arc.yaml similarity index 85% rename from helm/cluster-config/templates/namespaces/infra.yaml rename to helm/cluster-config/templates/namespaces/arc.yaml index f6bd908..50450f3 100644 --- a/helm/cluster-config/templates/namespaces/infra.yaml +++ b/helm/cluster-config/templates/namespaces/arc.yaml @@ -18,9 +18,14 @@ */}} ################################################################################ -# infra namespace where we create the infrastructure resources +# arc-systems: ARC controller + listeners; arc-runners: runner pods + secrets ################################################################################ apiVersion: v1 kind: Namespace metadata: - name: infra + name: arc-systems +--- +apiVersion: v1 +kind: Namespace +metadata: + name: arc-runners diff --git a/helm/cluster-config/templates/namespaces/ci.yaml b/helm/cluster-config/templates/namespaces/ci.yaml deleted file mode 100644 index 123ddc1..0000000 --- a/helm/cluster-config/templates/namespaces/ci.yaml +++ /dev/null @@ -1,26 +0,0 @@ -{{/* - Licensed to the Apache Software Foundation (ASF) under one - or more contributor license agreements. See the NOTICE file - distributed with this work for additional information - regarding copyright ownership. The ASF licenses this file - to you under the Apache License, Version 2.0 (the - "License"); you may not use this file except in compliance - with the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, - software distributed under the License is distributed on an - "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - KIND, either express or implied. See the License for the - specific language governing permissions and limitations - under the License. -*/}} - -################################################################################ -# ci namespace where we create the CI resources -################################################################################ -apiVersion: v1 -kind: Namespace -metadata: - name: ci diff --git a/helm/infra-helmfile.yaml b/helm/infra-helmfile.yaml index d8c49e5..100dd84 100644 --- a/helm/infra-helmfile.yaml +++ b/helm/infra-helmfile.yaml @@ -15,38 +15,26 @@ # specific language governing permissions and limitations # under the License. --- -repositories: - - name: jetstack - url: https://charts.jetstack.io - - name: autoscaler - url: https://kubernetes.github.io/autoscaler - - name: aws-ebs-csi-driver - url: https://kubernetes-sigs.github.io/aws-ebs-csi-driver - releases: - name: cluster-config chart: ./cluster-config namespace: kube-system - # https://github.com/kubernetes-sigs/aws-ebs-csi-driver/tree/master/charts/aws-ebs-csi-driver - - name: aws-ebs-csi-driver - chart: aws-ebs-csi-driver/aws-ebs-csi-driver + # https://karpenter.sh/docs/getting-started/ + # IAM/SQS for Karpenter comes from terraform/eks/karpenter.tf (Pod Identity). + - name: karpenter + chart: oci://public.ecr.aws/karpenter/karpenter namespace: kube-system + version: 1.11.3 values: - - ./values/ebs-csi-dirver.yaml - - # https://cert-manager.io/docs/installation/helm/ - - name: cert-manager - chart: jetstack/cert-manager - namespace: infra - version: v1.15.3 - values: - - ./values/cert-manager.yaml + - ./values/karpenter.yaml - # https://github.com/kubernetes/autoscaler/tree/master/charts/cluster-autoscaler - - name: autoscaler - chart: autoscaler/cluster-autoscaler - namespace: infra - version: 9.37.0 - values: - - ./values/cluster-autoscaler.yaml + # NodePool/EC2NodeClass CRs — depends on the Karpenter CRDs. + - name: karpenter-resources + chart: ./karpenter-resources + namespace: kube-system + needs: + - kube-system/karpenter + # On a fresh cluster the CRDs don't exist until the karpenter release above + # installs, so the upfront diff/validation of this release must be skipped. + disableValidationOnInstall: true diff --git a/helm/values/ebs-csi-dirver.yaml b/helm/karpenter-resources/Chart.yaml similarity index 83% rename from helm/values/ebs-csi-dirver.yaml rename to helm/karpenter-resources/Chart.yaml index 7f8f0d0..e33e60d 100644 --- a/helm/values/ebs-csi-dirver.yaml +++ b/helm/karpenter-resources/Chart.yaml @@ -15,7 +15,8 @@ # specific language governing permissions and limitations # under the License. --- -controller: - serviceAccount: - annotations: - eks.amazonaws.com/role-arn: arn:aws:iam::827901512104:role/ebs-csi-driver +apiVersion: v2 +name: karpenter-resources +description: Karpenter NodePools and EC2NodeClasses for the Airflow CI runners +type: application +version: 0.1.0 diff --git a/helm/karpenter-resources/templates/ec2nodeclass.yaml b/helm/karpenter-resources/templates/ec2nodeclass.yaml new file mode 100644 index 0000000..b5058dd --- /dev/null +++ b/helm/karpenter-resources/templates/ec2nodeclass.yaml @@ -0,0 +1,71 @@ +{{/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/}} +apiVersion: karpenter.k8s.aws/v1 +kind: EC2NodeClass +metadata: + name: ci +spec: + amiSelectorTerms: + - alias: al2023@latest + role: {{ .Values.nodeIamRoleName }} + subnetSelectorTerms: + - tags: + karpenter.sh/discovery: {{ .Values.clusterName }} + securityGroupSelectorTerms: + - tags: + karpenter.sh/discovery: {{ .Values.clusterName }} + associatePublicIPAddress: true + metadataOptions: + httpTokens: required + # Hop limit 1 keeps node IAM credentials unreachable from the runner + # containers (CI jobs must not get AWS access via IMDS). + httpPutResponseHopLimit: 1 + blockDeviceMappings: + - deviceName: /dev/xvda + ebs: + volumeSize: {{ .Values.nodeVolumeSize }} + volumeType: gp3 + iops: 4000 + throughput: 250 + encrypted: true + deleteOnTermination: true + userData: | + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="BOUNDARY" + + --BOUNDARY + Content-Type: text/x-shellscript; charset="us-ascii" + + #!/bin/bash + # Kernel limits for KinD clusters and fd-heavy test suites; GitHub-hosted + # runners ship these, stock AL2023 does not. + modprobe nf_conntrack || true + sysctl -w fs.inotify.max_user_watches=524288 + sysctl -w fs.inotify.max_user_instances=512 + sysctl -w net.netfilter.nf_conntrack_tcp_be_liberal=1 || true + sysctl -w fs.nr_open=2097152 + sysctl -w fs.file-max=2097152 + # Disable core dumps: AL2023's default systemd-coredump handler is + # kernel-global, so a SEGV inside any container dumps the whole process + # through the node's journald first — under test-suite disk load that + # takes >60s and broke task-sdk's test_exit_by_signal[segv] (GitHub's + # Ubuntu runners short-circuit via apport instead). CI nodes are + # ephemeral; nobody reads their dumps. + sysctl -w "kernel.core_pattern=|/bin/false" + --BOUNDARY-- diff --git a/helm/karpenter-resources/templates/nodepool-amd-shared.yaml b/helm/karpenter-resources/templates/nodepool-amd-shared.yaml new file mode 100644 index 0000000..b2f2eab --- /dev/null +++ b/helm/karpenter-resources/templates/nodepool-amd-shared.yaml @@ -0,0 +1,65 @@ +{{/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/}} +{{/* + On-demand pool for the bin-packed small/medium runner classes. Shared nodes + concentrate many jobs, so a single spot reclaim kills them all at once and a + rerun re-packs the same jobs into the same vulnerable shape (measured + 2026-07-27/28: reclaim storms of 5-57 events wiped whole retry batches + repeatedly; the on-demand premium is ~$1.5-2 per run). The xl class stays on + the spot-first `ci-amd` pool: one job per node, so reclaims stay cheap, + independent retries. +*/}} +apiVersion: karpenter.sh/v1 +kind: NodePool +metadata: + name: ci-amd-shared +spec: + template: + metadata: + labels: + airflow-ci/pool: amd-shared + spec: + nodeClassRef: + group: karpenter.k8s.aws + kind: EC2NodeClass + name: ci + taints: + - key: airflow-ci/runner + effect: NoSchedule + requirements: + - key: kubernetes.io/arch + operator: In + values: ["amd64"] + - key: kubernetes.io/os + operator: In + values: ["linux"] + - key: karpenter.sh/capacity-type + operator: In + values: ["on-demand"] + - key: karpenter.k8s.aws/instance-family + operator: In + values: ["c6a", "c7a", "m6a", "m7a", "r6a", "r7a", "c6i", "c7i", "m6i", "m7i"] + - key: karpenter.k8s.aws/instance-cpu + operator: In + values: ["8", "16", "32", "48"] + disruption: + consolidationPolicy: WhenEmpty + consolidateAfter: 60s + limits: + cpu: {{ .Values.amdSharedPoolCpuLimit | quote }} diff --git a/helm/karpenter-resources/templates/nodepool-amd.yaml b/helm/karpenter-resources/templates/nodepool-amd.yaml new file mode 100644 index 0000000..b053dba --- /dev/null +++ b/helm/karpenter-resources/templates/nodepool-amd.yaml @@ -0,0 +1,63 @@ +{{/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/}} +apiVersion: karpenter.sh/v1 +kind: NodePool +metadata: + name: ci-amd +spec: + template: + metadata: + labels: + airflow-ci/pool: amd + spec: + nodeClassRef: + group: karpenter.k8s.aws + kind: EC2NodeClass + name: ci + # Only runner pods (which tolerate this) land on these nodes, so they + # never pin cluster infra and can be reclaimed the moment a job ends. + taints: + - key: airflow-ci/runner + effect: NoSchedule + requirements: + - key: kubernetes.io/arch + operator: In + values: ["amd64"] + - key: kubernetes.io/os + operator: In + values: ["linux"] + # Spot first; Karpenter falls back to on-demand when spot is unavailable. + - key: karpenter.sh/capacity-type + operator: In + values: ["spot", "on-demand"] + - key: karpenter.k8s.aws/instance-family + operator: In + values: ["c6a", "c7a", "m6a", "m7a", "r6a", "r7a", "c6i", "c7i", "m6i", "m7i"] + # Small enough sizes for the small/medium runner classes to get + # right-sized (or bin-packed) nodes; xl pods still land on >= 32 vCPU + # because their 28-vCPU request cannot fit anything smaller. + - key: karpenter.k8s.aws/instance-cpu + operator: In + values: ["8", "16", "32", "48"] + disruption: + # Never repack running CI jobs; only reap empty nodes. + consolidationPolicy: WhenEmpty + consolidateAfter: 60s + limits: + cpu: {{ .Values.amdPoolCpuLimit | quote }} diff --git a/helm/karpenter-resources/templates/nodepool-arm-shared.yaml b/helm/karpenter-resources/templates/nodepool-arm-shared.yaml new file mode 100644 index 0000000..b1c402b --- /dev/null +++ b/helm/karpenter-resources/templates/nodepool-arm-shared.yaml @@ -0,0 +1,65 @@ +{{/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/}} +{{/* + On-demand pool for the bin-packed small/medium runner classes. Shared nodes + concentrate many jobs, so a single spot reclaim kills them all at once and a + rerun re-packs the same jobs into the same vulnerable shape (measured + 2026-07-27/28: reclaim storms of 5-57 events wiped whole retry batches + repeatedly; the on-demand premium is ~$1.5-2 per run). The xl class stays on + the spot-first `ci-arm` pool: one job per node, so reclaims stay cheap, + independent retries. +*/}} +apiVersion: karpenter.sh/v1 +kind: NodePool +metadata: + name: ci-arm-shared +spec: + template: + metadata: + labels: + airflow-ci/pool: arm-shared + spec: + nodeClassRef: + group: karpenter.k8s.aws + kind: EC2NodeClass + name: ci + taints: + - key: airflow-ci/runner + effect: NoSchedule + requirements: + - key: kubernetes.io/arch + operator: In + values: ["arm64"] + - key: kubernetes.io/os + operator: In + values: ["linux"] + - key: karpenter.sh/capacity-type + operator: In + values: ["on-demand"] + - key: karpenter.k8s.aws/instance-family + operator: In + values: ["c6g", "c7g", "c8g", "m6g", "m7g", "m8g", "r6g", "r7g", "r8g"] + - key: karpenter.k8s.aws/instance-cpu + operator: In + values: ["8", "16", "32", "48"] + disruption: + consolidationPolicy: WhenEmpty + consolidateAfter: 60s + limits: + cpu: {{ .Values.armSharedPoolCpuLimit | quote }} diff --git a/helm/karpenter-resources/templates/nodepool-arm.yaml b/helm/karpenter-resources/templates/nodepool-arm.yaml new file mode 100644 index 0000000..2d6e763 --- /dev/null +++ b/helm/karpenter-resources/templates/nodepool-arm.yaml @@ -0,0 +1,63 @@ +{{/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/}} +apiVersion: karpenter.sh/v1 +kind: NodePool +metadata: + name: ci-arm +spec: + template: + metadata: + labels: + airflow-ci/pool: arm + spec: + nodeClassRef: + group: karpenter.k8s.aws + kind: EC2NodeClass + name: ci + # Only runner pods (which tolerate this) land on these nodes, so they + # never pin cluster infra and can be reclaimed the moment a job ends. + taints: + - key: airflow-ci/runner + effect: NoSchedule + requirements: + - key: kubernetes.io/arch + operator: In + values: ["arm64"] + - key: kubernetes.io/os + operator: In + values: ["linux"] + # Spot first; Karpenter falls back to on-demand when spot is unavailable. + - key: karpenter.sh/capacity-type + operator: In + values: ["spot", "on-demand"] + - key: karpenter.k8s.aws/instance-family + operator: In + values: ["c6g", "c7g", "c8g", "m6g", "m7g", "m8g", "r6g", "r7g", "r8g"] + # Small enough sizes for the small/medium runner classes to get + # right-sized (or bin-packed) nodes; xl pods still land on >= 32 vCPU + # because their 28-vCPU request cannot fit anything smaller. + - key: karpenter.k8s.aws/instance-cpu + operator: In + values: ["8", "16", "32", "48"] + disruption: + # Never repack running CI jobs; only reap empty nodes. + consolidationPolicy: WhenEmpty + consolidateAfter: 60s + limits: + cpu: {{ .Values.armPoolCpuLimit | quote }} diff --git a/helm/values/cluster-autoscaler.yaml b/helm/karpenter-resources/values.yaml similarity index 51% copy from helm/values/cluster-autoscaler.yaml copy to helm/karpenter-resources/values.yaml index 2b4bf57..48c6b18 100644 --- a/helm/values/cluster-autoscaler.yaml +++ b/helm/karpenter-resources/values.yaml @@ -15,21 +15,21 @@ # specific language governing permissions and limitations # under the License. --- -cloudProvider: aws -awsRegion: us-east-2 +clusterName: airflow-ci -autoDiscovery: - clusterName: "airflow" +# Must match node_iam_role_name in terraform/eks/karpenter.tf. +nodeIamRoleName: airflow-ci-runner-node -rbac: - create: true - serviceAccount: - create: true - name: "cluster-autoscaler" - annotations: - eks.amazonaws.com/role-arn: arn:aws:iam::827901512104:role/eks-autoscaler-role +# Root volume for runner nodes: CI image + KinD images + parallel compose +# stacks need far more than the 20Gi AL2023 default (CodeBuild boxes had 256GB). +nodeVolumeSize: 200Gi -extraArgs: - scan-interval: 10s - scale-down-delay-after-add: 5m - scale-down-unneeded-time: 5m +# Upper bound on total simultaneously provisioned runner capacity +# (~100 concurrent 32-vCPU runner nodes). +amdPoolCpuLimit: "3200" +# On-demand shared pool (small/medium classes): sized for the measured +# full-matrix peak (~60 nodes averaging well under 48 vCPU each). +amdSharedPoolCpuLimit: "2000" +# ARM (Graviton) pools mirror the AMD split; experiment-sized like amd was. +armPoolCpuLimit: "3200" +armSharedPoolCpuLimit: "2000" diff --git a/helm/values/cert-manager.yaml b/helm/values/cert-manager.yaml deleted file mode 100644 index be548e5..0000000 --- a/helm/values/cert-manager.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. ---- -installCRDs: true -nodeSelector: - node-type: default diff --git a/helm/values/gha-runner-scale-set-controller.yaml b/helm/values/gha-runner-scale-set-controller.yaml index 63ab994..1ed8177 100644 --- a/helm/values/gha-runner-scale-set-controller.yaml +++ b/helm/values/gha-runner-scale-set-controller.yaml @@ -18,8 +18,7 @@ replicaCount: 1 labels: - type: ci app: gha-runner-scale-set-controller -nodeSelector: - node-type: default +# Watch all namespaces (scale sets live in arc-runners); no nodeSelector needed: +# runner nodes are tainted, so the controller lands on the system node group. diff --git a/helm/values/gha-runner-scale-sets/common.yaml.gotmpl b/helm/values/gha-runner-scale-sets/common.yaml.gotmpl index 9ed5238..1b23173 100644 --- a/helm/values/gha-runner-scale-sets/common.yaml.gotmpl +++ b/helm/values/gha-runner-scale-sets/common.yaml.gotmpl @@ -17,45 +17,134 @@ --- {{ $config := index .Values.runnerScaleSets .Release.Name }} -githubConfigUrl: https://github.com/apache +githubConfigUrl: {{ .Values.githubConfigUrl }} +# Pre-created secret in the arc-runners namespace holding the GitHub App +# credentials (github_app_id / github_app_installation_id / github_app_private_key). githubConfigSecret: github-config -runnerGroup: airflow-ci - minRunners: {{ $config.minRunners }} maxRunners: {{ $config.maxRunners }} -containerMode: - type: "dind" - +# The dind sidecar is spelled out (instead of `containerMode: dind`) so dockerd +# gets flags that fix real failures measured on the CodeBuild runners: +# --registry-mirror Docker Hub anonymous pull limits on shared cloud +# egress IPs (also covers in-container testcontainers +# pulls that cannot use host credentials). +# --default-address-pool parallel breeze compose stacks exhaust Docker's +# default ~31 network pools; /14 carved into /24s +# gives 1024 networks. Must not overlap the VPC CIDR +# (10.1.0.0/16). +# --default-ulimit nofile fd-heavy venv tests need >1024; the soft cap stays +# at 65536 because processes that close(0..soft) on +# spawn make a high soft limit a performance trap. template: + metadata: + annotations: + # Karpenter must never drain a node out from under a running CI job — + # not for drift (e.g. EC2NodeClass changes) and not for consolidation. + # Involuntary events (spot reclaim) are unaffected. + karpenter.sh/do-not-disrupt: "true" spec: + # CI jobs must not see this cluster's credentials: with the token mounted, + # kubernetes clients inside tests prefer in-cluster config over the KinD + # kubeconfig and hit the EKS API as the runner SA (403s broke the K8S + # system-test teardowns). EKS Pod Identity (S3 stash) uses its own + # separately-projected token and is unaffected. + automountServiceAccountToken: false + initContainers: + - name: init-dind-externals + image: {{ .Values.image }} + command: ["cp", "-r", "/home/runner/externals/.", "/home/runner/tmpDir/"] + volumeMounts: + - name: dind-externals + mountPath: /home/runner/tmpDir + - name: dind + image: docker:dind + args: + - dockerd + - --host=unix:///var/run/docker.sock + - --group=$(DOCKER_GROUP_GID) + - --registry-mirror=https://mirror.gcr.io + - --default-address-pool=base=172.16.0.0/14,size=24 + - --default-ulimit=nofile=65536:1048576 + # Classic image store (overlay2), matching GitHub-hosted runners. + # The containerd store (dind default) breaks `kind load docker-image`: + # docker save emits OCI manifests whose blobs ctr import --digests + # can't resolve ("content digest ... not found"). + - --feature=containerd-snapshotter=false + env: + - name: DOCKER_GROUP_GID + value: "123" + securityContext: + privileged: true + restartPolicy: Always + startupProbe: + exec: + command: ["docker", "info"] + periodSeconds: 5 + failureThreshold: 24 + volumeMounts: + - name: work + mountPath: /home/runner/_work + - name: dind-sock + mountPath: /var/run + - name: dind-externals + mountPath: /home/runner/externals + # /tmp is shared with the runner (see runner container) so that + # `docker run -v /tmp/...` host paths resolve — the daemon lives + # here, not in the runner container. + - name: shared-tmp + mountPath: /tmp containers: - name: runner image: {{ .Values.image }} - command: [ "/home/runner/run.sh" ] + command: ["/home/runner/run.sh"] + env: + - name: DOCKER_HOST + value: unix:///var/run/docker.sock + - name: RUNNER_WAIT_FOR_DOCKER_IN_SECONDS + value: "120" + # Lets airflow-repo CI scripts detect the self-hosted K8s runner + # (guards for host-specific steps like the /mnt docker relocation). + - name: AIRFLOW_SELF_HOSTED_RUNNER + value: "eks" + # Class-sized xdist worker cap; run_unit_tests.sh only defaults + # PARALLELISM when unset, so this wins over its self-hosted fallback. + - name: PARALLELISM + value: {{ $config.parallelism | quote }} + resources: + requests: + cpu: {{ $config.cpu | quote }} + memory: {{ $config.memory }} + volumeMounts: + - name: work + mountPath: /home/runner/_work + - name: dind-sock + mountPath: /var/run + # Shared with the dind sidecar: tests bind-mount pytest tmpdirs + # (docker run -v /tmp/...) and the daemon resolves those paths in + # ITS filesystem. On GitHub-hosted runners daemon and job share one + # host; this emptyDir recreates that contract for /tmp. + - name: shared-tmp + mountPath: /tmp + volumes: + - name: work + emptyDir: {} + - name: dind-sock + emptyDir: {} + - name: dind-externals + emptyDir: {} + - name: shared-tmp + emptyDir: {} nodeSelector: - node-type: gha-runners - size: {{ $config.size }} - arch: {{ $config.arch }} + airflow-ci/pool: {{ $config.pool }} tolerations: - - key: node-type - operator: Equal - value: gha-runners + - key: airflow-ci/runner + operator: Exists effect: NoSchedule - affinity: - podAntiAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - - labelSelector: - matchExpressions: - - key: actions.github.com/scale-set-name - operator: In - values: - - {{ .Release.Name }} - topologyKey: kubernetes.io/hostname controllerServiceAccount: name: actions-runner-controller-gha-rs-controller - namespace: {{ .Release.Namespace }} + namespace: arc-systems diff --git a/helm/values/gha-runner-scale-sets/runners.yaml b/helm/values/gha-runner-scale-sets/runners.yaml index 3c475fe..4d86536 100644 --- a/helm/values/gha-runner-scale-sets/runners.yaml +++ b/helm/values/gha-runner-scale-sets/runners.yaml @@ -15,35 +15,70 @@ # specific language governing permissions and limitations # under the License. --- -image: ghcr.io/apache/airflow-ci-infra/actions-runner:20240902-rc1 +# Always a date-pinned tag, never :latest — an unreviewed image change must not +# be able to break every canary at once. Rebuild monthly or when ARC's minimum +# runner version forces it. +# Fork-experiment override (public package on the fork owner's ghcr); switch to +# ghcr.io/apache/airflow-ci-infra/actions-runner:<tag> for the production deploy. +image: ghcr.io/shahar1/airflow-ci-actions-runner:20260731.2 + +# Repo URL for the fork experiment; switch to https://github.com/apache +# (org-level, with a restricted runner group) for the production deployment. +githubConfigUrl: https://github.com/shahar1/airflow + +# Three size classes, assigned per job from the measured per-job peaks of the +# 2026-07-26 go/no-go runs (airflow repo files/eks-class-mapping.md): each job's +# runs-on picks the smallest class fitting peak x 1.3 headroom on both CPU and +# memory (Non-DB providers is memory-bound at 35 GiB, hence xl). The xl request +# fills a whole 32-vCPU node (one job = one node); small/medium pods bin-pack +# when Karpenter batches several pending jobs onto one larger spot node. +# parallelism feeds the PARALLELISM env (xdist worker cap in run_unit_tests.sh) +# so test parallelism matches the class instead of oversubscribing small nodes. runnerScaleSets: - arc-small-amd: - minRunners: 0 - maxRunners: 30 - size: small - arch: x64 - arc-medium-amd: - minRunners: 0 - maxRunners: 30 - size: medium - arch: x64 - arc-large-amd: - minRunners: 0 - maxRunners: 30 - size: large - arch: x64 - arc-small-arm: - minRunners: 0 - maxRunners: 30 - size: small - arch: arm64 - arc-medium-arm: - minRunners: 0 - maxRunners: 30 - size: medium - arch: arm64 - arc-large-arm: - minRunners: 0 - maxRunners: 30 - size: large - arch: arm64 + # small/medium bin-pack onto shared nodes -> on-demand pool (a spot reclaim + # of a shared node kills every job on it and reruns re-pack the same batch; + # see nodepool-amd-shared.yaml). + airflow-runner-amd-small: + minRunners: 0 + maxRunners: 60 + pool: amd-shared + cpu: "4" + memory: 16Gi + parallelism: "4" + airflow-runner-amd-medium: + minRunners: 0 + maxRunners: 80 + pool: amd-shared + cpu: "8" + memory: 32Gi + parallelism: "8" + airflow-runner-amd-xl: + minRunners: 0 + maxRunners: 100 + pool: amd + cpu: "28" + memory: 56Gi + parallelism: "16" + # ARM (Graviton) classes mirror the AMD layout; the image tag is a + # multi-arch manifest so the same reference serves both architectures. + airflow-runner-arm-small: + minRunners: 0 + maxRunners: 60 + pool: arm-shared + cpu: "4" + memory: 16Gi + parallelism: "4" + airflow-runner-arm-medium: + minRunners: 0 + maxRunners: 80 + pool: arm-shared + cpu: "8" + memory: 32Gi + parallelism: "8" + airflow-runner-arm-xl: + minRunners: 0 + maxRunners: 100 + pool: arm + cpu: "28" + memory: 56Gi + parallelism: "16" diff --git a/helm/values/cluster-autoscaler.yaml b/helm/values/karpenter.yaml similarity index 68% rename from helm/values/cluster-autoscaler.yaml rename to helm/values/karpenter.yaml index 2b4bf57..4c5780f 100644 --- a/helm/values/cluster-autoscaler.yaml +++ b/helm/values/karpenter.yaml @@ -15,21 +15,17 @@ # specific language governing permissions and limitations # under the License. --- -cloudProvider: aws -awsRegion: us-east-2 +settings: + clusterName: airflow-ci + # Must match queue_name in terraform/eks/karpenter.tf. + interruptionQueue: airflow-ci-karpenter -autoDiscovery: - clusterName: "airflow" +# IAM comes from the EKS Pod Identity association created by Terraform — +# no serviceAccount role annotation needed. -rbac: - create: true - serviceAccount: - create: true - name: "cluster-autoscaler" - annotations: - eks.amazonaws.com/role-arn: arn:aws:iam::827901512104:role/eks-autoscaler-role - -extraArgs: - scan-interval: 10s - scale-down-delay-after-add: 5m - scale-down-unneeded-time: 5m +# Fits the 2x t4g.medium system nodes. +controller: + resources: + requests: + cpu: 500m + memory: 768Mi diff --git a/runner/Dockerfile b/runner/Dockerfile index 683028a..6fc9126 100644 --- a/runner/Dockerfile +++ b/runner/Dockerfile @@ -19,17 +19,97 @@ FROM ghcr.io/actions/actions-runner:latest USER root +# Host-level tools the airflow CI workflows expect on a runner — the target +# contract is GitHub's hosted ubuntu-24.04 image, restricted to what the CI +# graph actually invokes (pip, uv-via-pip, aws, parallel, docker CLI). Jobs +# also run `sudo apt-get install ...` themselves (as on GitHub-hosted +# runners), so the runner user gets passwordless sudo instead of us baking in +# every package. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - ca-certificates curl nodejs npm wget unzip vim git jq build-essential netcat subversion \ + sudo ca-certificates curl wget zip unzip zstd git jq parallel build-essential netcat-openbsd \ libssl-dev libffi-dev python3 python3-venv python3-dev python3-pip python-is-python3 \ - && npm install -g yarn \ && install -m 0755 -d /etc/apt/keyrings \ && curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \ && chmod a+r /etc/apt/keyrings/docker.asc \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \ && apt-get update \ - && apt-get install -y docker-compose-plugin + && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin docker-buildx-plugin \ + && rm -rf /var/lib/apt/lists/* \ + && echo "runner ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/runner \ + && chmod 0440 /etc/sudoers.d/runner -USER runner \ No newline at end of file +# The actions-runner base ships a malformed tz config (/etc/timezone = "/UTC", +# localtime -> zoneinfo//UTC) which breaks local-timezone detection with +# 'ZoneInfo keys may not be absolute paths' (seen in shared timezones tests). +# Normalize to what GitHub-hosted runners have. +RUN ln -snf /usr/share/zoneinfo/Etc/UTC /etc/localtime && echo "Etc/UTC" > /etc/timezone + +# Same mechanism GitHub's runner image uses to make system `pip install` work +# on Ubuntu 24.04 despite PEP 668 (actions/runner-images install-python.sh): +# a global pip.conf, not deleting the EXTERNALLY-MANAGED marker. Non-root pip +# installs fall through to ~/.local, hence the PATH addition. +RUN printf '[global]\nbreak-system-packages = true\n' > /etc/pip.conf + +# JDKs for the java-sdk prek hooks (sync-java-sdk-supervisor-schema, ktlint, +# spotless run gradle on the host; toolchains reference Java 11 and 17). +# GitHub-hosted runners ship the Temurin set — mirror the versions we use. +RUN apt-get update \ + && apt-get install -y --no-install-recommends openjdk-11-jdk-headless openjdk-17-jdk-headless \ + && rm -rf /var/lib/apt/lists/* \ + && ln -s "/usr/lib/jvm/java-17-openjdk-$(dpkg --print-architecture)" /usr/lib/jvm/java-17 +ENV JAVA_HOME=/usr/lib/jvm/java-17 + +# gh CLI (job-summary and finalize steps use `gh api`/`gh run view`; present +# on GitHub-hosted runners). Official apt repo per cli.github.com docs. +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && chmod a+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ + && rm -rf /var/lib/apt/lists/* + +# kubectl (hosted runners ship it; the k8s workflows' cluster-state dump steps +# call it directly on the host). Pinned to the cluster's minor via pkgs.k8s.io. +RUN curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.34/deb/Release.key -o /tmp/k8s.key \ + && gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg /tmp/k8s.key \ + && chmod a+r /etc/apt/keyrings/kubernetes-apt-keyring.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.34/deb/ /" > /etc/apt/sources.list.d/kubernetes.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends kubectl \ + && rm -rf /var/lib/apt/lists/* /tmp/k8s.key + +# Real python3.10 on PATH (uv-managed build; hosted runners ship toolcache +# pythons, so this matches the contract). Provider-compat jobs check out old +# airflow branches whose prek configs pin python3.10 hooks; without a local +# interpreter prek downloads its own managed CPython, and that download is +# broken on arm64 (sysconfigdata missing build_time_vars, first ARM run +# 2026-07-31). A PATH python short-circuits the download on both arches. +RUN pip install uv \ + && UV_PYTHON_INSTALL_DIR=/opt/uv-python uv python install 3.10 \ + && ln -s /opt/uv-python/cpython-3.10.*/bin/python3.10 /usr/local/bin/python3.10 \ + && /usr/local/bin/python3.10 -c "import sysconfig; sysconfig.get_path('stdlib')" \ + && pip uninstall -y uv + +# AWS CLI v2 (stash actions use `aws s3`); official installer, arch-aware. +RUN ARCH=$(uname -m) \ + && curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscliv2.zip \ + && unzip -q /tmp/awscliv2.zip -d /tmp \ + && /tmp/aws/install \ + && rm -rf /tmp/aws /tmp/awscliv2.zip + +ENV PATH="/home/runner/.local/bin:${PATH}" + +# Hosted runners export USER; workflows rely on it (e.g. `sudo chown -R +# "${USER}"` — with USER unset that chown is a silent no-op, exit 0, leaving +# root-owned dirs that later non-sudo steps can't write). +ENV USER=runner + +# breeze saves/loads CI image tars via `--image-file-dir /mnt` — on GitHub-hosted +# runners /mnt is the big scratch disk made writable by make_mnt_writeable.sh, +# which self-hosted runs deliberately skip. Backed here by the node's 200 GB +# root volume; sticky like /tmp. +RUN mkdir -p /mnt && chmod 1777 /mnt + +USER runner diff --git a/terraform/README.rst b/terraform/README.rst index ad8e00b..e9832c3 100644 --- a/terraform/README.rst +++ b/terraform/README.rst @@ -28,23 +28,37 @@ Requirements In order to deploy the infrastructure, you need to have the following tools installed: -- `tfenv <https://github.com/tfutils/tfenv>`_ to manage Terraform versions +- Terraform >= 1.9 (e.g. via `tfenv <https://github.com/tfutils/tfenv>`_: ``tfenv install 1.9.5``) - Configure your AWS and EKS credentials :ref:`doc <aws_configuration>` +State +~~~~~ -Configure Terraform -~~~~~~~~~~~~~~~~~~~ +The remote state lives in the ``airflow-ci-tfstate`` S3 bucket (``us-east-2``) with the +``TerraformLocks`` DynamoDB table. Credentials/profile are taken from the environment +(``AWS_PROFILE=...``), which also works for a future GitHub Actions plan/apply workflow +assuming a role via OIDC — nothing is hardcoded in the provider. -.. code-block:: bash +.. note:: - cd eks - tfenv install 1.5.3 + The bucket also contains ``tf-state/eks.tfstate`` — the **2024 deployment's** state. + Its cluster is gone but leftovers (the ``airflow-vpc`` VPC, old node-group IAM roles, + the ``/aws/eks/airflow/cluster`` log group, the ``alias/eks/airflow`` KMS alias) + still exist in the account and are **deliberately left untouched**. The current + deployment therefore uses its own state key (``tf-state/eks-airflow-ci.tfstate``) + and the cluster name ``airflow-ci``, so every derived resource name avoids the 2024 + ones. Do not import, modify, or delete the 2024 resources from this configuration. + For local iteration without touching the shared state, drop a + ``backend_override.tf`` (gitignored) with ``terraform { backend "local" {} }`` + next to the sources. Plan and deploy the infrastructure ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: bash + cd eks + # Initialize the Terraform modules and install the required providers, # just the first time or when you add a new module AWS_PROFILE=airflow terraform init diff --git a/terraform/eks/.terraform.lock.hcl b/terraform/eks/.terraform.lock.hcl index a3a0c54..b5d5d74 100644 --- a/terraform/eks/.terraform.lock.hcl +++ b/terraform/eks/.terraform.lock.hcl @@ -2,104 +2,109 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "5.64.0" - constraints = ">= 4.33.0, >= 5.46.0, >= 5.61.0, ~> 5.64.0" + version = "6.56.0" + constraints = ">= 6.0.0, ~> 6.0, >= 6.28.0, >= 6.52.0" hashes = [ - "h1:YH4I78rsS9t+YoGMPNzrM53aWi0Rb9Nud16iusrSXMg=", - "zh:1d361f8062c68c9d5ac14b0aa8390709542129b8a9b258e61bbbabc706078b44", - "zh:39dcbf53e3896bdd77071384c8fad4a5862c222c73f3bcf356aca488101f22fd", - "zh:3fad63505f0c5b6f01cc9a6ef02b2226983b79424126a9caf6eb724f654299f4", - "zh:53a8b90d00829cc27e3171a13a8ff1404ee0ea018e73f31d3f916d246cc39613", - "zh:5734c25ef5a04b40f3c1ac5f817f11e42ee3328f74dbc141c0e64afbb0acc834", - "zh:66ea14dbd87f291ce4a877123363933d3ca4022f209f885807a6689c22c24e80", - "zh:68e79654ad0894a3d93134c3377748ace3058d5fad5ec09d1e9a8f8f9b8a47ea", - "zh:7b74259d0ceef0c49cea6bcd171df997b6bad141085bbadded15b440faeb0eee", - "zh:988ebfb5d115dc57070b5abf2e4200ad49cde535f27fd2ba5e34cf9ab336a57f", + "h1:iWz9BgFQaDPA1ChVGYvgI3MlUnx3wSQCA2ggYqDPcz8=", + "zh:2b3fbb3bebcc663b85d5fd9bbc2d131ab89322d696ff5c6ac6b7ffb7b5fe92e7", + "zh:30e56ccc7f33a7778ab323a28fe893d8e9200dc5fb92ccb7023bee808db3c1b0", + "zh:67dca271bef16547ef8ab5a6349f9bce39d91d7c1ae3d8388ada687ca774ba44", + "zh:824c812695b14d2fddad5e22339d4520e16d9c875f5d5095f29003f49a6fd124", + "zh:8372b12e30078d1df8b52e1285fd0d9d35160a7d18c3b0211f55229e5a832fd3", + "zh:8922b45ab65e272e8951f70d890444ddb3441170d8fa6f51298f24f20d21943d", + "zh:8fc4fb94ac8547717128cbcf296ac0ba0918738c4882029cbba46bd4812eb5e4", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:a0a2d4efe2835f0101a0a5024e044a3f28c00e10a8d87fce89c707ef6db75cea", - "zh:aecb3e4b9121771dee9cac7975bf5d0657b5f3e8b57788c455beaeb0f3c48d93", - "zh:d2d3393170b8ef761d3146f39f6788c4a3e876e6c5d4cedca4870c2680688ae6", - "zh:daba5a005c1baa4a5eefbfb86d43ccf880eb5b42e8136f0d932f55886d72bda0", - "zh:de16a6ff3baacdaf9609a0a89aa1913fc19cccaf5ee0fc1c49c5a075baa47c02", + "zh:a2efcd0174b79c1dffce48a5984f137ebc6f67d2c2ee966b47210e1faeb05cf2", + "zh:a4a50aa269a19c1d664b0dd59ee8047ed8a4a5b449f54733518309feccce1f43", + "zh:a830d80316b3c3127c9e0c77b9d4f50702c9d1a884c08973ac50b326d9ac734a", + "zh:d7e1b9bb2df3cdde8381ab101a807ae54e72c156d13a6b73dae2b922dca0c9f5", + "zh:d87c2a1cfc03b01cf13dff3700898ab990e1817326728824da8499dd0129da72", + "zh:fbb1848a597263c66dc6587ec8c3e0586e505e36f99d23752763534f62a3fef7", + "zh:fcb54d08eb3c763f01223e12b4eacbd018478e8e67c6b8611940564dfbea3d90", + "zh:ff6df70b2b2f75bd9000630f131c02e6bc2b9172cdb2547030f3fc019a3f3bc5", ] } provider "registry.terraform.io/hashicorp/cloudinit" { - version = "2.3.4" + version = "2.4.0" constraints = ">= 2.0.0" hashes = [ - "h1:S3j8poSaLbaftlKq2STBkQEkZH253ZLaHhBHBifdpBQ=", - "zh:09f1f1e1d232da96fbf9513b0fb5263bc2fe9bee85697aa15d40bb93835efbeb", - "zh:381e74b90d7a038c3a8dcdcc2ce8c72d6b86da9f208a27f4b98cabe1a1032773", - "zh:398eb321949e28c4c5f7c52e9b1f922a10d0b2b073b7db04cb69318d24ffc5a9", - "zh:4a425679614a8f0fe440845828794e609b35af17db59134c4f9e56d61e979813", - "zh:4d955d8608ece4984c9f1dacda2a59fdb4ea6b0243872f049b388181aab8c80a", + "h1:Bx3XQkBSY3RAGwLZb8hyi8AhvahPNlt4mlyZhW9guOI=", + "zh:1b0fe71b8e87a068f7cd9faaa733100ab72ab61ce812b8bd2b8e3e6ea3907b2d", + "zh:2aa9631ad64cfda1eb58f147619b631dadedfaf9453b422aa5ada2d3861183c1", + "zh:2c5f35463bdfb2f87d3576b81e62c30f8109e67bb6f21ffcbc46a855811455c0", + "zh:5970bcad151ea236bd262ada1a5a23bfbc1716f94a4e8b16ab2bcdda91d6a671", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:a48fbee1d58d55a1f4c92c2f38c83a37c8b2f2701ed1a3c926cefb0801fa446a", - "zh:b748fe6631b16a1dafd35a09377c3bffa89552af584cf95f47568b6cd31fc241", - "zh:d4b931f7a54603fa4692a2ec6e498b95464babd2be072bed5c7c2e140a280d99", - "zh:f1c9337fcfe3a7be39d179eb7986c22a979cfb2c587c05f1b3b83064f41785c5", - "zh:f58fc57edd1ee3250a28943cd84de3e4b744cdb52df0356a53403fc240240636", - "zh:f5f50de0923ff530b03e1bca0ac697534d61bb3e5fc7f60e13becb62229097a9", + "zh:79a0676909732b6ec0441a733af6383513cde3bd2cef5c1ad0a74131e1286a04", + "zh:818f16141481a1202b3977becd19a12d4d46cd2e3f5753f5d0d0049adacf8f8c", + "zh:948d98716831087e69eca99f91ed7964cc537f3aca279f7494645ee56c9dc4ec", + "zh:a75e78889565a51df3e8e3af207e36e5ddb25e47ce1780a784c82dc3c3109b67", + "zh:a9c6e455d52b1bba5272bd87a35cfabcfd6d903dcbe42e2de926228dbb1e39b2", + "zh:b846805d8c2f5d1d6c2ffeeaf32109d9af7db7fa3c56929bfc1dcfaadf9c8bd8", + "zh:c3e5279756b46c4f49a6f4c81347fbe2fffebb2bf18a5c24664830304a1f6a8e", + "zh:c8be7b31893163d0046b0137a6100533f07e8efd192a1903b6bb4c42be12dceb", ] } provider "registry.terraform.io/hashicorp/null" { - version = "3.2.2" + version = "3.3.0" constraints = ">= 3.0.0" hashes = [ - "h1:IMVAUHKoydFrlPrl9OzasDnw/8ntZFerCC9iXw1rXQY=", - "zh:3248aae6a2198f3ec8394218d05bd5e42be59f43a3a7c0b71c66ec0df08b69e7", - "zh:32b1aaa1c3013d33c245493f4a65465eab9436b454d250102729321a44c8ab9a", - "zh:38eff7e470acb48f66380a73a5c7cdd76cc9b9c9ba9a7249c7991488abe22fe3", - "zh:4c2f1faee67af104f5f9e711c4574ff4d298afaa8a420680b0cb55d7bbc65606", - "zh:544b33b757c0b954dbb87db83a5ad921edd61f02f1dc86c6186a5ea86465b546", - "zh:696cf785090e1e8cf1587499516b0494f47413b43cb99877ad97f5d0de3dc539", - "zh:6e301f34757b5d265ae44467d95306d61bef5e41930be1365f5a8dcf80f59452", + "h1:l+dm3lhmu4ys7GbvIldfn544olSPH0DOiYruuFSfQkY=", + "zh:021748b5ea3b5f6956f2e75c42c5cdc113b391fb98ac71364a4965d23b37000f", + "zh:3b27956f8541d46704fda234e0d535c2ae2a4b33411848b1ee262a1ec03568b0", + "zh:3de4ed47d6d0f4d8edba4a5092c7c9799950eda63989d8d0d2586e6afcb0aa20", + "zh:57ed8935c7d56dbc91cf2673534582cacfaab7a2f105f51d9f797e99df0c0c47", + "zh:58e176ba1d142827089e30e0711e007309a9f2726e8881986da5026e9778fdf4", + "zh:5949c4a3d4a93f841f155cdb7e991c087e637145c1630572e21948224f8f4923", + "zh:76d60f366b743003c1b085afa769b45b2198ee919927e45807d7d44fb42c067d", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:913a929070c819e59e94bb37a2a253c228f83921136ff4a7aa1a178c7cce5422", - "zh:aa9015926cd152425dbf86d1abdbc74bfe0e1ba3d26b3db35051d7b9ca9f72ae", - "zh:bb04798b016e1e1d49bcc76d62c53b56c88c63d6f2dfe38821afef17c416a0e1", - "zh:c23084e1b23577de22603cff752e59128d83cfecc2e6819edadd8cf7a10af11e", + "zh:79cd1bab1261a07f84e917191d7ddc4340ac5f5524283767256f7ffd7f87caf0", + "zh:8ec9083038cf710b30e319eaa467c9df7fa52bbd9969b61053a35bc2cdd2e0a6", + "zh:a6e502cb579685ab7aeb886c2bb11ddd9cfed74b41008592d57cbc3351a9218b", + "zh:acb74d6b4f66ff6acfcda315df802a7432170ef3955c9b432cb4580767004006", + "zh:f0ce55d8d9ffdb33dab612b1246f9bab060a9d54fc32ce2b4a038646155660af", ] } provider "registry.terraform.io/hashicorp/time" { - version = "0.12.0" + version = "0.14.0" constraints = ">= 0.9.0" hashes = [ - "h1:Os2Ok7txtlUJHh6Hg7o+74Ql85SnRb/fGmah22yXpLw=", - "zh:019a4c09af254ef80b72cf0d843dfe72d99483e227138cf5b514a1b9977ab4c3", - "zh:0ae310ec740ebc6f275529507d60bb747d0bf39e72fc5a2fa90d74486006132c", - "zh:13d6aec117f05237fbf8c7d91d6ebb19797b00aa87e7a812642d3ea4738a394e", - "zh:2e87abbc261f9317d0c2ef26e01d5fabf77679da7d2cac6f47df7d198f720989", - "zh:4a6d471176ce0264455aa7d5457b8702f78400010c201c1719708958a1b7b647", + "h1:4EThC3ocCFiFPMZQSUvSGSxoJqBcGWxMcFYmL67uS7Y=", + "zh:12abfd6b800e4d7fa6db7310dec8ffd440b31993861ef188c7ed5260b3073937", + "zh:23005521e800bb19e1597bf755c5f70d675d30b685d4255001ed5fa47d9df3f1", + "zh:2fea249b582ae97cd1cc10385187ea50993bb47c28cc5df0305e57ceaabf0a10", + "zh:322018d3b987b7aad08697178029a2bb667bed699e88328f0c89c52a2fd41341", + "zh:32a08e98fce2d273cb9b2c89d6c54727cc9f0a32e15bfd896be4e02cc6b48f95", + "zh:3db89aabd0e619616bd4b0f8b373a7586dfe60feffcea12a84a0bdbc445714b3", + "zh:7488f56c81d742dc020f29063626c8f07ca188aa97be61e7307e8d62397020a2", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:8978d5474a6da30bc0ad21c17db188d6918cacf3df3f6506b72ef3a268d53e2e", - "zh:b109efe138dfcb45dc04a9cc6809d185ab8b0ebc12040847c2dac430fda5af68", - "zh:b58e039b9106ac0a8de3c07f53b5279d7f0215fb35f2d23df642dfce0875382f", - "zh:ba2cbb2e515922d13efe3a46647be84f5426fcfcaa0f1520b3efeab8db847ed3", - "zh:c6c1ef1f26f25bca3abb5e07fa33dca37ed39cc26d0ff877964f2ffe5edd618c", - "zh:f8e171f923b7d2e789abd034072465dec3e6133c3a7644b7a7a965a74d52224e", + "zh:7cb4067f2e7559b13f7562ef722f948950901eb37834873e98360ab28f66e9d7", + "zh:9d552c8345f61e1b7db8e725144981345f18ac1014d58d6f5ddf0928a195fffb", + "zh:a8e69fb6b97fc9d86fb19a9f4d42abe33c4a68e700b15387ce2e17d2b9934bed", + "zh:aeeb900eb8dd0f790c60ea5c0e0c8d42bd6e4a54f391681d4decca15b544394b", + "zh:c239c619101a8c95e1f14061eb973c57a8d15fa0e68878ced5bbd76858ee5b79", ] } provider "registry.terraform.io/hashicorp/tls" { - version = "4.0.5" - constraints = ">= 3.0.0" + version = "4.3.0" + constraints = ">= 4.0.0" hashes = [ - "h1:zeG5RmggBZW/8JWIVrdaeSJa0OG62uFX5HY1eE8SjzY=", - "zh:01cfb11cb74654c003f6d4e32bbef8f5969ee2856394a96d127da4949c65153e", - "zh:0472ea1574026aa1e8ca82bb6df2c40cd0478e9336b7a8a64e652119a2fa4f32", - "zh:1a8ddba2b1550c5d02003ea5d6cdda2eef6870ece86c5619f33edd699c9dc14b", - "zh:1e3bb505c000adb12cdf60af5b08f0ed68bc3955b0d4d4a126db5ca4d429eb4a", - "zh:6636401b2463c25e03e68a6b786acf91a311c78444b1dc4f97c539f9f78de22a", - "zh:76858f9d8b460e7b2a338c477671d07286b0d287fd2d2e3214030ae8f61dd56e", - "zh:a13b69fb43cb8746793b3069c4d897bb18f454290b496f19d03c3387d1c9a2dc", - "zh:a90ca81bb9bb509063b736842250ecff0f886a91baae8de65c8430168001dad9", - "zh:c4de401395936e41234f1956ebadbd2ed9f414e6908f27d578614aaa529870d4", - "zh:c657e121af8fde19964482997f0de2d5173217274f6997e16389e7707ed8ece8", - "zh:d68b07a67fbd604c38ec9733069fbf23441436fecf554de6c75c032f82e1ef19", + "h1:j/BqLS2N2AScZyotd9nZpHdieJ7e5S8y+A+ZfIu8kL8=", + "zh:0ab58d6f8991d436c7d2dbd89ed814709b949b07ac5a54ee53b0aec1fa772a8b", + "zh:60b347abcb56f45d97c56f14d895069cd15a83993f199777f571b79fea3642ee", + "zh:6889be32640349230de3f23856e6f04e0e9ced4a84a27d3f552fa54684448218", + "zh:73f8e1ecf7135033165fb14b7e8bf4d656f3ce13065ec35762ea0481975328c7", + "zh:94ce25ee253eca0b42cae9c856b36bca8103b6453012d1b279c3623c805f2d42", + "zh:96bc6de9fd67bc446fd11257872e1ffb1029a996ed1d65a3f6b43f6d408ad9ab", + "zh:97c609a310a51bfd504d704e036d72064a84bf0bdb36cc08cd4cc66098212b41", + "zh:a12c16e94533c5bd123f75032576b9dc91dd5d5ccd5f7cf331d0f2e1adc55cf8", + "zh:c4f014f876adf7af57188795050bda5b0029d8c7d7773031102b6c36dcf1fc21", + "zh:d9b0a21583aaa3df3a95394fb949a3c515ff71c2ff5a1fc4a73d364aa90bfca5", + "zh:da510d22f0c6d71ad19a76406f106b782448f512375787ecfabb338ed1e311a7", + "zh:f0e9447a9ce3a24cdaa113089e65663c836d8b9bfdb915a1c0284e0112cab5c0", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", ] } diff --git a/terraform/eks/asg_tags.tf b/terraform/eks/asg_tags.tf deleted file mode 100644 index 3c05429..0000000 --- a/terraform/eks/asg_tags.tf +++ /dev/null @@ -1,67 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -###################################################################################################### -# In order to scale the node groups from zero, we need to add the labels and taints to the ASGs tags: -# k8s.io/cluster-autoscaler/node-template/label/<label-key> = <label-value> -# k8s.io/cluster-autoscaler/node-template/taint/<taint-key> = <taint-value>:<taint-effect> -###################################################################################################### - -locals { - - taint_effects = { - NO_SCHEDULE = "NoSchedule" - NO_EXECUTE = "NoExecute" - PREFER_NO_SCHEDULE = "PreferNoSchedule" - } - - cluster_autoscaler_label_tags = merge([ - for name, group in module.eks.eks_managed_node_groups : { - for label_name, label_value in coalesce(group.node_group_labels, {}) : "${name}|label|${label_name}" => { - autoscaling_group = group.node_group_autoscaling_group_names[0], - key = "k8s.io/cluster-autoscaler/node-template/label/${label_name}", - value = label_value, - } - } - ]...) - - cluster_autoscaler_taint_tags = merge([ - for name, group in module.eks.eks_managed_node_groups : { - for taint in coalesce(group.node_group_taints, []) : "${name}|taint|${taint.key}" => { - autoscaling_group = group.node_group_autoscaling_group_names[0], - key = "k8s.io/cluster-autoscaler/node-template/taint/${taint.key}" - value = "${taint.value}:${local.taint_effects[taint.effect]}" - } - } - ]...) - - cluster_autoscaler_asg_tags = merge(local.cluster_autoscaler_label_tags, local.cluster_autoscaler_taint_tags) -} - -resource "aws_autoscaling_group_tag" "cluster_autoscaler_label_tags" { - for_each = local.cluster_autoscaler_asg_tags - - autoscaling_group_name = each.value.autoscaling_group - - tag { - key = each.value.key - value = each.value.value - - propagate_at_launch = true - } -} - diff --git a/terraform/eks/eks.tf b/terraform/eks/eks.tf index 5b4203a..bd901db 100644 --- a/terraform/eks/eks.tf +++ b/terraform/eks/eks.tf @@ -18,218 +18,59 @@ data "aws_availability_zones" "available" {} locals { - cluster_name = "airflow" + cluster_name = "airflow-ci" } module "eks" { source = "terraform-aws-modules/eks/aws" - version = "20.24.0" + version = "~> 21.24" - cluster_name = local.cluster_name - cluster_version = "1.30" + name = local.cluster_name + kubernetes_version = var.kubernetes_version vpc_id = module.vpc.vpc_id subnet_ids = module.vpc.public_subnets - cluster_endpoint_public_access = true - - eks_managed_node_group_defaults = { - ami_type = "AL2_ARM_64" - use_custom_launch_template = false - disk_size = 50 - desired_size = 0 - } - - eks_managed_node_groups = { - - default_nodes = { - name = "default" - - instance_types = [ - "t4g.medium", - "t4g.large", - "t4g.xlarge", - "m7g.medium", - "m7g.large", - "m7g.xlarge", - ] - - min_size = 0 - max_size = 5 - desired_size = 1 - - capacity_type = "SPOT" - - labels = { - "node-type" = "default" - } + endpoint_public_access = true + enable_cluster_creator_admin_permissions = true + + addons = { + coredns = {} + kube-proxy = {} + # before_compute: the module disables EKS's bootstrap self-managed CNI when + # managed addons are used, so vpc-cni must exist BEFORE nodes boot — nodes + # without a CNI never become Ready and the node group lands in CREATE_FAILED + # (bitten by this on 2026-07-25 when a crashed apply skipped the addons). + vpc-cni = { + before_compute = true } - - # GHA arm runners' nodes - GHA_runners_small = { - name = "gha-runners-small" - - instance_types = [for node_type in var.runners_node_types: "${node_type}.${var.small_runners_node_size}"] - - min_size = 0 - max_size = 30 - - capacity_type = "SPOT" - - disk_size = 50 - - labels = { - "node-type" = "gha-runners" - "size" = "small" - "arch" = "arm64" - } - - taints = [ - { - "key" = "node-type" - "value" = "gha-runners" - "effect" = "NO_SCHEDULE" - } - ] + # Required for Karpenter's Pod Identity association. + eks-pod-identity-agent = { + before_compute = true } + } - GHA_runners_medium = { - name = "gha-runners-medium" - - instance_types = [for node_type in var.runners_node_types: "${node_type}.${var.medium_runners_node_size}"] - - min_size = 0 - max_size = 30 - - capacity_type = "SPOT" - - disk_size = 50 - - labels = { - "node-type" = "gha-runners" - "size" = "medium" - "arch" = "arm64" - } - - taints = [ - { - "key" = "node-type" - "value" = "gha-runners" - "effect" = "NO_SCHEDULE" - } - ] - } - - GHA_runners_large = { - name = "gha-runners-large" - - instance_types = [for node_type in var.runners_node_types: "${node_type}.${var.large_runners_node_size}"] - - min_size = 0 - max_size = 30 - - capacity_type = "SPOT" - - labels = { - "node-type" = "gha-runners" - "size" = "large" - "arch" = "arm64" - } - - taints = [ - { - "key" = "node-type" - "value" = "gha-runners" - "effect" = "NO_SCHEDULE" - } - ] - } - - # GHA x64 runners' nodes - x64_GHA_runners_small = { - name = "gha-x64-runners-small" - - ami_type = "AL2_x86_64" - - instance_types = [for node_type in var.x64_runners_node_types: "${node_type}.${var.small_runners_x64_node_size}"] - - min_size = 0 - max_size = 30 - - capacity_type = "SPOT" - - disk_size = 50 - - labels = { - "node-type" = "gha-runners" - "size" = "small" - "arch" = "x64" - } - - taints = [ - { - "key" = "node-type" - "value" = "gha-runners" - "effect" = "NO_SCHEDULE" - } - ] - } - - x64_GHA_runners_medium = { - name = "gha-x64-runners-medium" - - ami_type = "AL2_x86_64" - - instance_types = [for node_type in var.x64_runners_node_types: "${node_type}.${var.medium_runners_node_size}"] - - min_size = 0 - max_size = 30 - - capacity_type = "SPOT" - - disk_size = 50 - - labels = { - "node-type" = "gha-runners" - "size" = "medium" - "arch" = "x64" - } - - taints = [ - { - "key" = "node-type" - "value" = "gha-runners" - "effect" = "NO_SCHEDULE" - } - ] - } - - x64_GHA_runners_large = { - name = "gha-x64-runners-large" - - ami_type = "AL2_x86_64" - - instance_types = [for node_type in var.x64_runners_node_types: "${node_type}.${var.large_runners_node_size}"] - - min_size = 0 - max_size = 30 + # Karpenter's EC2NodeClass discovers the node security group by this tag. + node_security_group_tags = { + "karpenter.sh/discovery" = local.cluster_name + } - capacity_type = "SPOT" + eks_managed_node_groups = { + # Small always-on group for the controllers (Karpenter, ARC, listeners). + # Everything else runs on Karpenter-provisioned nodes. + system = { + name = "airflow-ci-system" + ami_type = "AL2023_ARM_64_STANDARD" + instance_types = var.system_node_instance_types + capacity_type = "ON_DEMAND" + + min_size = 2 + max_size = 3 + desired_size = 2 labels = { - "node-type" = "gha-runners" - "size" = "large" - "arch" = "x64" + "airflow-ci/role" = "system" } - - taints = [ - { - "key" = "node-type" - "value" = "gha-runners" - "effect" = "NO_SCHEDULE" - } - ] } } } - diff --git a/terraform/eks/iam.tf b/terraform/eks/iam.tf deleted file mode 100644 index e2c3000..0000000 --- a/terraform/eks/iam.tf +++ /dev/null @@ -1,134 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -data "aws_iam_policy_document" "autoscaler_policy_document" { - statement { - effect = "Allow" - actions = [ - "autoscaling:DescribeAutoScalingGroups", - "autoscaling:DescribeAutoScalingInstances", - "autoscaling:DescribeLaunchConfigurations", - "autoscaling:DescribeScalingActivities", - "autoscaling:DescribeTags", - "ec2:DescribeInstanceTypes", - "ec2:DescribeLaunchTemplateVersions" - ] - resources = ["*"] - } - - statement { - effect = "Allow" - actions = [ - "autoscaling:SetDesiredCapacity", - "autoscaling:TerminateInstanceInAutoScalingGroup", - "ec2:DescribeImages", - "ec2:GetInstanceTypesFromInstanceRequirements", - "eks:DescribeNodegroup" - ] - resources = ["*"] - } -} - -resource "aws_iam_policy" "autoscaler_policy" { - name = "eks-autoscaler-policy" - description = "EKS Autoscaler Policy" - policy = data.aws_iam_policy_document.autoscaler_policy_document.json -} - -data "aws_iam_policy_document" "autoscaler_role_assume_policy_document" { - statement { - actions = [ - "sts:AssumeRoleWithWebIdentity" - ] - - principals { - type = "Federated" - identifiers = [ - module.eks.oidc_provider_arn - ] - } - - condition { - test = "StringEquals" - variable = "${module.eks.oidc_provider}:sub" - values = [ - "system:serviceaccount:infra:cluster-autoscaler" - ] - } - - condition { - test = "StringEquals" - variable = "${module.eks.oidc_provider}:aud" - values = [ - "sts.amazonaws.com" - ] - } - - } -} - -resource "aws_iam_role" "autoscaler_role" { - name = "eks-autoscaler-role" - assume_role_policy = data.aws_iam_policy_document.autoscaler_role_assume_policy_document.json -} - -resource "aws_iam_role_policy_attachment" "autoscaler_policy_attachment" { - role = aws_iam_role.autoscaler_role.name - policy_arn = aws_iam_policy.autoscaler_policy.arn -} - -data "aws_iam_policy_document" "ebs_csi_driver_assume_policy_document" { - statement { - actions = [ - "sts:AssumeRoleWithWebIdentity" - ] - - principals { - type = "Federated" - identifiers = [ - module.eks.oidc_provider_arn - ] - } - - condition { - test = "StringEquals" - variable = "${module.eks.oidc_provider}:sub" - values = [ - "system:serviceaccount:kube-system:ebs-csi-controller-sa" - ] - } - - condition { - test = "StringEquals" - variable = "${module.eks.oidc_provider}:aud" - values = [ - "sts.amazonaws.com" - ] - } - - } -} - -resource "aws_iam_role" "ebs_csi_driver" { - name = "ebs-csi-driver" - assume_role_policy = data.aws_iam_policy_document.ebs_csi_driver_assume_policy_document.json -} - -resource "aws_iam_role_policy_attachment" "ebs_csi_driver" { - role = aws_iam_role.ebs_csi_driver.name - policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy" -} diff --git a/terraform/eks/vpc.tf b/terraform/eks/karpenter.tf similarity index 50% copy from terraform/eks/vpc.tf copy to terraform/eks/karpenter.tf index 1d80025..c334d69 100644 --- a/terraform/eks/vpc.tf +++ b/terraform/eks/karpenter.tf @@ -15,30 +15,22 @@ # specific language governing permissions and limitations # under the License. -module "vpc" { - source = "terraform-aws-modules/vpc/aws" - version = "5.13.0" +# IAM + SQS plumbing for Karpenter (controller role via EKS Pod Identity, node +# role + access entry, spot-interruption queue). Karpenter itself is installed +# with helmfile (helm/infra-helmfile.yaml); the names below are referenced from +# the helm values, so they are pinned instead of name-prefixed. +module "karpenter" { + source = "terraform-aws-modules/eks/aws//modules/karpenter" + version = "~> 21.24" - name = "airflow-vpc" + cluster_name = module.eks.cluster_name - cidr = "10.1.0.0/16" - azs = slice(data.aws_availability_zones.available.names, 0, 3) + # Referenced by helm/values/karpenter.yaml (settings.interruptionQueue). + queue_name = "airflow-ci-karpenter" - private_subnets = ["10.1.1.0/24", "10.1.2.0/24", "10.1.3.0/24"] - public_subnets = ["10.1.4.0/24", "10.1.5.0/24", "10.1.6.0/24"] + # Referenced by the EC2NodeClass (spec.role) in helm/karpenter-resources. + node_iam_role_name = "airflow-ci-runner-node" + node_iam_role_use_name_prefix = false - enable_nat_gateway = false - enable_dns_hostnames = true - - map_public_ip_on_launch = true - - public_subnet_tags = { - "kubernetes.io/cluster/${local.cluster_name}" = "shared" - "kubernetes.io/role/elb" = 1 - } - - private_subnet_tags = { - "kubernetes.io/cluster/${local.cluster_name}" = "shared" - "kubernetes.io/role/internal-elb" = 1 - } -} \ No newline at end of file + create_pod_identity_association = true +} diff --git a/terraform/eks/vpc.tf b/terraform/eks/outputs.tf similarity index 50% copy from terraform/eks/vpc.tf copy to terraform/eks/outputs.tf index 1d80025..f7d224d 100644 --- a/terraform/eks/vpc.tf +++ b/terraform/eks/outputs.tf @@ -15,30 +15,27 @@ # specific language governing permissions and limitations # under the License. -module "vpc" { - source = "terraform-aws-modules/vpc/aws" - version = "5.13.0" - - name = "airflow-vpc" - - cidr = "10.1.0.0/16" - azs = slice(data.aws_availability_zones.available.names, 0, 3) - - private_subnets = ["10.1.1.0/24", "10.1.2.0/24", "10.1.3.0/24"] - public_subnets = ["10.1.4.0/24", "10.1.5.0/24", "10.1.6.0/24"] - - enable_nat_gateway = false - enable_dns_hostnames = true - - map_public_ip_on_launch = true - - public_subnet_tags = { - "kubernetes.io/cluster/${local.cluster_name}" = "shared" - "kubernetes.io/role/elb" = 1 - } - - private_subnet_tags = { - "kubernetes.io/cluster/${local.cluster_name}" = "shared" - "kubernetes.io/role/internal-elb" = 1 - } -} \ No newline at end of file +output "cluster_name" { + description = "EKS cluster name" + value = module.eks.cluster_name +} + +output "cluster_endpoint" { + description = "EKS API endpoint" + value = module.eks.cluster_endpoint +} + +output "karpenter_node_iam_role_name" { + description = "IAM role assumed by Karpenter-launched nodes (EC2NodeClass spec.role)" + value = module.karpenter.node_iam_role_name +} + +output "karpenter_interruption_queue" { + description = "SQS queue for spot interruption handling (karpenter settings.interruptionQueue)" + value = module.karpenter.queue_name +} + +output "update_kubeconfig" { + description = "Command to configure kubectl" + value = "aws eks --region ${var.region} update-kubeconfig --name ${module.eks.cluster_name} --alias ${module.eks.cluster_name}" +} diff --git a/terraform/eks/provider.tf b/terraform/eks/provider.tf index 89656fe..5388c88 100644 --- a/terraform/eks/provider.tf +++ b/terraform/eks/provider.tf @@ -15,7 +15,16 @@ # specific language governing permissions and limitations # under the License. +# Credentials come from the environment (AWS_PROFILE locally, OIDC role in a +# future GitHub Actions workflow) — do not hardcode a profile here. provider "aws" { - profile = "airflow" - region = var.region + region = var.region + + default_tags { + tags = { + Project = "airflow-ci" + ManagedBy = "terraform" + Repo = "apache/airflow-ci-infra" + } + } } \ No newline at end of file diff --git a/terraform/eks/runner_pod_identity.tf b/terraform/eks/runner_pod_identity.tf new file mode 100644 index 0000000..bbd267d --- /dev/null +++ b/terraform/eks/runner_pod_identity.tf @@ -0,0 +1,113 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# The airflow CI workflows pass artifacts between jobs via the S3-backed stash +# actions (.github/actions/stash-s3-*). Runner pods get scoped S3 access +# through EKS Pod Identity — node IAM credentials are deliberately +# unreachable from pods (IMDS hop limit 1 on the EC2NodeClass). +locals { + # Same-region successor of the us-east-1 `airflow-ci-runner-cache` bucket + # (bucket names are global, so the regional suffix disambiguates). Contents + # are disposable inter-job artifacts — nothing was migrated. + stash_bucket = "airflow-ci-runner-cache-us-east-2" + + # One service account per runner scale set, created by the gha-runner-scale-set + # chart as "<scale-set-name>-gha-rs-no-permission" in the arc-runners namespace. + runner_scale_set_names = [ + "airflow-runner-amd-small", + "airflow-runner-amd-medium", + "airflow-runner-amd-xl", + "airflow-runner-arm-small", + "airflow-runner-arm-medium", + "airflow-runner-arm-xl", + ] +} + +resource "aws_s3_bucket" "stash" { + bucket = local.stash_bucket +} + +resource "aws_s3_bucket_public_access_block" "stash" { + bucket = aws_s3_bucket.stash.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# Stashes are short-lived hand-offs between jobs of a run; the actions ask for +# ~2-day retention, the lifecycle rule is the backstop that actually deletes. +resource "aws_s3_bucket_lifecycle_configuration" "stash" { + bucket = aws_s3_bucket.stash.id + + rule { + id = "expire-stashes" + status = "Enabled" + + filter {} + + expiration { + days = 3 + } + + abort_incomplete_multipart_upload { + days_after_initiation = 1 + } + } +} + +data "aws_iam_policy_document" "runner_pods_assume" { + statement { + actions = ["sts:AssumeRole", "sts:TagSession"] + principals { + type = "Service" + identifiers = ["pods.eks.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "runner_pods_stash" { + statement { + actions = ["s3:GetObject", "s3:PutObject"] + resources = ["arn:aws:s3:::${local.stash_bucket}/*"] + } + statement { + actions = ["s3:ListBucket", "s3:GetBucketLocation"] + resources = ["arn:aws:s3:::${local.stash_bucket}"] + } +} + +resource "aws_iam_role" "runner_pods" { + name = "airflow-ci-runner-pods" + assume_role_policy = data.aws_iam_policy_document.runner_pods_assume.json +} + +resource "aws_iam_role_policy" "runner_pods_stash" { + name = "stash-bucket-access" + role = aws_iam_role.runner_pods.id + policy = data.aws_iam_policy_document.runner_pods_stash.json +} + +resource "aws_eks_pod_identity_association" "runner_pods" { + for_each = toset(local.runner_scale_set_names) + + cluster_name = module.eks.cluster_name + namespace = "arc-runners" + service_account = "${each.value}-gha-rs-no-permission" + role_arn = aws_iam_role.runner_pods.arn +} diff --git a/terraform/eks/state.tf b/terraform/eks/state.tf index 025e912..8801ae2 100644 --- a/terraform/eks/state.tf +++ b/terraform/eks/state.tf @@ -18,10 +18,16 @@ terraform { backend "s3" { - region = "us-east-2" - bucket = "airflow-ci-tfstate" - key = "tf-state/eks.tfstate" - encrypt = true - dynamodb_table = "TerraformLocks" + region = "us-east-2" + bucket = "airflow-ci-tfstate" + # Fresh key for the 2026 airflow-ci cluster. tf-state/eks.tfstate is the + # 2024 deployment's state and is deliberately left untouched (its cluster + # is gone but the VPC/IAM leftovers still exist and must not be disturbed). + key = "tf-state/eks-airflow-ci.tfstate" + encrypt = true + # S3-native locking (.tflock object next to the state). Replaces the + # deprecated dynamodb_table locking; the TerraformLocks DynamoDB table is + # still referenced by the 2024 state and must not be deleted. + use_lockfile = true } } diff --git a/terraform/eks/variables.tf b/terraform/eks/variables.tf index 045c496..deeb5a0 100644 --- a/terraform/eks/variables.tf +++ b/terraform/eks/variables.tf @@ -21,48 +21,14 @@ variable "region" { default = "us-east-2" } -variable "runners_node_types" { - description = "Node type for the runners" - type = list(string) - default = [ - "t4g", - "m7g", - "m6g", - ] -} - -variable "x64_runners_node_types" { - description = "X64 node type for the runners" - type = list(string) - default = [ - "m7a", - "m6a", - "t3", - "t3a", - ] -} - -variable "small_runners_node_size" { - description = "Node size for the small runners" - type = string - default = "medium" -} - -variable "small_runners_x64_node_size" { - # This is a separate variable because the r series doesn't have a medium size - description = "Node size for the small runners" +variable "kubernetes_version" { + description = "EKS Kubernetes version; keep within standard support to avoid extended-support fees" type = string - default = "large" + default = "1.34" } -variable "medium_runners_node_size" { - description = "Node size for the medium runners" - type = string - default = "xlarge" +variable "system_node_instance_types" { + description = "Instance types for the small always-on system node group (Karpenter + ARC controllers)" + type = list(string) + default = ["t4g.medium"] } - -variable "large_runners_node_size" { - description = "Node size for the large runners" - type = string - default = "2xlarge" -} \ No newline at end of file diff --git a/terraform/eks/version.tf b/terraform/eks/version.tf index a215415..93977fd 100644 --- a/terraform/eks/version.tf +++ b/terraform/eks/version.tf @@ -19,8 +19,9 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "~> 5.64.0" + version = "~> 6.0" } } - required_version = "~> 1.9.5" + # 1.10+ for S3-native state locking (use_lockfile) + required_version = ">= 1.10.0" } \ No newline at end of file diff --git a/terraform/eks/vpc.tf b/terraform/eks/vpc.tf index 1d80025..3ff2339 100644 --- a/terraform/eks/vpc.tf +++ b/terraform/eks/vpc.tf @@ -15,17 +15,21 @@ # specific language governing permissions and limitations # under the License. +# Nodes live in public subnets with public IPs on purpose: a NAT gateway costs +# ~$400/yr while public IPv4 on ephemeral runner nodes is a few $/mo, and the +# security groups allow no inbound traffic (runners are outbound-only). module "vpc" { source = "terraform-aws-modules/vpc/aws" - version = "5.13.0" + version = "~> 6.6" - name = "airflow-vpc" + name = "airflow-ci-vpc" cidr = "10.1.0.0/16" azs = slice(data.aws_availability_zones.available.names, 0, 3) - private_subnets = ["10.1.1.0/24", "10.1.2.0/24", "10.1.3.0/24"] - public_subnets = ["10.1.4.0/24", "10.1.5.0/24", "10.1.6.0/24"] + # Docker inside the runners carves networks out of 172.16.0.0/14 — the VPC + # range must stay clear of it, hence 10.x. + public_subnets = ["10.1.4.0/24", "10.1.5.0/24", "10.1.6.0/24"] enable_nat_gateway = false enable_dns_hostnames = true @@ -34,11 +38,7 @@ module "vpc" { public_subnet_tags = { "kubernetes.io/cluster/${local.cluster_name}" = "shared" - "kubernetes.io/role/elb" = 1 + # Karpenter's EC2NodeClass discovers subnets by this tag. + "karpenter.sh/discovery" = local.cluster_name } - - private_subnet_tags = { - "kubernetes.io/cluster/${local.cluster_name}" = "shared" - "kubernetes.io/role/internal-elb" = 1 - } -} \ No newline at end of file +}
