Skip to content
FreeToolNet
Go back

Android emulator with KVM on GitHub Actions: workflow and a caching trap

Instrumented Android tests need a device or an emulator. An emulator without hardware acceleration is too slow to be useful in CI, and acceleration on Linux needs /dev/kvm.

GitHub-hosted Linux runners expose /dev/kvm. With one extra step to make it usable, a small app’s instrumented test ran green in about three minutes from a cold start. This post has the workflow I used, the numbers from four runs, and the “optimisation” that turned a three-minute job into one that hung for more than twenty.

The test project

I kept the app as small as possible so that the CI setup was the only thing being tested:

  • A Kotlin/Gradle app with one module.
  • One androidTest test using AndroidJUnit4 that asserts the app’s package name.
  • Android Gradle Plugin 8.5.2, Kotlin 1.9.24, compileSdk 34, minSdk 24.
  • No Gradle wrapper committed. CI installs Gradle 8.7 itself, so the repo is text files only.

The workflow

This is .github/workflows/android-ci.yml:

name: Android CI

on:
  push:
  workflow_dispatch:

jobs:
  instrumented-test:
    name: Instrumented test on emulator
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v4
        with:
          gradle-version: "8.7"

      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: Run instrumented tests on emulator
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 29
          arch: x86_64
          target: default
          disable-animations: true
          emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim
          script: gradle connectedCheck

What each step is for

Triggers. push runs it on every commit. workflow_dispatch adds a “Run workflow” button, which I used to check that a re-run with no code change gives the same result.

Set up Gradle. gradle/actions/setup-gradle with gradle-version: '8.7' puts a gradle binary on the path. That is why the script calls gradle connectedCheck and not ./gradlew. If your repo has a wrapper, use the wrapper.

Enable KVM. This is the step people miss. The device node exists on the runner, but the runner user cannot open it. The udev rule sets the mode on /dev/kvm to 0666, then udev is reloaded and re-triggered for that device so the rule applies immediately. Without this step the emulator cannot use hardware acceleration.

The emulator step. android-emulator-runner downloads the system image, creates the AVD, boots it, waits for boot, runs the script, and shuts the emulator down. My options:

  • api-level: 29, arch: x86_64, target: default: a plain x86_64 image without Google APIs. It matches the runner’s CPU architecture, which is what lets KVM help.
  • -no-window: there is no display on a runner.
  • -gpu swiftshader_indirect: software rendering for graphics, since there is no GPU.
  • -no-snapshot: start cold and do not save state on exit.
  • -noaudio -no-boot-anim: skip things a test does not need.
  • disable-animations: true: turns off system animations, which removes a common source of flaky UI tests.

gradle connectedCheck builds the app and test APKs, installs them on the running emulator, and runs every instrumented test.

Results

The first run passed in about 204 seconds for the whole job. That figure is the evidence that acceleration was working. An emulator falling back to software emulation would not boot and finish a test run in that time.

The two clean runs of this workflow took between 2 minutes 36 seconds and 3 minutes 27 seconds. The faster one was a manual workflow_dispatch re-run with no changes, so the result is repeatable and not a lucky first try.

Most of that time is not the emulator. It is setting up Java and Gradle, downloading SDK pieces, and building. That matters for the next section.

The trap: AVD snapshot caching

The usual advice for speeding this up is to cache the AVD. You boot the emulator once, let it save a snapshot, store ~/.android/avd with actions/cache, and have later runs restore from the snapshot instead of cold-booting. I added it:

- name: AVD cache
  uses: actions/cache@v4
  id: avd-cache
  with:
    path: |
      ~/.android/avd/*
      ~/.android/adb*
    key: avd-29

- name: Create AVD and generate snapshot for caching
  if: steps.avd-cache.outputs.cache-hit != 'true'
  uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: 29
    arch: x86_64
    target: default
    force-avd-creation: false
    emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
    disable-animations: false
    script: echo "Generated AVD snapshot for caching."

- name: Run instrumented tests on emulator
  uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: 29
    arch: x86_64
    target: default
    force-avd-creation: false
    emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
    disable-animations: true
    script: gradle connectedCheck

On the run where the cache was restored (run #4), the good parts worked:

  • The cache restored.
  • The emulator booted from the snapshot.
  • The instrumented test passed in about two minutes, with BUILD SUCCESSFUL.

Then the job did not end. It sat in the action’s Terminate Emulator step for more than twenty minutes, with this in the log:

ERROR | stop: Not implemented

The emulator that had been restored from a snapshot would not shut down cleanly on the runner, and the teardown step has no timeout of its own. So a job whose tests had already passed kept the runner busy until I stopped it.

Why I removed the cache instead of fixing it

I went back to the simple workflow. The reasoning:

  • The saving was small. On API 29, a cold boot with KVM is a small slice of the run. The AVD cache does nothing for the Gradle setup, SDK download and build, which are the bulk of the time.
  • The cost was large. A hang in teardown is worse than a slow boot. It burns minutes, blocks the queue, and makes a passing run look broken.
  • The simple version was already reliable. Two clean green runs, both under three and a half minutes.

For a small app on API 29, AVD caching was a bad trade. It may pay off for newer and heavier system images where cold boot is much slower. I have not measured that, so I am not claiming it.

One thing I would add to any emulator job, and have not tested on this one, is a timeout-minutes value on the job. It would not fix the hang, but it would stop a stuck teardown from running on.

Public or private repo

Emulator jobs use more minutes than a lint job, so the billing rule matters. Standard GitHub-hosted runners are free for public repositories. Private repositories on the free plan get 2,000 minutes a month. I kept this sample public for that reason. At around three minutes a run, a private repo on the free plan would still fit several hundred runs a month.

Running the same thing locally

If you want the same check before pushing, any Linux environment with /dev/kvm works. WSL2 on my Windows laptop exposes it too, so the emulator is accelerated there as well. The CI job is still worth having, because it starts from a clean machine every time.

Summary

  • GitHub-hosted Linux runners expose /dev/kvm. Add the udev rule step, or the emulator cannot use it.
  • Use an x86_64 image and software GPU rendering.
  • A cold-boot run of a small app took 2m36s to 3m27s in my runs.
  • AVD snapshot caching restored and booted fine, then hung more than twenty minutes in Terminate Emulator with ERROR | stop: Not implemented. I removed it.
  • Measure where the time goes before caching anything. Here it was the build, not the boot.

How this post was made

Written with AI assistance and reviewed by the author before publishing. See the editorial policy.

Commands and output were captured on 2026-11-03 on GitHub-hosted ubuntu-latest runner, public repository, minimal Kotlin/Gradle Android app with one instrumented test (reactivecircus/android-emulator-runner@v2, actions/setup-java@v4 (Temurin 17), gradle/actions/setup-gradle@v4 (Gradle 8.7), Android Gradle Plugin 8.5.2, Kotlin 1.9.24, Emulator API level 29, x86_64).



Previous Post
Self-host a Search Console MCP server behind an nginx bearer token
Next Post
Sandboxing an AI agent web app in Docker behind nginx basic auth