Skip to content

Pipelines de CI basados en EKS: compilación y push a ECR

Versiones compatibles: Amazon EKS 1.28+, GitLab Runner 16.x+, Actions Runner Controller 0.9+ Última actualización: February 23, 2026

< Anterior: NLB Blue/Green | Tabla de contenido | Siguiente: ArgoCD multi-cluster >


Descripción general

Crear imágenes de contenedor dentro de clusters EKS elimina dependencias de infraestructura de CI externa y proporciona una integración más estrecha con los servicios de AWS. Esta guía cubre la configuración de pipelines de CI mediante GitLab Runner y GitHub Actions Runner Controller, con estrategias de compilación multi-platform para arquitecturas ARM (Graviton) y x86.

Beneficios clave:

  • Integración nativa con ECR mediante Pod Identity
  • Runners con escalado automático basado en la carga de trabajo
  • Builds multi-architecture en hardware nativo
  • Optimización de costos con instancias Spot

1. Configuración de repositorios ECR

1.1 Módulo Terraform de ECR

Crea un módulo reutilizable de repositorio ECR con políticas de lifecycle, scanning y acceso cross-account.

hcl
# modules/ecr/main.tf

variable "repository_name" {
  description = "Name of the ECR repository"
  type        = string
}

variable "image_tag_mutability" {
  description = "Tag mutability setting"
  type        = string
  default     = "IMMUTABLE"
}

variable "scan_on_push" {
  description = "Enable image scanning on push"
  type        = bool
  default     = true
}

variable "encryption_type" {
  description = "Encryption type (AES256 or KMS)"
  type        = string
  default     = "AES256"
}

variable "kms_key_arn" {
  description = "KMS key ARN for encryption (required if encryption_type is KMS)"
  type        = string
  default     = null
}

variable "cross_account_ids" {
  description = "List of AWS account IDs for cross-account access"
  type        = list(string)
  default     = []
}

variable "lifecycle_policy_count" {
  description = "Number of images to retain"
  type        = number
  default     = 30
}

resource "aws_ecr_repository" "this" {
  name                 = var.repository_name
  image_tag_mutability = var.image_tag_mutability

  image_scanning_configuration {
    scan_on_push = var.scan_on_push
  }

  encryption_configuration {
    encryption_type = var.encryption_type
    kms_key         = var.kms_key_arn
  }

  tags = {
    Name        = var.repository_name
    ManagedBy   = "terraform"
    Environment = terraform.workspace
  }
}

resource "aws_ecr_lifecycle_policy" "this" {
  repository = aws_ecr_repository.this.name

  policy = jsonencode({
    rules = [
      {
        rulePriority = 1
        description  = "Keep last ${var.lifecycle_policy_count} images"
        selection = {
          tagStatus     = "tagged"
          tagPrefixList = ["v", "release"]
          countType     = "imageCountMoreThan"
          countNumber   = var.lifecycle_policy_count
        }
        action = {
          type = "expire"
        }
      },
      {
        rulePriority = 2
        description  = "Remove untagged images older than 7 days"
        selection = {
          tagStatus   = "untagged"
          countType   = "sinceImagePushed"
          countUnit   = "days"
          countNumber = 7
        }
        action = {
          type = "expire"
        }
      },
      {
        rulePriority = 3
        description  = "Remove dev/feature images older than 14 days"
        selection = {
          tagStatus     = "tagged"
          tagPrefixList = ["dev-", "feature-", "pr-"]
          countType     = "sinceImagePushed"
          countUnit     = "days"
          countNumber   = 14
        }
        action = {
          type = "expire"
        }
      }
    ]
  })
}

# Cross-account access policy
resource "aws_ecr_repository_policy" "cross_account" {
  count      = length(var.cross_account_ids) > 0 ? 1 : 0
  repository = aws_ecr_repository.this.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "CrossAccountPull"
        Effect = "Allow"
        Principal = {
          AWS = [for id in var.cross_account_ids : "arn:aws:iam::${id}:root"]
        }
        Action = [
          "ecr:GetDownloadUrlForLayer",
          "ecr:BatchGetImage",
          "ecr:BatchCheckLayerAvailability"
        ]
      }
    ]
  })
}

output "repository_url" {
  value = aws_ecr_repository.this.repository_url
}

output "repository_arn" {
  value = aws_ecr_repository.this.arn
}

1.2 Configuración de replicación de ECR

Habilita la replicación cross-region para recuperación ante desastres:

hcl
# ecr-replication.tf

resource "aws_ecr_replication_configuration" "this" {
  replication_configuration {
    rule {
      destination {
        region      = "us-west-2"
        registry_id = data.aws_caller_identity.current.account_id
      }

      repository_filter {
        filter      = "prod-"
        filter_type = "PREFIX_MATCH"
      }
    }

    rule {
      destination {
        region      = "eu-west-1"
        registry_id = data.aws_caller_identity.current.account_id
      }

      repository_filter {
        filter      = "prod-"
        filter_type = "PREFIX_MATCH"
      }
    }
  }
}

1.3 Configuración de scanning mejorado

Configura el scanning mejorado de ECR con Inspector:

hcl
# ecr-scanning.tf

resource "aws_ecr_registry_scanning_configuration" "this" {
  scan_type = "ENHANCED"

  rule {
    scan_frequency = "CONTINUOUS_SCAN"
    repository_filter {
      filter      = "*"
      filter_type = "WILDCARD"
    }
  }

  rule {
    scan_frequency = "SCAN_ON_PUSH"
    repository_filter {
      filter      = "dev-*"
      filter_type = "WILDCARD"
    }
  }
}

2. GitLab Runner en EKS

2.1 Valores Helm de GitLab Runner

Despliega GitLab Runner con Kubernetes executor para builds dinámicos basados en pod:

yaml
# gitlab-runner/values.yaml

gitlabUrl: https://gitlab.example.com/
runnerRegistrationToken: ""  # Use runnerToken instead (deprecated)

# Use authentication token (GitLab 16.0+)
runnerToken: ""  # Set via --set or external secret

concurrent: 10
checkInterval: 3

rbac:
  create: true
  rules:
    - apiGroups: [""]
      resources: ["pods", "pods/exec", "secrets", "configmaps"]
      verbs: ["get", "list", "watch", "create", "patch", "delete"]
    - apiGroups: [""]
      resources: ["pods/log"]
      verbs: ["get"]

serviceAccount:
  create: true
  name: gitlab-runner
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/GitLabRunnerRole

runners:
  config: |
    [[runners]]
      name = "eks-runner"
      executor = "kubernetes"
      [runners.kubernetes]
        namespace = "gitlab-runner"
        image = "alpine:latest"
        privileged = false

        # Pod resources
        cpu_limit = "2"
        cpu_request = "500m"
        memory_limit = "4Gi"
        memory_request = "1Gi"

        # Service account for builds
        service_account = "gitlab-runner-build"

        # Node selection
        [runners.kubernetes.node_selector]
          "kubernetes.io/os" = "linux"
          "node.kubernetes.io/instance-type" = "m6i.xlarge"

        # Tolerations for dedicated CI nodes
        [[runners.kubernetes.node_tolerations]]
          key = "ci-workload"
          operator = "Equal"
          value = "true"
          effect = "NoSchedule"

        # Pod labels
        [runners.kubernetes.pod_labels]
          "app.kubernetes.io/component" = "ci-build"

        # Pod annotations for monitoring
        [runners.kubernetes.pod_annotations]
          "prometheus.io/scrape" = "true"

        # Helper image configuration
        helper_image = "gitlab/gitlab-runner-helper:x86_64-latest"

        # Build container security context
        [runners.kubernetes.build_container_security_context]
          run_as_user = 1000
          run_as_group = 1000
          run_as_non_root = true

        # Volume mounts for caching
        [[runners.kubernetes.volumes.empty_dir]]
          name = "docker-cache"
          mount_path = "/var/lib/docker"
          medium = "Memory"

        [[runners.kubernetes.volumes.empty_dir]]
          name = "build-cache"
          mount_path = "/cache"

      [runners.cache]
        Type = "s3"
        Shared = true
        [runners.cache.s3]
          ServerAddress = "s3.amazonaws.com"
          BucketName = "gitlab-runner-cache-123456789012"
          BucketLocation = "us-east-1"

  # Tags for job matching
  tags: "eks,docker,linux"
  runUntagged: false
  protected: false

# Resource limits for runner manager pod
resources:
  limits:
    memory: 256Mi
    cpu: 200m
  requests:
    memory: 128Mi
    cpu: 100m

# Pod security context
podSecurityContext:
  runAsNonRoot: true
  runAsUser: 100
  fsGroup: 65533

# Affinity for runner manager
affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
            - key: node.kubernetes.io/instance-type
              operator: In
              values:
                - t3.medium
                - t3.large

# Metrics for monitoring
metrics:
  enabled: true
  portName: metrics
  port: 9252
  serviceMonitor:
    enabled: true

2.2 Rol IAM para GitLab Runner (Pod Identity)

hcl
# gitlab-runner-iam.tf

# IAM Role for GitLab Runner
resource "aws_iam_role" "gitlab_runner" {
  name = "GitLabRunnerRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "pods.eks.amazonaws.com"
        }
        Action = [
          "sts:AssumeRole",
          "sts:TagSession"
        ]
      }
    ]
  })
}

# ECR push policy
resource "aws_iam_role_policy" "gitlab_runner_ecr" {
  name = "ecr-push-policy"
  role = aws_iam_role.gitlab_runner.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ecr:GetAuthorizationToken"
        ]
        Resource = "*"
      },
      {
        Effect = "Allow"
        Action = [
          "ecr:BatchCheckLayerAvailability",
          "ecr:GetDownloadUrlForLayer",
          "ecr:BatchGetImage",
          "ecr:PutImage",
          "ecr:InitiateLayerUpload",
          "ecr:UploadLayerPart",
          "ecr:CompleteLayerUpload",
          "ecr:DescribeRepositories",
          "ecr:ListImages"
        ]
        Resource = "arn:aws:ecr:*:${data.aws_caller_identity.current.account_id}:repository/*"
      }
    ]
  })
}

# S3 cache policy
resource "aws_iam_role_policy" "gitlab_runner_cache" {
  name = "s3-cache-policy"
  role = aws_iam_role.gitlab_runner.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:DeleteObject",
          "s3:ListBucket"
        ]
        Resource = [
          "arn:aws:s3:::gitlab-runner-cache-${data.aws_caller_identity.current.account_id}",
          "arn:aws:s3:::gitlab-runner-cache-${data.aws_caller_identity.current.account_id}/*"
        ]
      }
    ]
  })
}

# Pod Identity Association
resource "aws_eks_pod_identity_association" "gitlab_runner" {
  cluster_name    = var.cluster_name
  namespace       = "gitlab-runner"
  service_account = "gitlab-runner"
  role_arn        = aws_iam_role.gitlab_runner.arn
}

# Also create association for build service account
resource "aws_eks_pod_identity_association" "gitlab_runner_build" {
  cluster_name    = var.cluster_name
  namespace       = "gitlab-runner"
  service_account = "gitlab-runner-build"
  role_arn        = aws_iam_role.gitlab_runner.arn
}

2.3 Pipeline de GitLab CI completo

yaml
# .gitlab-ci.yml

stages:
  - build
  - test
  - security
  - push
  - deploy

variables:
  AWS_REGION: us-east-1
  ECR_REGISTRY: ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
  IMAGE_NAME: myapp
  DOCKER_BUILDKIT: "1"

.docker-login: &docker-login
  - aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ECR_REGISTRY}

.build-base:
  tags:
    - eks
    - docker
  before_script:
    - *docker-login

# Build stage
build:
  extends: .build-base
  stage: build
  image: docker:24-dind
  services:
    - docker:24-dind
  script:
    - docker build
        --cache-from ${ECR_REGISTRY}/${IMAGE_NAME}:cache
        --build-arg BUILDKIT_INLINE_CACHE=1
        -t ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}
        -t ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_REF_SLUG}
        .
    - docker push ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}
    - docker push ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_REF_SLUG}
  cache:
    key: docker-${CI_COMMIT_REF_SLUG}
    paths:
      - /cache
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# Unit tests
test:unit:
  stage: test
  image: ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}
  tags:
    - eks
  script:
    - npm test
  coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
  artifacts:
    reports:
      junit: test-results.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
  needs:
    - build

# Integration tests
test:integration:
  stage: test
  image: ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}
  tags:
    - eks
  services:
    - name: postgres:15
      alias: db
    - name: redis:7
      alias: cache
  variables:
    DATABASE_URL: postgres://postgres:postgres@db:5432/test
    REDIS_URL: redis://cache:6379
  script:
    - npm run test:integration
  needs:
    - build
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual

# Security scanning
security:trivy:
  stage: security
  image: aquasec/trivy:latest
  tags:
    - eks
  before_script:
    - *docker-login
  script:
    - trivy image
        --exit-code 1
        --severity HIGH,CRITICAL
        --ignore-unfixed
        --format json
        --output trivy-report.json
        ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}
  artifacts:
    reports:
      container_scanning: trivy-report.json
  allow_failure: true
  needs:
    - build

# Push to production registry
push:production:
  extends: .build-base
  stage: push
  image: docker:24
  script:
    - docker pull ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}
    - docker tag ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA} ${ECR_REGISTRY}/${IMAGE_NAME}:latest
    - docker tag ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA} ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_TAG}
    - docker push ${ECR_REGISTRY}/${IMAGE_NAME}:latest
    - docker push ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_TAG}
  needs:
    - test:unit
    - security:trivy
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/

# Deploy to staging
deploy:staging:
  stage: deploy
  image: bitnami/kubectl:latest
  tags:
    - eks
  script:
    - kubectl set image deployment/myapp myapp=${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA} -n staging
    - kubectl rollout status deployment/myapp -n staging --timeout=300s
  environment:
    name: staging
    url: https://staging.example.com
  needs:
    - test:unit
    - test:integration
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

2.4 Gestión del token de Runner con External Secrets

yaml
# gitlab-runner-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: gitlab-runner-token
  namespace: gitlab-runner
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: gitlab-runner-secret
    creationPolicy: Owner
  data:
    - secretKey: runner-token
      remoteRef:
        key: gitlab/runner-token
        property: token

3. GitHub Self-Hosted Runner (Actions Runner Controller)

3.1 Instalación de ARC con Helm

bash
# Add the ARC Helm repository
helm repo add actions-runner-controller https://actions-runner-controller.github.io/actions-runner-controller
helm repo update

# Create namespace
kubectl create namespace actions-runner-system
yaml
# arc-values.yaml

replicaCount: 1

image:
  repository: ghcr.io/actions/actions-runner-controller
  tag: "0.9.3"

serviceAccount:
  create: true
  name: actions-runner-controller
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/ActionsRunnerControllerRole

# Authentication via GitHub App (recommended)
authSecret:
  enabled: true
  create: false
  name: controller-manager

# GitHub App configuration
githubAPP:
  enabled: true

# Controller resources
resources:
  limits:
    cpu: 500m
    memory: 512Mi
  requests:
    cpu: 100m
    memory: 128Mi

# Metrics
metrics:
  serviceMonitor:
    enabled: true

# Pod security
podSecurityContext:
  runAsNonRoot: true
  runAsUser: 1000
  fsGroup: 1000

# Webhook configuration for scale-from-zero
githubWebhookServer:
  enabled: true
  replicaCount: 1
  service:
    type: ClusterIP
  ingress:
    enabled: true
    ingressClassName: alb
    annotations:
      alb.ingress.kubernetes.io/scheme: internet-facing
      alb.ingress.kubernetes.io/target-type: ip
      alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:123456789012:certificate/xxx
    hosts:
      - host: arc-webhook.example.com
        paths:
          - path: /
            pathType: Prefix

Instala ARC:

bash
helm install arc actions-runner-controller/actions-runner-controller \
  -n actions-runner-system \
  -f arc-values.yaml

3.2 Secret de GitHub App

yaml
# github-app-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: controller-manager
  namespace: actions-runner-system
type: Opaque
stringData:
  github_app_id: "123456"
  github_app_installation_id: "12345678"
  github_app_private_key: |
    -----BEGIN RSA PRIVATE KEY-----
    ...
    -----END RSA PRIVATE KEY-----

3.3 Configuración de RunnerDeployment

yaml
# runner-deployment.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: eks-runners
  namespace: actions-runner-system
spec:
  replicas: 2
  template:
    spec:
      organization: my-org
      # Or use repository for repo-level runners:
      # repository: my-org/my-repo

      labels:
        - eks
        - linux
        - x64

      group: production

      image: summerwind/actions-runner:latest

      serviceAccountName: actions-runner

      resources:
        limits:
          cpu: "2"
          memory: 4Gi
        requests:
          cpu: "500m"
          memory: 1Gi

      nodeSelector:
        kubernetes.io/os: linux
        kubernetes.io/arch: amd64

      tolerations:
        - key: ci-workload
          operator: Equal
          value: "true"
          effect: NoSchedule

      # Docker-in-Docker mode
      dockerdWithinRunnerContainer: true

      # Volume mounts
      volumeMounts:
        - name: work
          mountPath: /runner/_work

      volumes:
        - name: work
          emptyDir: {}

      env:
        - name: DOCKER_BUILDKIT
          value: "1"
        - name: AWS_REGION
          value: us-east-1

3.4 RunnerSet para runners con estado

yaml
# runner-set.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerSet
metadata:
  name: eks-runner-set
  namespace: actions-runner-system
spec:
  organization: my-org

  replicas: 3

  selector:
    matchLabels:
      app: runner

  serviceName: runner

  template:
    metadata:
      labels:
        app: runner
    spec:
      serviceAccountName: actions-runner

      containers:
        - name: runner
          image: summerwind/actions-runner:latest
          resources:
            limits:
              cpu: "4"
              memory: 8Gi
            requests:
              cpu: "1"
              memory: 2Gi

          volumeMounts:
            - name: runner-work
              mountPath: /runner/_work
            - name: docker-cache
              mountPath: /var/lib/docker

      nodeSelector:
        node.kubernetes.io/instance-type: m6i.2xlarge

  volumeClaimTemplates:
    - metadata:
        name: runner-work
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: gp3
        resources:
          requests:
            storage: 100Gi
    - metadata:
        name: docker-cache
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: gp3
        resources:
          requests:
            storage: 50Gi

3.5 HorizontalRunnerAutoscaler (escalado desde cero)

yaml
# horizontal-runner-autoscaler.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
  name: eks-runners-autoscaler
  namespace: actions-runner-system
spec:
  scaleTargetRef:
    kind: RunnerDeployment
    name: eks-runners

  minReplicas: 0
  maxReplicas: 20

  scaleDownDelaySecondsAfterScaleOut: 300

  metrics:
    - type: TotalNumberOfQueuedAndInProgressWorkflowRuns
      repositoryNames:
        - my-org/repo1
        - my-org/repo2

  # Scale up triggers
  scaleUpTriggers:
    - githubEvent:
        workflowJob: {}
      amount: 1
      duration: "5m"

3.6 Workflow completo de GitHub Actions

yaml
# .github/workflows/build-push.yaml

name: Build and Push to ECR

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: myapp

permissions:
  id-token: write
  contents: read
  packages: write

jobs:
  build:
    runs-on: [self-hosted, eks, linux, x64]

    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
      image-digest: ${{ steps.build.outputs.digest }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsRole
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=sha,prefix=

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:cache
          cache-to: type=registry,ref=${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:cache,mode=max
          provenance: false

  test:
    runs-on: [self-hosted, eks, linux, x64]
    needs: build
    if: github.event_name == 'pull_request'

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Run tests
        run: |
          npm ci
          npm test

  security-scan:
    runs-on: [self-hosted, eks, linux, x64]
    needs: build
    if: github.event_name != 'pull_request'

    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsRole
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ needs.build.outputs.image-tag }}
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'HIGH,CRITICAL'

      - name: Upload Trivy scan results
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy-results.sarif'

  deploy:
    runs-on: [self-hosted, eks, linux, x64]
    needs: [build, security-scan]
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - name: Deploy to EKS
        run: |
          kubectl set image deployment/myapp \
            myapp=${{ needs.build.outputs.image-tag }} \
            -n production
          kubectl rollout status deployment/myapp -n production

3.7 Rol IAM para GitHub Actions Runner

hcl
# github-actions-runner-iam.tf

resource "aws_iam_role" "github_actions_runner" {
  name = "GitHubActionsRunnerRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "pods.eks.amazonaws.com"
        }
        Action = [
          "sts:AssumeRole",
          "sts:TagSession"
        ]
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "github_actions_ecr" {
  role       = aws_iam_role.github_actions_runner.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser"
}

resource "aws_eks_pod_identity_association" "github_actions_runner" {
  cluster_name    = var.cluster_name
  namespace       = "actions-runner-system"
  service_account = "actions-runner"
  role_arn        = aws_iam_role.github_actions_runner.arn
}

4. Build multi-platform (ARM + x86)

4.1 Configuración de runner ARM (Graviton)

yaml
# arm-runner-deployment.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: eks-arm-runners
  namespace: actions-runner-system
spec:
  replicas: 2
  template:
    spec:
      organization: my-org

      labels:
        - eks
        - linux
        - arm64
        - graviton

      image: summerwind/actions-runner:latest

      serviceAccountName: actions-runner

      resources:
        limits:
          cpu: "4"
          memory: 8Gi
        requests:
          cpu: "1"
          memory: 2Gi

      nodeSelector:
        kubernetes.io/os: linux
        kubernetes.io/arch: arm64
        node.kubernetes.io/instance-type: m7g.xlarge

      tolerations:
        - key: arch
          operator: Equal
          value: arm64
          effect: NoSchedule

      dockerdWithinRunnerContainer: true

4.2 Configuración de runner x86

yaml
# x86-runner-deployment.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: eks-x86-runners
  namespace: actions-runner-system
spec:
  replicas: 2
  template:
    spec:
      organization: my-org

      labels:
        - eks
        - linux
        - amd64
        - x64

      image: summerwind/actions-runner:latest

      nodeSelector:
        kubernetes.io/os: linux
        kubernetes.io/arch: amd64
        node.kubernetes.io/instance-type: m6i.xlarge

      dockerdWithinRunnerContainer: true

4.3 Workflow multi-platform de GitHub Actions

yaml
# .github/workflows/multi-arch-build.yaml

name: Multi-Architecture Build

on:
  push:
    branches: [main]
    tags: ['v*']

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: myapp

permissions:
  id-token: write
  contents: read

jobs:
  build-amd64:
    runs-on: [self-hosted, eks, linux, amd64]
    outputs:
      digest: ${{ steps.build.outputs.digest }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsRole
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push AMD64
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}-amd64
          platforms: linux/amd64
          provenance: false

  build-arm64:
    runs-on: [self-hosted, eks, linux, arm64]
    outputs:
      digest: ${{ steps.build.outputs.digest }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsRole
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push ARM64
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}-arm64
          platforms: linux/arm64
          provenance: false

  create-manifest:
    runs-on: [self-hosted, eks, linux, amd64]
    needs: [build-amd64, build-arm64]

    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsRole
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Create and push manifest
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          # Create manifest list
          docker manifest create ${REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA} \
            ${REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA}-amd64 \
            ${REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA}-arm64

          # Annotate with architecture info
          docker manifest annotate ${REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA} \
            ${REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA}-amd64 \
            --os linux --arch amd64

          docker manifest annotate ${REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA} \
            ${REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA}-arm64 \
            --os linux --arch arm64

          # Push manifest
          docker manifest push ${REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA}

          # Tag as latest for main branch
          if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
            docker manifest create ${REGISTRY}/${ECR_REPOSITORY}:latest \
              ${REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA}-amd64 \
              ${REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA}-arm64
            docker manifest push ${REGISTRY}/${ECR_REPOSITORY}:latest
          fi

4.4 GitLab CI multi-platform con matrix

yaml
# .gitlab-ci.yml (multi-arch)

stages:
  - build
  - manifest
  - deploy

variables:
  AWS_REGION: us-east-1
  ECR_REGISTRY: ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
  IMAGE_NAME: myapp

.docker-login: &docker-login
  - aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ECR_REGISTRY}

# Parallel architecture builds
build:
  stage: build
  image: docker:24-dind
  services:
    - docker:24-dind
  parallel:
    matrix:
      - ARCH: amd64
        RUNNER_TAG: x64
      - ARCH: arm64
        RUNNER_TAG: graviton
  tags:
    - eks
    - ${RUNNER_TAG}
  before_script:
    - *docker-login
  script:
    - docker build
        --platform linux/${ARCH}
        -t ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}-${ARCH}
        .
    - docker push ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}-${ARCH}

# Create multi-arch manifest
manifest:
  stage: manifest
  image: docker:24
  tags:
    - eks
    - x64
  before_script:
    - *docker-login
  script:
    # Create manifest list
    - docker manifest create ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}
        ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}-amd64
        ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}-arm64

    # Annotate architectures
    - docker manifest annotate ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}
        ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}-amd64
        --os linux --arch amd64

    - docker manifest annotate ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}
        ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}-arm64
        --os linux --arch arm64

    # Push manifest
    - docker manifest push ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}

    # Tag as latest for default branch
    - |
      if [ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]; then
        docker manifest create ${ECR_REGISTRY}/${IMAGE_NAME}:latest \
          ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}-amd64 \
          ${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}-arm64
        docker manifest push ${ECR_REGISTRY}/${IMAGE_NAME}:latest
      fi
  needs:
    - build

4.5 Buildx multi-platform (runner único)

Para configuraciones más simples, usa Docker Buildx con emulación QEMU:

yaml
# .github/workflows/buildx-multi-arch.yaml

name: Buildx Multi-Architecture

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: [self-hosted, eks, linux, amd64]

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsRole
          aws-region: us-east-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push multi-platform
        uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: |
            ${{ steps.login-ecr.outputs.registry }}/myapp:${{ github.sha }}
            ${{ steps.login-ecr.outputs.registry }}/myapp:latest
          cache-from: type=registry,ref=${{ steps.login-ecr.outputs.registry }}/myapp:cache
          cache-to: type=registry,ref=${{ steps.login-ecr.outputs.registry }}/myapp:cache,mode=max

5. Optimización de builds

5.1 Caché de BuildKit con ECR

dockerfile
# syntax=docker/dockerfile:1.4

FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN --mount=type=cache,target=/root/.npm \
    npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Build con caché:

bash
# Build with ECR cache
docker buildx build \
  --cache-from type=registry,ref=${ECR_REGISTRY}/myapp:cache \
  --cache-to type=registry,ref=${ECR_REGISTRY}/myapp:cache,mode=max \
  --push \
  -t ${ECR_REGISTRY}/myapp:${VERSION} \
  .

5.2 Builds con Kaniko (rootless)

Kaniko permite crear imágenes sin Docker daemon:

yaml
# kaniko-build-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: kaniko-build
  namespace: ci
spec:
  backoffLimit: 0
  template:
    spec:
      serviceAccountName: kaniko-builder
      restartPolicy: Never
      containers:
        - name: kaniko
          image: gcr.io/kaniko-project/executor:latest
          args:
            - --dockerfile=Dockerfile
            - --context=git://github.com/myorg/myrepo.git#refs/heads/main
            - --destination=123456789012.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
            - --cache=true
            - --cache-repo=123456789012.dkr.ecr.us-east-1.amazonaws.com/myapp/cache
            - --snapshot-mode=redo
            - --use-new-run
          env:
            - name: AWS_REGION
              value: us-east-1
          resources:
            limits:
              cpu: "2"
              memory: 4Gi
            requests:
              cpu: "1"
              memory: 2Gi

GitLab CI con Kaniko:

yaml
# .gitlab-ci.yml (kaniko)

build:kaniko:
  stage: build
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  tags:
    - eks
  script:
    - |
      /kaniko/executor \
        --context "${CI_PROJECT_DIR}" \
        --dockerfile "${CI_PROJECT_DIR}/Dockerfile" \
        --destination "${ECR_REGISTRY}/${IMAGE_NAME}:${CI_COMMIT_SHA}" \
        --cache=true \
        --cache-repo="${ECR_REGISTRY}/${IMAGE_NAME}/cache" \
        --snapshot-mode=redo \
        --use-new-run

5.3 Estrategias de caching de capas

dockerfile
# Optimized Dockerfile for caching

# Stage 1: Dependencies (cached unless package files change)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# Stage 2: Build (cached unless source changes)
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# Copy configuration first (changes less often)
COPY tsconfig.json next.config.js ./
# Copy source last (changes most often)
COPY src ./src
COPY public ./public
RUN npm run build

# Stage 3: Production image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
CMD ["node", "server.js"]

5.4 Caché S3 para artefactos de build

yaml
# GitLab Runner cache configuration
runners:
  config: |
    [[runners]]
      [runners.cache]
        Type = "s3"
        Shared = true
        [runners.cache.s3]
          ServerAddress = "s3.amazonaws.com"
          BucketName = "ci-cache-bucket"
          BucketLocation = "us-east-1"
hcl
# S3 bucket for CI cache
resource "aws_s3_bucket" "ci_cache" {
  bucket = "ci-cache-${data.aws_caller_identity.current.account_id}"
}

resource "aws_s3_bucket_lifecycle_configuration" "ci_cache" {
  bucket = aws_s3_bucket.ci_cache.id

  rule {
    id     = "expire-old-cache"
    status = "Enabled"

    expiration {
      days = 7
    }

    filter {
      prefix = "runner/"
    }
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "ci_cache" {
  bucket = aws_s3_bucket.ci_cache.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

Resumen

Esta guía cubrió la configuración completa para pipelines de CI basados en EKS:

ComponentePropósitoCaracterísticas clave
ECRContainer registryPolíticas de lifecycle, scanning, acceso cross-account
GitLab RunnerEjecutor de GitLab CIKubernetes executor, Pod Identity, caché S3
ARCRunner de GitHub ActionsEscalado desde cero, autoscaling basado en webhook
Multi-platformBuilds ARM + x86Runners nativos, listas de manifest
BuildKit/KanikoBuilds optimizadosCaching de capas, builds rootless

Buenas prácticas:

  • Usa Pod Identity para acceso seguro a AWS (sin credenciales estáticas)
  • Habilita el escalado desde cero para optimizar costos
  • Implementa Dockerfiles multi-stage para imágenes más pequeñas
  • Usa caching basado en registry para builds más rápidos
  • Ejecuta security scans como parte del pipeline

Documentación relacionada


< Anterior: NLB Blue/Green | Tabla de contenido | Siguiente: ArgoCD multi-cluster >