Menggunakan AWS Spot Instance sebagai CI/CD Runner
13 min read

Menggunakan AWS Spot Instance sebagai CI/CD Runner

CI/CD pipeline di banyak organisasi sudah jauh melampaui sekadar go test dan go build. Terraform plan yang harus menarik state dari ratusan resource, SonarQube analysis yang memakan 4 vCPU selama 10 menit, Java Gradle build dengan dependency graph yang besar, Docker build multi-stage dengan layer cache yang tebal — semua ini mengubah runner dari “komputer kecil yang sesekali dipakai” menjadi beban infrastruktur yang serius. Dan masalahnya konsisten: runner self-hosted yang always-on over-provisioned untuk peak load tapi idle sebagian besar waktu, atau shared runner yang menciptakan antrean panjang yang membuat developer frustrasi menunggu feedback. AWS Spot Instance menawarkan jalan keluar yang elegan: compute kelas atas, harga 70–90% lebih murah dari On-Demand, dipakai hanya saat dibutuhkan, dan dihancurkan begitu job selesai. Artikel ini membahas cara kerja, implementasi lengkap, dan trade-off yang perlu dipahami sebelum mengadopsinya.

Mengapa CI/CD adalah Spot Workload yang Sempurna

Spot Instance memiliki satu karakteristik yang tidak bisa ditawar: AWS bisa mengambilnya kembali kapan saja dengan pemberitahuan 2 menit. Bagi production server, ini adalah disqualifier. Bagi CI/CD runner, ini hampir tidak relevan — jika dipahami dengan benar.

Perhatikan karakteristik CI/CD job yang membuatnya cocok secara alami:

CI/CD JOB vs PRODUCTION WORKLOAD

CI/CD Job:
  ✓ Stateless — tidak menyimpan state di luar artifact
  ✓ Ephemeral — dirancang untuk mati setelah selesai
  ✓ Retriable — jika gagal, tinggal jalankan ulang
  ✓ Durasi pendek — kebanyakan 5–30 menit
  ✓ Bisa dijadwalkan — tidak ada user yang menunggu secara langsung
  ✓ Idempotent — hasil yang sama jika dijalankan ulang dari awal

Production Server:
  ✗ Stateful — menyimpan in-memory state, active connections
  ✗ Long-running — uptime minggu hingga bulan
  ✗ Tidak bisa di-interrupt sembarangan — user sedang dilayani
  ✗ Recovery kompleks — restart bisa memengaruhi user

Kombinasi ini membuat CI/CD runner adalah kandidat Spot Instance yang hampir sempurna — bahkan lebih cocok dari batch processing sekalipun, karena infrastruktur retry-nya (GitHub Actions, GitLab CI) sudah built-in.

Berapa Potensi Penghematannya

Angka ini bukan teori. Spot pricing bervariasi per region dan instance type, tapi diskon dibanding On-Demand konsisten besar.

Instance TypevCPURAMOn-Demand/jamSpot/jamHemat
c6i.2xlarge816 GB$0.34~$0.09~73%
c6i.4xlarge1632 GB$0.68~$0.18~74%
m6i.4xlarge1664 GB$0.768~$0.19~75%
c6i.8xlarge3264 GB$1.36~$0.32~76%

Satu job berat yang membutuhkan c6i.4xlarge selama 20 menit:

  • On-Demand: $0.68 × (20/60) = $0.23 per job
  • Spot: $0.18 × (20/60) = $0.06 per job

Di tim dengan 200 job berat per hari, penghematannya mencapai sekitar $34/hari atau ~$1.000/bulan — hanya dari satu jenis job. Tim yang lebih besar dengan pipeline lebih banyak bisa menghemat jauh lebih besar.


Cara Kerja Spot Market

Memahami mekanisme Spot Market membantu merancang strategi mitigasi yang tepat dan menghindari keputusan konfigurasi yang salah.

flowchart TD
    subgraph SpotMarket["AWS Spot Market"]
        POOL["Spot Instance Pool\n(kapasitas AWS yang tidak terpakai)"]
        PRICE["Spot Price\n(berfluktuasi per AZ per instance type)"]
    end

    subgraph Request["Request dari CI/CD"]
        REQ["Spot Instance Request\n+ optional Max Price"]
    end

    subgraph Outcomes["Kemungkinan Hasil"]
        OK["Instance berjalan\n✓ Spot price < max price\n✓ Kapasitas tersedia"]
        INTERRUPT["Instance diinterupsi\n2 menit sebelum terminate\n⚠ Kapasitas dibutuhkan AWS kembali"]
        NOFULFILL["Request tidak dipenuhi\n✗ Tidak ada kapasitas\ndi AZ yang dipilih"]
    end

    Request -->|Submit| SpotMarket
    SpotMarket --> OK
    SpotMarket --> INTERRUPT
    SpotMarket --> NOFULFILL

    OK -->|Mitigasi tidak diperlukan| DONE1["Job berjalan normal"]
    INTERRUPT -->|Mitigasi| RETRY["Job di-retry\ndi instance baru"]
    NOFULFILL -->|Mitigasi| FALLBACK["Fallback ke AZ lain\natau On-Demand"]

    style OK fill:#e8f5e9,stroke:#43a047
    style INTERRUPT fill:#fff3e0,stroke:#fb8c00
    style NOFULFILL fill:#ffebee,stroke:#e53935

Ada dua hal yang perlu dipahami tentang interupsi Spot:

Pertama, interupsi bukan hal biasa sehari-hari. AWS mempublish data bahwa rata-rata interupsi untuk sebagian besar instance type di region populer adalah di bawah 5% per bulan. Instance type yang populer (c5, c6i, m5) di availability zone dengan kapasitas besar bahkan lebih rendah lagi.

Kedua, Kita bisa memilih instance type yang lebih tidak berisiko interupsi. AWS menyediakan Spot Instance Advisor yang menampilkan frekuensi interupsi historis per instance type. Untuk CI/CD runner, pilih instance type dengan label “< 5% interruption rate”.


Arsitektur Ephemeral Runner

Pola yang paling bersih untuk CI/CD runner berbasis Spot adalah arsitektur fully ephemeral: tidak ada runner yang “standby”, setiap job menciptakan instance baru, dan instance menghancurkan dirinya sendiri setelah job selesai.

sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub
    participant WF as Workflow (ubuntu-latest)
    participant AWS as AWS EC2
    participant SPOT as Spot Instance (Runner)

    Dev->>GH: git push / PR
    GH->>WF: Trigger workflow
    WF->>AWS: ec2 run-instances\n(Spot, custom AMI)
    AWS-->>WF: Instance ID
    Note over AWS,SPOT: ~60-90 detik cold start\n(lebih cepat dengan custom AMI)
    SPOT->>SPOT: User data script berjalan:\n1. Install tools (jika tidak pakai custom AMI)\n2. Register sebagai GitHub runner\n3. Mulai mendengarkan job

    GH->>SPOT: Kirim job ke runner
    SPOT->>SPOT: Jalankan CI job\n(build, test, scan, dll)
    SPOT-->>GH: Upload artifacts, report status

    SPOT->>AWS: ec2 terminate-instances\n(self-terminate)
    Note over SPOT: Runner --ephemeral:\notomatis unregister dari GitHub
    AWS-->>SPOT: Instance terminated

Keindahan arsitektur ini ada di dua detail kecil yang sangat penting: flag --ephemeral pada konfigurasi runner memastikan runner otomatis unregister dari GitHub setelah satu job, dan self-termination di akhir user data script memastikan tidak ada instance yang lupa dimatikan dan terus ditagih.


Menyiapkan Infrastruktur

IAM Role untuk EC2 Runner

Instance perlu permission untuk menghancurkan dirinya sendiri. Ini adalah minimal policy yang dibutuhkan.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowSelfTerminate",
      "Effect": "Allow",
      "Action": "ec2:TerminateInstances",
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "ec2:ResourceTag/Purpose": "ci-runner"
        }
      }
    },
    {
      "Sid": "AllowDescribeForSelfIdentification",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeTags"
      ],
      "Resource": "*"
    }
  ]
}

Kondisi ec2:ResourceTag/Purpose: ci-runner memastikan instance hanya bisa mematikan instance lain yang juga berlabel runner — mencegah bug atau eksploitasi yang bisa mematikan instance lain secara tidak sengaja.

User Data Script (Bootstrap)

Script ini dijalankan saat instance pertama kali boot. Untuk pendekatan tanpa custom AMI, ini lebih panjang karena harus install semua dependencies.

#!/bin/bash
set -euo pipefail

# Redirect output ke log untuk debugging
exec > >(tee /var/log/runner-bootstrap.log) 2>&1
echo "Bootstrap dimulai: $(date)"

# --- Variabel dari environment atau SSM Parameter Store ---
GITHUB_ORG="${GITHUB_ORG}"
GITHUB_REPO="${GITHUB_REPO}"
RUNNER_TOKEN="${RUNNER_TOKEN}"   # Sebaiknya ambil dari AWS SSM, bukan inject langsung
RUNNER_VERSION="2.316.0"
RUNNER_LABELS="spot,large,ci"
AWS_REGION="ap-southeast-1"

# --- Install dependencies ---
yum update -y -q
yum install -y -q docker git jq curl unzip

# Install AWS CLI v2 jika belum ada
if ! command -v aws &> /dev/null; then
    curl -sL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
    unzip -q /tmp/awscliv2.zip -d /tmp
    /tmp/aws/install
fi

# Start Docker
systemctl enable docker
systemctl start docker
usermod -aG docker ec2-user

# --- Install dan konfigurasi GitHub Actions Runner ---
RUNNER_DIR="/opt/actions-runner"
mkdir -p ${RUNNER_DIR}
cd ${RUNNER_DIR}

curl -sL -o runner.tar.gz \
    "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"

tar xzf runner.tar.gz
rm runner.tar.gz

# Ambil runner token dari SSM Parameter Store (lebih aman dari inject langsung)
# RUNNER_TOKEN=$(aws ssm get-parameter \
#     --name "/ci/github-runner-token" \
#     --with-decryption \
#     --region ${AWS_REGION} \
#     --query "Parameter.Value" \
#     --output text)

# Register runner ke GitHub
# --ephemeral: runner hanya menerima satu job lalu unregister otomatis
./config.sh \
    --url "https://github.com/${GITHUB_ORG}/${GITHUB_REPO}" \
    --token "${RUNNER_TOKEN}" \
    --name "spot-runner-$(hostname)-$(date +%s)" \
    --labels "${RUNNER_LABELS}" \
    --unattended \
    --ephemeral

echo "Runner berhasil terdaftar: $(date)"

# --- Jalankan runner ---
# Jalankan sebagai service agar bisa di-manage dengan systemd
./svc.sh install
./svc.sh start

# Tunggu sampai runner selesai menerima dan menjalankan job
# Polling: cek apakah runner process masih berjalan
while systemctl is-active --quiet actions.runner.*; do
    sleep 10
done

echo "Job selesai, mulai self-terminate: $(date)"

# --- Self-terminate ---
INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
aws ec2 terminate-instances \
    --instance-ids "${INSTANCE_ID}" \
    --region "${AWS_REGION}"
Jangan inject RUNNER_TOKEN langsung ke user data script sebagai plaintext jika bisa dihindari. User data script bisa dibaca oleh siapa pun yang punya akses DescribeInstances. Gunakan AWS SSM Parameter Store (dengan enkripsi KMS) untuk menyimpan token dan ambil saat bootstrap dengan IAM role yang tepat.

Custom AMI untuk Startup Lebih Cepat

Salah satu kelemahan pendekatan vanilla adalah startup time yang lama jika semua tools diinstall saat boot. Untuk pipeline Java + Terraform + SonarQube, bisa membutuhkan 3–5 menit hanya untuk setup.

Solusinya adalah custom AMI yang sudah preinstall semua tools.

# Script untuk membangun custom AMI (jalankan di EC2 yang akan di-snapshot)
#!/bin/bash
set -euo pipefail

# Java 21 (untuk Gradle/Maven build)
yum install -y java-21-amazon-corretto-headless

# Gradle
GRADLE_VERSION="8.7"
curl -sL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip
unzip -q /tmp/gradle.zip -d /opt
ln -s /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/local/bin/gradle

# Terraform
TERRAFORM_VERSION="1.8.4"
curl -sL "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" \
    -o /tmp/terraform.zip
unzip -q /tmp/terraform.zip -d /usr/local/bin
chmod +x /usr/local/bin/terraform

# SonarQube Scanner
SONAR_VERSION="6.1.0.4477"
curl -sL "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_VERSION}-linux-x64.zip" \
    -o /tmp/sonar.zip
unzip -q /tmp/sonar.zip -d /opt
ln -s /opt/sonar-scanner-${SONAR_VERSION}-linux-x64/bin/sonar-scanner /usr/local/bin/sonar-scanner

# Docker (sudah terinstall, pastikan update ke versi terbaru)
yum update -y docker
systemctl enable docker

# GitHub Actions Runner (preinstall tapi jangan register dulu)
RUNNER_VERSION="2.316.0"
mkdir -p /opt/actions-runner
cd /opt/actions-runner
curl -sL -o runner.tar.gz \
    "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
tar xzf runner.tar.gz && rm runner.tar.gz

# Setelah script ini selesai:
# 1. Buat AMI dari instance ini via AWS Console atau CLI
# 2. Gunakan AMI ID ini di workflow GitHub Actions
# aws ec2 create-image --instance-id i-xxxx --name "ci-runner-v1.0" --no-reboot

Dengan custom AMI, user data script saat boot hanya perlu melakukan registrasi runner ke GitHub — startup time turun dari 3–5 menit menjadi 60–90 detik.


Implementasi GitHub Actions

Workflow Lengkap dengan Error Handling

name: Heavy CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:

env:
  AWS_REGION: ap-southeast-1
  INSTANCE_TYPE: c6i.4xlarge
  AMI_ID: ami-0abcdef1234567890  # Custom AMI

jobs:
  # Job 1: Launch Spot Instance
  # Job ini berjalan di runner GitHub standard (murah, cepat)
  launch-runner:
    runs-on: ubuntu-latest
    outputs:
      instance-id: ${{ steps.launch.outputs.instance-id }}
      runner-label: ${{ steps.launch.outputs.runner-label }}
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-launcher
          aws-region: ${{ env.AWS_REGION }}

      - name: Get GitHub Runner Token
        id: get-token
        run: |
          TOKEN=$(curl -sX POST \
            -H "Authorization: Bearer ${{ secrets.GH_PAT }}" \
            -H "Accept: application/vnd.github+json" \
            "https://api.github.com/repos/${{ github.repository }}/actions/runners/registration-token" \
            | jq -r .token)
          echo "::add-mask::${TOKEN}"
          echo "token=${TOKEN}" >> $GITHUB_OUTPUT          

      - name: Launch Spot Instance
        id: launch
        run: |
          RUNNER_LABEL="spot-$(echo ${{ github.run_id }}-${{ github.run_attempt }})"

          # Encode user data sebagai base64
          USER_DATA=$(cat <<EOF | base64 -w 0
          #!/bin/bash
          export GITHUB_ORG="${{ github.repository_owner }}"
          export GITHUB_REPO="${{ github.event.repository.name }}"
          export RUNNER_TOKEN="${{ steps.get-token.outputs.token }}"
          export RUNNER_LABELS="${RUNNER_LABEL},spot,large"
          export AWS_REGION="${{ env.AWS_REGION }}"
          bash /opt/bootstrap-runner.sh
          EOF
          )

          INSTANCE_ID=$(aws ec2 run-instances \
            --image-id ${{ env.AMI_ID }} \
            --instance-type ${{ env.INSTANCE_TYPE }} \
            --iam-instance-profile Name=ci-runner-instance-profile \
            --instance-market-options '{"MarketType":"spot","SpotOptions":{"SpotInstanceType":"one-time","InstanceInterruptionBehavior":"terminate"}}' \
            --user-data "${USER_DATA}" \
            --tag-specifications 'ResourceType=instance,Tags=[{Key=Purpose,Value=ci-runner},{Key=RunID,Value=${{ github.run_id }}}]' \
            --metadata-options '{"HttpTokens":"required","HttpEndpoint":"enabled"}' \
            --region ${{ env.AWS_REGION }} \
            --query 'Instances[0].InstanceId' \
            --output text)

          echo "instance-id=${INSTANCE_ID}" >> $GITHUB_OUTPUT
          echo "runner-label=${RUNNER_LABEL}" >> $GITHUB_OUTPUT
          echo "Instance ${INSTANCE_ID} diluncurkan, menunggu runner siap..."          

      - name: Wait for runner to be ready
        run: |
          # Tunggu sampai runner terdaftar di GitHub (max 3 menit)
          TIMEOUT=180
          ELAPSED=0
          RUNNER_LABEL="${{ steps.launch.outputs.runner-label }}"

          while [ $ELAPSED -lt $TIMEOUT ]; do
            RUNNER_COUNT=$(curl -s \
              -H "Authorization: Bearer ${{ secrets.GH_PAT }}" \
              -H "Accept: application/vnd.github+json" \
              "https://api.github.com/repos/${{ github.repository }}/actions/runners" \
              | jq "[.runners[] | select(.labels[].name == \"${RUNNER_LABEL}\")] | length")

            if [ "${RUNNER_COUNT}" -gt "0" ]; then
              echo "Runner siap setelah ${ELAPSED} detik"
              exit 0
            fi

            sleep 10
            ELAPSED=$((ELAPSED + 10))
          done

          echo "Timeout: runner tidak terdaftar dalam ${TIMEOUT} detik"
          exit 1          

  # Job 2: CI berat yang berjalan di Spot Instance
  build-and-test:
    needs: launch-runner
    runs-on: [self-hosted, "${{ needs.launch-runner.outputs.runner-label }}"]
    timeout-minutes: 45
    steps:
      - uses: actions/checkout@v4

      - name: Gradle Build
        run: ./gradlew build --no-daemon --parallel

      - name: Run Tests
        run: ./gradlew test --no-daemon

      - name: SonarQube Scan
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
        run: |
          sonar-scanner \
            -Dsonar.projectKey=${{ github.event.repository.name }} \
            -Dsonar.sources=src \
            -Dsonar.java.binaries=build/classes          

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: build/libs/

  # Job 3: Cleanup — pastikan instance dihapus meski job gagal
  cleanup:
    needs: [launch-runner, build-and-test]
    runs-on: ubuntu-latest
    if: always()  # Jalankan selalu, bahkan jika build gagal
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-launcher
          aws-region: ${{ env.AWS_REGION }}

      - name: Terminate instance if still running
        run: |
          INSTANCE_ID="${{ needs.launch-runner.outputs.instance-id }}"
          STATE=$(aws ec2 describe-instances \
            --instance-ids ${INSTANCE_ID} \
            --query 'Reservations[0].Instances[0].State.Name' \
            --output text \
            --region ${{ env.AWS_REGION }} 2>/dev/null || echo "terminated")

          if [ "${STATE}" != "terminated" ] && [ "${STATE}" != "shutting-down" ]; then
            echo "Instance masih berjalan, melakukan terminate..."
            aws ec2 terminate-instances \
              --instance-ids ${INSTANCE_ID} \
              --region ${{ env.AWS_REGION }}
          else
            echo "Instance sudah tidak berjalan: ${STATE}"
          fi          

Job cleanup dengan kondisi if: always() adalah safety net yang penting — ia memastikan instance tidak lupa dimatikan bahkan ketika job gagal di tengah jalan sebelum self-terminate sempat dieksekusi.


Menangani Spot Interruption

Spot interruption adalah kondisi yang harus diantisipasi, bukan dihindari. Strategi yang tepat adalah membuatnya tidak menyakitkan.

flowchart TD
    JOB[Job sedang berjalan\ndi Spot Instance] --> INT{Spot Interruption\nNotice diterima}

    INT -- Tidak terjadi --> DONE[Job selesai normal\nInstance self-terminate]

    INT -- Ya, 2 menit sebelum terminate --> SIGNAL["Instance menerima\nITB-Termination-Notice\ndi metadata endpoint"]
    SIGNAL --> HANDLER["Interruption handler script:\n1. Kirim SIGTERM ke runner\n2. Runner checkpoint state\n3. Upload partial artifacts"]
    HANDLER --> TERMINATE[Instance terminate]
    TERMINATE --> RETRY["GitHub Actions:\njob ditandai failed"]
    RETRY --> RERUN["Rerun job secara manual\natau otomatis via retry config"]

    style INT fill:#fff3e0,stroke:#fb8c00
    style DONE fill:#e8f5e9,stroke:#43a047
    style RERUN fill:#e3f2fd,stroke:#1e88e5
# Script untuk handle Spot Interruption Notice
# Tambahkan di user data atau jalankan sebagai background service

#!/bin/bash
# spot-interruption-handler.sh

while true; do
    # Cek Spot Interruption Notice dari metadata endpoint
    HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
        -H "X-aws-ec2-metadata-token: $(curl -s -X PUT \
            -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' \
            http://169.254.169.254/latest/api/token)" \
        http://169.254.169.254/latest/meta-data/spot/termination-time)

    if [ "${HTTP_CODE}" == "200" ]; then
        echo "SPOT INTERRUPTION NOTICE diterima! Melakukan graceful shutdown..."

        # Kirim sinyal ke GitHub Actions runner agar bisa cleanup
        pkill -SIGTERM -f "Runner.Listener"

        # Tunggu sebentar lalu terminate
        sleep 30
        INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
        aws ec2 terminate-instances --instance-ids "${INSTANCE_ID}" --region ap-southeast-1
        exit 0
    fi

    sleep 5
done

Untuk pipeline yang berjalan lebih dari 15 menit (Terraform plan besar, analisis SonarQube, Maven build project besar), sangat direkomendasikan memecah pipeline menjadi tahap-tahap yang lebih kecil dengan artifact yang di-upload di antara tahap. Dengan begitu jika interruption terjadi di satu tahap, retry hanya mengulang tahap tersebut — bukan seluruh pipeline.


Optimasi Lanjutan

Strategi Multi-AZ untuk Ketersediaan Lebih Tinggi

Spot capacity bervariasi per Availability Zone. Strategi terbaik adalah meminta instance dari beberapa AZ sekaligus dan menggunakan instance type yang fleksibel.

# Gunakan EC2 Fleet untuk permintaan multi-AZ dan multi-instance-type
- name: Launch Spot via EC2 Fleet
  run: |
    aws ec2 create-fleet \
      --launch-template-configs '[
        {"LaunchTemplateSpecification":{"LaunchTemplateName":"ci-runner-template","Version":"$Latest"}},
      ]' \
      --target-capacity-specification '{
        "TotalTargetCapacity": 1,
        "SpotTargetCapacity": 1,
        "DefaultTargetCapacityType": "spot"
      }' \
      --spot-options '{
        "AllocationStrategy": "price-capacity-optimized",
        "InstanceInterruptionBehavior": "terminate"
      }' \
      --overrides '[
        {"InstanceType":"c6i.4xlarge","SubnetId":"subnet-aaa","WeightedCapacity":1},
        {"InstanceType":"c5.4xlarge","SubnetId":"subnet-aaa","WeightedCapacity":1},
        {"InstanceType":"c6i.4xlarge","SubnetId":"subnet-bbb","WeightedCapacity":1},
        {"InstanceType":"c5a.4xlarge","SubnetId":"subnet-bbb","WeightedCapacity":1}
      ]'    

AllocationStrategy: price-capacity-optimized adalah strategi terbaik untuk CI/CD — ia memilih pool dengan kapasitas terbesar yang juga memiliki harga kompetitif, sehingga probabilitas interruption lebih rendah.

Menggunakan GitLab Runner Manager (Alternatif)

Jika menggunakan GitLab CI, tersedia gitlab-runner dengan executor docker+machine yang otomatis mengelola Spot Instance lifecycle. Setup ini lebih sederhana karena manajemen instance ditangani oleh runner manager.

# /etc/gitlab-runner/config.toml
[[runners]]
  name = "spot-runner"
  url = "https://gitlab.com"
  token = "RUNNER_TOKEN"
  executor = "docker+machine"

  [runners.docker]
    image = "docker:latest"

  [runners.machine]
    IdleCount = 0          # Tidak ada instance standby
    IdleTime = 300         # Terminate setelah 5 menit idle
    MaxBuilds = 1          # Terminate setelah 1 job (ephemeral)
    MachineDriver = "amazonec2"
    MachineName = "gitlab-runner-%s"

    MachineOptions = [
      "amazonec2-instance-type=c6i.4xlarge",
      "amazonec2-region=ap-southeast-1",
      "amazonec2-request-spot-instance=true",
      "amazonec2-spot-price=0.30",
      "amazonec2-ami=ami-xxxx",
      "amazonec2-iam-instance-profile=ci-runner-instance-profile",
      "amazonec2-tags=Purpose,ci-runner",
    ]

Kapan Tepat dan Tidak Tepat

SANGAT DIREKOMENDASIKAN jika:
  ✓ CI job berjalan > 10 menit secara konsisten
  ✓ Resource yang dibutuhkan besar (>= 8 vCPU, >= 16 GB RAM)
  ✓ Ada banyak parallel job yang sering antri
  ✓ Tim DevOps cukup matang untuk mengelola EC2
  ✓ Budget infra CI mulai tidak proporsional
  ✓ Semua job bersifat idempotent dan bisa di-retry

PERLU PERTIMBANGAN LEBIH LANJUT jika:
  ⚠ Ada job yang tidak idempotent (database migration, deployment tanpa rollback)
  ⚠ Pipeline butuh startup time sangat cepat (< 1 menit)
  ⚠ Tim belum familiar dengan AWS dan EC2

TIDAK DIREKOMENDASIKAN jika:
  ✗ CI job kecil dan cepat (< 5 menit) — overhead setup tidak worth it
  ✗ Job yang tidak bisa di-retry sama sekali
  ✗ Frekuensi pipeline sangat rendah (< 10 job/hari)
  ✗ Tidak ada monitoring dan alerting untuk failed job

Ringkasan

  • CI/CD adalah Spot workload sempurna — stateless, ephemeral, retriable, dan durasi pendek adalah semua karakteristik yang membuat Spot Instance ideal untuk runner.
  • Penghematan 70–90% bukan teori — dengan Spot pricing yang konsisten, pipeline berat yang sebelumnya mahal bisa dijalankan dengan biaya yang sangat berbeda di skala ratusan job per hari.
  • Arsitektur ephemeral adalah kunci — runner dibuat saat job akan mulai, mati setelah job selesai. Tidak ada instance standby yang idle dan menambah biaya.
  • Flag --ephemeral wajib digunakan — tanpanya, runner tetap terdaftar di GitHub setelah job selesai dan bisa menerima job lain saat instance sudah tidak ada.
  • Jangan simpan Runner Token di user data secara plaintext — gunakan AWS SSM Parameter Store dengan enkripsi KMS dan ambil via IAM role saat bootstrap.
  • Custom AMI memangkas startup time secara signifikan — preinstall Java, Terraform, Docker, SonarQube, dan GitHub Runner binary sehingga saat boot hanya perlu registrasi, bukan instalasi.
  • Job cleanup if: always() adalah safety net wajib — jika job gagal sebelum self-terminate berjalan, job cleanup yang berjalan setelahnya memastikan instance tetap dihapus.
  • price-capacity-optimized adalah allocation strategy terbaik untuk CI/CD — ia memilih pool dengan kapasitas terbesar, yang berarti probabilitas interruption lebih rendah dibanding strategi lowest-price.

Portofolio