Laboratorio: Ejecutar Runners Autoalojados Efímeros de GitHub Actions con Actions Runner Controller

Descripción general

Los runners alojados en GitHub son compartidos y efímeros por defecto — cada trabajo obtiene una máquina virtual nueva que se destruye después de que el trabajo se completa. Los runners autoalojados, por otro lado, son persistentes y compartidos entre ejecuciones de flujos de trabajo. Esto crea un riesgo de seguridad significativo: secretos, tokens y artefactos de compilación de un trabajo pueden filtrarse al siguiente. Un flujo de trabajo comprometido puede envenenar el entorno del runner para todos los trabajos futuros.

Actions Runner Controller (ARC) resuelve este problema. ARC es un operador nativo de Kubernetes que te proporciona runners autoalojados efímeros, autoescalables y basados en contenedores. Cada trabajo obtiene un pod nuevo que se destruye cuando el trabajo se completa — igual que los runners alojados en GitHub, pero ejecutándose en tu propia infraestructura con tus propias herramientas y políticas de red.

En este laboratorio práctico, vas a:

  • Desplegar ARC en un clúster local de Kubernetes
  • Configurar runner scale sets efímeros
  • Demostrar el aislamiento entre trabajos (el beneficio de seguridad principal)
  • Construir imágenes personalizadas de runners
  • Implementar aislamiento de grupos de runners para separación de funciones
  • Configurar autoescalado
  • Aplicar políticas de red para restringir el acceso de red de los runners

Requisitos previos

Antes de comenzar este laboratorio, asegúrate de tener lo siguiente:

  • Clúster de Kuberneteskind, minikube, o un clúster gestionado en la nube (EKS, GKE, AKS)
  • Helm 3 — Instalar desde helm.sh
  • kubectl — Configurado para comunicarse con tu clúster
  • Cuenta de GitHub — Con acceso de administrador a un repositorio u organización
  • GitHub App o Personal Access Token (PAT) — Con los alcances repo y admin:org (PAT) o los permisos apropiados de GitHub App
  • Docker — Para construir imágenes personalizadas de runners (Ejercicio 4)

Configuración del entorno

Usaremos kind (Kubernetes in Docker) para crear un clúster local. Esto mantiene el laboratorio autocontenido y fácil de limpiar.

Crear un clúster kind

kind create cluster --name arc-lab

Verifica que el clúster esté ejecutándose:

kubectl cluster-info --context kind-arc-lab

Crear un repositorio de prueba en GitHub

Crea un nuevo repositorio (por ejemplo, arc-lab-test) en tu cuenta de GitHub. Añade un archivo de flujo de trabajo simple en .github/workflows/test.yml:

name: ARC Test Workflow
on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Hello from GitHub-hosted runner
        run: echo "This runs on a GitHub-hosted runner"

Haz push de esto a tu repositorio. Lo modificaremos más adelante para apuntar a los runners de ARC.

Ejercicio 1: Instalar ARC con Helm

Actions Runner Controller v2 usa charts de Helm para desplegar dos componentes: un controlador que gestiona el ciclo de vida de los pods de runners, y uno o más runner scale sets que se registran con GitHub y aceptan trabajos.

Paso 1: Añadir el repositorio de Helm

helm repo add actions-runner-controller \
  https://actions-runner-controller.github.io/actions-runner-controller
helm repo update

Paso 2: Configurar la autenticación

ARC necesita autenticarse con la API de GitHub. Tienes dos opciones:

Opción A: GitHub App (Recomendado para producción)

Crea una GitHub App en la configuración de tu organización o cuenta:

  1. Ve a Settings → Developer settings → GitHub Apps → New GitHub App
  2. Establece los siguientes permisos:
    • Repository: Actions (read), Administration (read/write), Metadata (read)
    • Organization: Self-hosted runners (read/write)
  3. Genera una clave privada y descárgala
  4. Instala la App en tu organización o repositorio
  5. Anota el App ID y el Installation ID

Opción B: Personal Access Token (Más simple para laboratorios)

Crea un PAT (clásico) con los alcances repo y admin:org, o un PAT de grano fino con permisos de Actions y Administration. Para este laboratorio, usaremos un PAT por simplicidad.

Paso 3: Instalar el controlador ARC

helm install arc \
  actions-runner-controller/gha-runner-scale-set-controller \
  --namespace arc-systems \
  --create-namespace

Verifica que el controlador esté ejecutándose:

kubectl get pods -n arc-systems

Deberías ver una salida similar a:

NAME                                     READY   STATUS    RESTARTS   AGE
arc-gha-runner-scale-set-controller-xxx  1/1     Running   0          30s

Paso 4: Instalar un Runner Scale Set

Ahora despliega un runner scale set que se registre con tu repositorio de GitHub:

helm install arc-runner-set \
  actions-runner-controller/gha-runner-scale-set \
  --namespace arc-runners \
  --create-namespace \
  --set githubConfigUrl="https://github.com/<org>/<repo>" \
  --set githubConfigSecret.github_token="<PAT>"

Reemplaza <org>/<repo> con la ruta de tu repositorio y <PAT> con tu personal access token.

Verifica el runner scale set:

kubectl get pods -n arc-runners

En este punto, puede que no haya pods de runners todavía — ARC utiliza un modelo de escalar a cero. Los pods se crean solo cuando se encolan trabajos.

Paso 5: Verificar en GitHub

Navega a tu repositorio en GitHub: Settings → Actions → Runners. Deberías ver el runner scale set listado con el nombre arc-runner-set. El estado muestra que está listo para aceptar trabajos.

Ejercicio 2: Ejecutar un flujo de trabajo en runners de ARC

Ahora actualiza el flujo de trabajo de prueba para apuntar al runner scale set de ARC en lugar de los runners alojados en GitHub.

Paso 1: Actualizar el flujo de trabajo

Modifica .github/workflows/test.yml para usar la etiqueta del runner de ARC:

name: ARC Test Workflow
on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  test:
    runs-on: arc-runner-set
    steps:
      - name: Hello from ARC runner
        run: |
          echo "This runs on an ephemeral ARC runner!"
          echo "Hostname: $(hostname)"
          echo "Runner OS: $(uname -a)"
      - name: Show environment
        run: env | sort

El cambio clave es runs-on: arc-runner-set — esto coincide con el nombre de la release de Helm para el runner scale set.

Paso 2: Activar el flujo de trabajo

Haz push del archivo de flujo de trabajo actualizado o usa el botón «Run workflow» (workflow_dispatch) en la interfaz de GitHub Actions.

Paso 3: Observar el pod del runner

Observa el namespace arc-runners mientras se ejecuta el flujo de trabajo:

kubectl get pods -n arc-runners -w

Verás un pod creado para el trabajo:

NAME                          READY   STATUS    RESTARTS   AGE
arc-runner-set-xxxxx-runner   1/1     Running   0          5s

Después de que el trabajo se complete, el pod se termina y se elimina:

NAME                          READY   STATUS      RESTARTS   AGE
arc-runner-set-xxxxx-runner   0/1     Completed   0          45s

Ejecuta kubectl get pods -n arc-runners de nuevo — el pod ya no está. Este es el modelo efímero: cada trabajo obtiene un contenedor nuevo, y el contenedor se destruye cuando el trabajo termina. No hay persistencia de estado entre trabajos.

Ejercicio 3: Demostrar la seguridad efímera

Este ejercicio demuestra el beneficio de seguridad principal de los runners efímeros: no hay contaminación entre trabajos.

Paso 1: Crear un flujo de trabajo que escriba datos sensibles

Crea .github/workflows/ephemeral-test.yml:

name: Ephemeral Security Test
on: workflow_dispatch

jobs:
  write-secret:
    runs-on: arc-runner-set
    steps:
      - name: Write sensitive data
        run: |
          echo "SECRET_API_KEY=sk-prod-abc123xyz" > /tmp/secret-data
          echo "DB_PASSWORD=super-secret-password" >> /tmp/secret-data
          echo "Written sensitive data to /tmp/secret-data"
          cat /tmp/secret-data

  read-secret:
    runs-on: arc-runner-set
    needs: write-secret
    steps:
      - name: Attempt to read previous job data
        run: |
          echo "Checking if /tmp/secret-data exists from previous job..."
          if [ -f /tmp/secret-data ]; then
            echo "SECURITY RISK: Found data from previous job!"
            cat /tmp/secret-data
          else
            echo "SECURE: /tmp/secret-data does not exist."
            echo "Each job gets a fresh container — no cross-job contamination."
          fi

Paso 2: Ejecutar el flujo de trabajo

Activa el flujo de trabajo mediante workflow_dispatch. El primer trabajo (write-secret) escribe datos sensibles en /tmp/secret-data. El segundo trabajo (read-secret) se ejecuta en un nuevo pod e intenta leer ese archivo.

Paso 3: Verificar los resultados

En los logs de GitHub Actions, verás:

  • Trabajo write-secret: Escribe exitosamente el archivo e imprime el contenido
  • Trabajo read-secret: El archivo no existe — la salida muestra SECURE: /tmp/secret-data does not exist.

Cada trabajo se ejecutó en un pod separado, recién creado. Cuando el pod de write-secret fue destruido, todos los datos — incluyendo el archivo sensible — fueron destruidos con él.

Por qué esto importa

En un runner autoalojado persistente, el archivo /tmp/secret-data seguiría en disco cuando se ejecuta el segundo trabajo. Un flujo de trabajo malicioso en un pull request podría leer secretos, tokens o credenciales dejados por trabajos anteriores. Con runners efímeros, este vector de ataque se elimina.

Ejercicio 4: Imágenes personalizadas de runners

Los runners de ARC usan una imagen de contenedor base. Para uso en el mundo real, necesitas personalizar esta imagen para incluir tus herramientas de compilación.

Paso 1: Crear un Dockerfile personalizado

Crea un Dockerfile para tu runner personalizado:

FROM ghcr.io/actions/actions-runner:latest

USER root

# Install build tools
RUN apt-get update && apt-get install -y \
    curl \
    wget \
    git \
    jq \
    unzip \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

# Install Go
RUN wget -q https://go.dev/dl/go1.22.4.linux-amd64.tar.gz \
    && tar -C /usr/local -xzf go1.22.4.linux-amd64.tar.gz \
    && rm go1.22.4.linux-amd64.tar.gz
ENV PATH="$PATH:/usr/local/go/bin"

# Install cosign
RUN curl -sSL -o /usr/local/bin/cosign \
    https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 \
    && chmod +x /usr/local/bin/cosign

# Install Docker CLI (for Docker-in-Docker workflows)
RUN curl -fsSL https://get.docker.com | sh

USER runner

Paso 2: Construir y subir la imagen

# Build the image
docker build -t ghcr.io/<org>/custom-runner:latest .

# Authenticate to GitHub Container Registry
echo "<PAT>" | docker login ghcr.io -u <username> --password-stdin

# Push the image
docker push ghcr.io/<org>/custom-runner:latest

Paso 3: Configurar ARC para usar la imagen personalizada

Crea un archivo de valores custom-runner-values.yaml:

githubConfigUrl: "https://github.com/<org>/<repo>"
githubConfigSecret:
  github_token: "<PAT>"

template:
  spec:
    containers:
      - name: runner
        image: ghcr.io/<org>/custom-runner:latest
        command: ["/home/runner/run.sh"]
        resources:
          requests:
            cpu: "500m"
            memory: "512Mi"
          limits:
            cpu: "2"
            memory: "2Gi"

Actualiza el runner scale set con la imagen personalizada:

helm upgrade arc-runner-set \
  actions-runner-controller/gha-runner-scale-set \
  --namespace arc-runners \
  -f custom-runner-values.yaml

Paso 4: Verificar las herramientas personalizadas

Crea un flujo de trabajo que use las herramientas personalizadas:

name: Custom Runner Tools Test
on: workflow_dispatch

jobs:
  verify-tools:
    runs-on: arc-runner-set
    steps:
      - name: Verify Go
        run: go version
      - name: Verify cosign
        run: cosign version
      - name: Verify Docker CLI
        run: docker --version

Beneficio de seguridad: Al construir tu propia imagen de runner, controlas exactamente qué herramientas y dependencias están presentes en el entorno de compilación. No hay binarios inesperados, no hay software preinstalado que no hayas aprobado, y puedes fijar cada herramienta a una versión específica. También puedes escanear la imagen en busca de vulnerabilidades antes de desplegarla.

Ejercicio 5: Aislamiento de grupos de runners

Los diferentes flujos de trabajo tienen diferentes niveles de confianza. La validación de pull requests no debería tener acceso a secretos de producción. Los flujos de trabajo de despliegue necesitan secretos pero solo deberían ejecutarse desde la rama main. ARC te permite implementar esta separación creando runner scale sets distintos con diferentes etiquetas y configuraciones.

Paso 1: Crear un Runner Scale Set para validación de PR

Crea pr-runner-values.yaml:

githubConfigUrl: "https://github.com/<org>/<repo>"
githubConfigSecret:
  github_token: "<PAT>"

template:
  spec:
    containers:
      - name: runner
        image: ghcr.io/<org>/custom-runner:latest
        command: ["/home/runner/run.sh"]
        env:
          - name: RUNNER_GROUP
            value: "pr-validation"
        resources:
          requests:
            cpu: "250m"
            memory: "256Mi"
          limits:
            cpu: "1"
            memory: "1Gi"
helm install arc-runner-pr \
  actions-runner-controller/gha-runner-scale-set \
  --namespace arc-runners \
  -f pr-runner-values.yaml

Paso 2: Crear un Runner Scale Set para despliegue

Crea deploy-runner-values.yaml:

githubConfigUrl: "https://github.com/<org>/<repo>"
githubConfigSecret:
  github_token: "<PAT>"

template:
  spec:
    containers:
      - name: runner
        image: ghcr.io/<org>/custom-runner:latest
        command: ["/home/runner/run.sh"]
        env:
          - name: RUNNER_GROUP
            value: "deployment"
        resources:
          requests:
            cpu: "500m"
            memory: "512Mi"
          limits:
            cpu: "2"
            memory: "2Gi"
    serviceAccountName: deploy-runner-sa
    nodeSelector:
      runner-type: deployment
helm install arc-runner-deploy \
  actions-runner-controller/gha-runner-scale-set \
  --namespace arc-runners \
  -f deploy-runner-values.yaml

Paso 3: Configurar flujos de trabajo para aislamiento

Usa diferentes etiquetas de runner según el activador del flujo de trabajo:

name: CI/CD Pipeline
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  validate:
    if: github.event_name == 'pull_request'
    runs-on: arc-runner-pr
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: make test
      - name: Run linter
        run: make lint

  deploy:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: arc-runner-deploy
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: make deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Esto implementa separación de funciones a nivel de runner. Los trabajos de validación de PR se ejecutan en runners que no tienen acceso a secretos de despliegue ni a segmentos de red privilegiados. Los trabajos de despliegue se ejecutan en un conjunto separado de runners que tienen las credenciales y acceso de red necesarios, pero solo se activan con pushes a main.

Ejercicio 6: Autoescalado

ARC soporta autoescalado de forma nativa. Los pods de runners se crean bajo demanda y se destruyen cuando están inactivos. Puedes configurar réplicas mínimas y máximas para controlar el costo y la capacidad de respuesta.

Paso 1: Configurar parámetros de autoescalado

Actualiza tu archivo de valores del runner scale set para incluir parámetros de escalado:

githubConfigUrl: "https://github.com/<org>/<repo>"
githubConfigSecret:
  github_token: "<PAT>"

minRunners: 0
maxRunners: 10

template:
  spec:
    containers:
      - name: runner
        image: ghcr.io/actions/actions-runner:latest
        command: ["/home/runner/run.sh"]
helm upgrade arc-runner-set \
  actions-runner-controller/gha-runner-scale-set \
  --namespace arc-runners \
  -f autoscale-values.yaml

Paso 2: Generar carga

Crea un flujo de trabajo que active múltiples trabajos en paralelo:

name: Autoscale Test
on: workflow_dispatch

jobs:
  parallel-job:
    runs-on: arc-runner-set
    strategy:
      matrix:
        id: [1, 2, 3, 4, 5]
    steps:
      - name: Simulate work
        run: |
          echo "Job ${{ matrix.id }} running on $(hostname)"
          sleep 60

Activa este flujo de trabajo y observa cómo los pods escalan:

kubectl get pods -n arc-runners -w

Verás cinco pods creados — uno para cada trabajo de la matriz:

NAME                              READY   STATUS    RESTARTS   AGE
arc-runner-set-abcde-runner       1/1     Running   0          5s
arc-runner-set-fghij-runner       1/1     Running   0          5s
arc-runner-set-klmno-runner       1/1     Running   0          5s
arc-runner-set-pqrst-runner       1/1     Running   0          5s
arc-runner-set-uvwxy-runner       1/1     Running   0          5s

Después de que los trabajos se completen (60 segundos), todos los pods se terminan. El namespace vuelve a cero pods.

Paso 3: Configurar el retraso de reducción de escala

Para optimización de costos, puede que quieras que los pods permanezcan activos por un corto período después de que un trabajo se complete. Esto evita la latencia de arranque en frío para cargas de trabajo intermitentes. El comportamiento de escalar a cero de ARC es la opción predeterminada y más segura. Si necesitas runners en caliente, mantén la ventana corta (menos de 5 minutos) y asegúrate de que el modo efímero siga aplicándose.

Ejercicio 7: Políticas de red para runners

Las NetworkPolicies de Kubernetes te permiten restringir el acceso de red de los pods de runners. Esta es una defensa crítica contra la exfiltración de datos desde compilaciones comprometidas.

Paso 1: Crear una NetworkPolicy

Aplica la siguiente NetworkPolicy al namespace arc-runners:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: runner-egress-policy
  namespace: arc-runners
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    # Allow DNS resolution
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    # Allow GitHub API and Actions services
    - to:
        - ipBlock:
            cidr: 140.82.112.0/20
        - ipBlock:
            cidr: 143.55.64.0/20
        - ipBlock:
            cidr: 185.199.108.0/22
        - ipBlock:
            cidr: 4.0.0.0/8
      ports:
        - protocol: TCP
          port: 443
    # Allow your container registry (example: ghcr.io)
    - to:
        - ipBlock:
            cidr: 140.82.112.0/20
      ports:
        - protocol: TCP
          port: 443
    # Allow your artifact storage (replace with your CIDR)
    # - to:
    #     - ipBlock:
    #         cidr: 10.0.0.0/8
    #   ports:
    #     - protocol: TCP
    #       port: 443
kubectl apply -f runner-network-policy.yaml

Nota: GitHub publica sus rangos de IP en https://api.github.com/meta. Usa los rangos de actions y api. Los CIDRs anteriores son ejemplos — verifica los rangos actuales y actualízalos en consecuencia.

Paso 2: Probar la NetworkPolicy

Crea un flujo de trabajo que intente alcanzar una URL externa:

name: Network Policy Test
on: workflow_dispatch

jobs:
  test-network:
    runs-on: arc-runner-set
    steps:
      - name: Test GitHub API (should work)
        run: curl -s -o /dev/null -w "%{http_code}" https://api.github.com

      - name: Test external URL (should be blocked)
        run: |
          if curl -s --connect-timeout 5 https://evil-exfiltration-server.example.com; then
            echo "FAIL: External access was allowed"
            exit 1
          else
            echo "PASS: External access was blocked by NetworkPolicy"
          fi

Cuando ejecutes este flujo de trabajo:

  • La solicitud a la API de GitHub tiene éxito (HTTP 200) porque la NetworkPolicy permite el tráfico a los rangos de IP de GitHub.
  • La solicitud a la URL externa agota el tiempo y falla porque no está en la lista de egreso permitido.

Esto previene que una compilación comprometida exfiltre código fuente, secretos o artefactos de compilación a un servidor controlado por un atacante. Incluso si una dependencia maliciosa ejecuta código arbitrario durante la compilación, no puede comunicarse con el exterior.

Limpieza

Elimina todos los recursos creados durante este laboratorio:

# Delete Helm releases
helm uninstall arc-runner-set -n arc-runners
helm uninstall arc-runner-pr -n arc-runners
helm uninstall arc-runner-deploy -n arc-runners
helm uninstall arc -n arc-systems

# Delete namespaces
kubectl delete namespace arc-runners
kubectl delete namespace arc-systems

# Delete the kind cluster
kind delete cluster --name arc-lab

Si creaste una GitHub App para este laboratorio, puedes eliminarla desde Settings → Developer settings → GitHub Apps. Revoca cualquier PAT que hayas creado.

Conclusiones clave

  • Los runners efímeros eliminan la contaminación entre trabajos. Cada trabajo obtiene un contenedor nuevo — secretos, tokens y artefactos de compilación se destruyen cuando el trabajo se completa.
  • ARC proporciona los beneficios de runners autoalojados sin los riesgos de seguridad. Obtienes herramientas personalizadas, acceso a red privada y control de costos mientras mantienes el modelo de seguridad efímero.
  • Las imágenes personalizadas de runners te dan control total sobre el entorno de compilación. Fija versiones de herramientas, escanea vulnerabilidades y elimina el riesgo de cadena de suministro del software preinstalado.
  • El aislamiento de grupos de runners implementa la separación de funciones. Los flujos de trabajo de validación de PR y despliegue se ejecutan en conjuntos de runners separados con diferentes privilegios y acceso de red.
  • Las políticas de red son una capa crítica de defensa. Restringir el egreso de los runners previene la exfiltración de datos incluso si un paso de compilación está comprometido.
  • El autoescalado a cero reduce costos y superficie de ataque. Los pods de runners existen solo durante la duración de un trabajo — no hay infraestructura persistente que mantener o asegurar.

Próximos pasos

Continúa fortaleciendo tu postura de seguridad CI/CD con estas guías relacionadas: