CI hemat memori dimulai dari satu hal: ukur penggunaan RAM dengan benar. Banyak build, test, atau lint gagal Out Of Memory bukan karena sistem benar-benar kehabisan memori kosong, melainkan karena proses melewati limit memori container/runner, paralelisme terlalu tinggi, atau pembacaan metrik memori yang keliru.

Konteks ini mirip dengan pelajaran klasik dari topik "FreeBSD ate my ram": angka memori OS bisa menipu jika kita hanya melihat free memory. Di runner CI, cache file, buffer, dan page cache sering membuat RAM terlihat "penuh", padahal sebagian masih dapat direklamasi kernel. Karena itu, audit RAM harus membedakan antara penggunaan memori yang normal dan masalah nyata seperti kebocoran, proses zombie, atau tahap build tertentu yang melonjak tajam.

Memahami angka memori: free, available, buffer, dan file cache

Sebelum mengubah pipeline, pahami dulu apa yang sedang dibaca. Di Linux, memori yang tampak terpakai tidak selalu berarti tidak tersedia.

  • free: RAM yang benar-benar belum dipakai sama sekali. Angka ini sering kecil dan bukan indikator utama kesehatan sistem.
  • available: estimasi memori yang masih bisa dipakai aplikasi baru tanpa swap besar atau tekanan memori berat. Ini biasanya metrik yang lebih berguna daripada free.
  • buffers: memori untuk metadata I/O dan buffering level kernel.
  • cached / file cache: cache halaman file yang dipakai kernel untuk mempercepat akses file. Jika proses lain butuh RAM, bagian ini biasanya dapat dikurangi.
  • anon memory: memori privat proses, misalnya heap aplikasi, struktur runtime, objek test runner, atau bundler. Ini biasanya lebih dekat ke sumber OOM.

Kesalahan umum di CI adalah melihat free -m, menemukan angka free kecil, lalu menganggap runner bocor. Padahal bisa jadi yang besar adalah file cache hasil ekstraksi dependency, kompilasi, atau aktivitas filesystem.

Masalah sebenarnya muncul ketika:

  • proses mendekati atau melewati limit cgroup;
  • available memory jatuh terus dan tidak pulih;
  • penggunaan anon memory naik tanpa turun selama fase yang sama;
  • kernel atau runtime membunuh proses dengan sinyal OOM.

Audit RAM runner CI: ukur puncak memori, bukan snapshot sesaat

Snapshot satu kali hampir selalu kurang berguna. Yang dibutuhkan adalah peak memory per job dan, idealnya, per tahap: install, build, test, lint, packaging, dan publish.

1. Catat limit memori yang benar dari cgroup

Di banyak runner modern, job berjalan di dalam container atau environment dengan limit cgroup. Jika Anda hanya membaca memori host, hasilnya bisa menyesatkan. Baca limit dari cgroup terlebih dahulu.

#!/usr/bin/env sh
set -eu

read_cgroup_limit() {
  if [ -f /sys/fs/cgroup/memory.max ]; then
    cat /sys/fs/cgroup/memory.max
  elif [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
    cat /sys/fs/cgroup/memory/memory.limit_in_bytes
  else
    echo unknown
  fi
}

read_cgroup_current() {
  if [ -f /sys/fs/cgroup/memory.current ]; then
    cat /sys/fs/cgroup/memory.current
  elif [ -f /sys/fs/cgroup/memory/memory.usage_in_bytes ]; then
    cat /sys/fs/cgroup/memory/memory.usage_in_bytes
  else
    echo unknown
  fi
}

limit=$(read_cgroup_limit)
current=$(read_cgroup_current)
echo "cgroup_memory_limit=$limit"
echo "cgroup_memory_current=$current"

Catatan penting:

  • Pada cgroup v2, file yang umum adalah memory.max dan memory.current.
  • Pada cgroup v1, file yang umum adalah memory.limit_in_bytes dan memory.usage_in_bytes.
  • Beberapa environment dapat mengembalikan nilai sangat besar atau max, yang berarti tidak ada limit eksplisit di level tersebut.

2. Ukur puncak memori selama job berjalan

Gunakan monitor ringan yang berjalan di background dan menyimpan angka puncak. Ini lebih berguna daripada memanggil free sekali di akhir job.

#!/usr/bin/env sh
set -eu

OUT_FILE="${1:-memory-peak.txt}"
INTERVAL="${INTERVAL:-2}"
THRESHOLD_PERCENT="${THRESHOLD_PERCENT:-90}"

get_limit() {
  if [ -f /sys/fs/cgroup/memory.max ]; then
    cat /sys/fs/cgroup/memory.max
  elif [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
    cat /sys/fs/cgroup/memory/memory.limit_in_bytes
  else
    echo 0
  fi
}

get_current() {
  if [ -f /sys/fs/cgroup/memory.current ]; then
    cat /sys/fs/cgroup/memory.current
  elif [ -f /sys/fs/cgroup/memory/memory.usage_in_bytes ]; then
    cat /sys/fs/cgroup/memory/memory.usage_in_bytes
  else
    echo 0
  fi
}

limit=$(get_limit)
peak=0

while true; do
  current=$(get_current)
  [ "$current" -gt "$peak" ] && peak="$current"

  if [ "$limit" != "0" ] && [ "$limit" != "max" ]; then
    pct=$(( current * 100 / limit ))
    if [ "$pct" -ge "$THRESHOLD_PERCENT" ]; then
      echo "WARN memory usage high: ${pct}% (${current}/${limit})" >&2
    fi
  fi

  echo "$peak" > "$OUT_FILE"
  sleep "$INTERVAL"
done

Jalankan script ini di background sebelum tahap berat dimulai, lalu hentikan setelah job selesai.

3. Segmentasikan metrik per tahap

Masalah memori biasanya terkonsentrasi di satu tahap. Pisahkan pencatatan untuk:

  • dependency install
  • build
  • unit/integration test
  • lint/typecheck
  • package/image build

Jika semua tahap dicampur dalam satu job panjang, Anda akan tahu job tersebut boros, tetapi tidak tahu bagian mana yang harus dioptimalkan.

Membedakan kebocoran memori vs cache normal

Tidak semua grafik naik adalah kebocoran. Pola yang perlu dibedakan:

Pola cache normal

  • Naik saat ekstraksi dependency, pembacaan banyak file, atau kompilasi.
  • Didominasi cache filesystem, bukan pertumbuhan heap proses tertentu.
  • Dapat turun ketika proses berhenti atau saat sistem butuh memori untuk proses lain.

Pola yang mengarah ke kebocoran

  • Memori proses tertentu naik terus sepanjang job tanpa kembali turun.
  • Puncak terus bertambah di tahap yang sama dari waktu ke waktu, meski input relatif stabil.
  • OOM terjadi bahkan setelah paralelisme diturunkan dan cache dibersihkan.

Cara praktis memeriksa proses yang paling boros

ps -eo pid,ppid,rss,%mem,comm --sort=-rss | head -20

Untuk runner berbasis container, Anda juga bisa mencatat proses teratas beberapa kali selama build untuk melihat siapa yang tumbuh. Jika yang naik adalah compiler, bundler, JVM, Node process, browser headless, atau database test container, optimasinya akan berbeda.

Jika penggunaan cgroup tinggi tetapi tidak terlihat pada satu proses besar, kemungkinan ada akumulasi beberapa worker paralel, child process, atau cache filesystem dalam namespace/container tersebut.

Identifikasi tahap paling boros: build, test, atau lint?

Setelah ada angka puncak per tahap, fokus ke tahap yang benar-benar dominan. Tiga tersangka yang paling sering:

Build

  • Bundler frontend dengan paralelisme agresif.
  • Kompilasi native atau transpile skala besar.
  • Build container image yang membaca banyak layer dan file.

Test

  • Test runner membuka terlalu banyak worker.
  • Integration test menjalankan database, cache, browser headless, atau service tambahan.
  • Data fixture terlalu besar dan di-load sekaligus.

Lint dan typecheck

  • Monorepo yang melint seluruh tree sekaligus.
  • Type checker yang memuat graph proyek besar dalam satu proses.
  • Worker lint paralel yang masing-masing memuat parser/AST besar.

Jangan mengasumsikan tahap build selalu paling berat. Pada banyak proyek, justru test paralel atau typecheck monorepo yang lebih sering memicu OOM.

Implementasi audit di GitHub Actions

Berikut contoh sederhana untuk menjalankan monitor RAM di background dan menyimpan artefaknya.

name: ci
on: [push, pull_request]

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start memory monitor
        run: |
          chmod +x ci/memwatch.sh
          ./ci/memwatch.sh memory-peak.txt &
          echo $! > memwatch.pid

      - name: Install dependencies
        run: ./ci/install.sh

      - name: Build
        run: ./ci/build.sh

      - name: Test
        run: ./ci/test.sh

      - name: Stop memory monitor
        if: always()
        run: |
          kill "$(cat memwatch.pid)" || true
          echo "Peak bytes: $(cat memory-peak.txt || echo unknown)"

      - name: Upload memory artifact
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: ci-memory
          path: |
            memory-peak.txt
            memwatch.pid

Untuk audit yang lebih berguna, pecah monitor per tahap. Misalnya, jalankan monitor khusus sebelum build, hentikan setelah build, lalu simpan output build-memory.txt. Ulangi untuk test dan lint.

Implementasi audit di GitLab CI

stages:
  - build
  - test

build:
  stage: build
  script:
    - chmod +x ci/memwatch.sh
    - ./ci/memwatch.sh build-memory.txt &
    - export MEMWATCH_PID=$!
    - ./ci/install.sh
    - ./ci/build.sh
    - kill "$MEMWATCH_PID" || true
    - echo "Build peak: $(cat build-memory.txt || echo unknown)"
  artifacts:
    when: always
    paths:
      - build-memory.txt

test:
  stage: test
  script:
    - chmod +x ci/memwatch.sh
    - ./ci/memwatch.sh test-memory.txt &
    - export MEMWATCH_PID=$!
    - ./ci/test.sh
    - kill "$MEMWATCH_PID" || true
    - echo "Test peak: $(cat test-memory.txt || echo unknown)"
  artifacts:
    when: always
    paths:
      - test-memory.txt

Jika runner Anda berbasis Docker executor atau Kubernetes, pembacaan cgroup menjadi sangat penting karena limit job bisa jauh lebih kecil daripada memori node fisik.

Strategi menekan RAM pada runner CI

1. Split job berdasarkan karakter beban

Menggabungkan install, build, lint, test, dan packaging dalam satu job besar meningkatkan puncak memori karena cache proses dan child process bisa tumpang tindih. Pecah job jika:

  • build dan test sama-sama berat;
  • lint/typecheck bisa jalan terpisah;
  • artifact build dapat dipakai ulang oleh job berikutnya.

Kelebihan: puncak memori per job turun, diagnosis lebih mudah.
Kekurangan: durasi pipeline total bisa naik jika artifact besar atau dependensi harus dipulihkan ulang.

2. Batasi paralelisme secara eksplisit

OOM di CI sangat sering disebabkan worker terlalu banyak. Default tool biasanya mengikuti jumlah CPU, padahal limit RAM runner belum tentu sebanding.

Contoh prinsip umum:

  • Kurangi jumlah worker test.
  • Batasi concurrency bundler/kompiler.
  • Hindari menjalankan lint + typecheck + test berat bersamaan dalam job yang sama.

Jika tool Anda mendukung opsi --max-workers, --jobs, atau variabel lingkungan serupa, gunakan nilai konservatif dulu lalu naikkan bertahap sambil memantau peak memory.

3. Caching dependency yang aman, bukan cache proses

Cache yang aman untuk CI biasanya adalah:

  • cache download package manager;
  • cache dependency yang immutable atau dikunci oleh lockfile;
  • artifact hasil build yang memang ingin dipakai lintas stage.

Yang perlu dihindari:

  • menyimpan direktori yang bercampur dengan file sementara besar;
  • cache yang membuat workspace membengkak di setiap restore;
  • cache tool state yang korup atau sulit divalidasi.

Cache dependency membantu waktu eksekusi, tetapi tidak selalu menurunkan peak RAM. Bahkan restore cache besar bisa menambah tekanan filesystem dan page cache. Karena itu, ukur sebelum dan sesudah mengaktifkan cache.

4. Kurangi scope kerja per job

  • Lint hanya file yang berubah, jika kebijakan tim mengizinkan.
  • Typecheck per paket/workspace pada monorepo.
  • Shard test suite besar menjadi beberapa job.
  • Pisahkan integration test berat dari unit test cepat.

Ini sering lebih efektif daripada menambah RAM runner, terutama untuk tim kecil dengan anggaran terbatas.

5. Bersihkan proses tambahan yang tidak perlu

Service sampingan seperti database, browser headless, atau emulator bisa menyumbang banyak RAM. Jalankan hanya ketika dibutuhkan, dan matikan setelah tahap selesai. Jangan biarkan service test aktif sepanjang pipeline jika hanya satu subset test yang memerlukannya.

Guardrail fail-fast saat RAM mendekati ambang

Alih-alih menunggu kernel membunuh proses secara acak, tambahkan guardrail agar job gagal lebih cepat dengan pesan yang jelas. Ini membuat diagnosis lebih mudah dan mencegah log korup atau artifact setengah jadi.

#!/usr/bin/env sh
set -eu

THRESHOLD_PERCENT="${THRESHOLD_PERCENT:-95}"

get_limit() {
  if [ -f /sys/fs/cgroup/memory.max ]; then
    cat /sys/fs/cgroup/memory.max
  elif [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
    cat /sys/fs/cgroup/memory/memory.limit_in_bytes
  else
    echo 0
  fi
}

get_current() {
  if [ -f /sys/fs/cgroup/memory.current ]; then
    cat /sys/fs/cgroup/memory.current
  elif [ -f /sys/fs/cgroup/memory/memory.usage_in_bytes ]; then
    cat /sys/fs/cgroup/memory/memory.usage_in_bytes
  else
    echo 0
  fi
}

limit=$(get_limit)
current=$(get_current)

if [ "$limit" != "0" ] && [ "$limit" != "max" ]; then
  pct=$(( current * 100 / limit ))
  if [ "$pct" -ge "$THRESHOLD_PERCENT" ]; then
    echo "ERROR: memory usage ${pct}% exceeds threshold" >&2
    exit 1
  fi
fi

Script ini bisa dipanggil:

  • sebelum memulai tahap berat;
  • di tengah loop test shard;
  • setelah restore dependency atau sebelum build image.

Trade-off: fail-fast dapat menghentikan job yang sebenarnya masih mungkin selesai. Karena itu, tetapkan ambang berdasarkan observasi, bukan tebakan. Mulai dari peringatan, lalu aktifkan mode gagal jika pola OOM sudah jelas.

Debugging saat job tetap OOM

Periksa sinyal OOM dan exit code

Jika proses berhenti tiba-tiba tanpa stack trace yang jelas, curigai OOM killer atau runtime yang mati karena limit memori. Di beberapa environment Anda bisa melihat pesan killed, exit code non-zero tertentu, atau catatan dari orchestration layer.

Bandingkan RSS proses dengan usage cgroup

Jika RSS proses utama tampak tidak besar tetapi memory.current tinggi, cek:

  • child process/worker;
  • service sidecar;
  • page cache dan filesystem activity;
  • shared memory atau temporary files di memory-backed filesystem.

Matikan satu optimasi pada satu waktu

Jangan ubah lima hal sekaligus. Misalnya:

  1. turunkan worker test;
  2. ukur lagi peak memory;
  3. pecah lint dari build;
  4. ukur lagi;
  5. baru evaluasi cache dependency.

Dengan begitu Anda tahu perubahan mana yang benar-benar berpengaruh.

Waspadai false positive dari pembacaan host

Pada runner shared atau nested container, alat yang membaca memori host dapat membuat Anda merasa masih aman, padahal container job sudah hampir menyentuh limit cgroup. Untuk keputusan fail-fast, prioritaskan metrik cgroup job.

Checklist implementasi untuk tim kecil

Jika Anda ingin menerapkan CI hemat memori tanpa proyek observabilitas besar, mulai dari checklist ini:

  1. Baca limit cgroup di setiap job dan tampilkan di log.
  2. Pasang monitor peak memory ringan yang menulis artefak per job atau per tahap.
  3. Pisahkan tahap install, build, test, dan lint agar titik boros terlihat jelas.
  4. Turunkan paralelisme pada test runner, bundler, atau compiler yang paling berat.
  5. Shard test berat atau pisahkan integration test dari unit test.
  6. Gunakan cache dependency yang aman, berbasis lockfile, dan ukur dampaknya pada RAM serta waktu restore.
  7. Tambahkan guardrail fail-fast dengan ambang konservatif agar error lebih mudah didiagnosis.
  8. Catat proses terboros saat job mendekati ambang untuk memudahkan optimasi berikutnya.
  9. Evaluasi tren: apakah puncak RAM stabil, naik perlahan, atau melonjak setelah perubahan tertentu.
  10. Naikkan spesifikasi runner hanya setelah bottleneck terbukti, bukan sebagai tebakan pertama.

Intinya, OOM di CI jarang selesai hanya dengan menambah RAM. Dengan membaca metrik memori secara benar, memeriksa limit cgroup, dan mengukur puncak memori per tahap, Anda bisa membedakan cache normal dari masalah nyata. Dari sana, optimasi seperti split job, pembatasan paralelisme, dan guardrail fail-fast biasanya memberi hasil yang paling praktis dengan biaya rendah.