all posts

Mobile CI in MicroVMs: What Runs and What Cannot

Ajay Kumar··9 min read

The question arrives roughly once a month, usually phrased as a single sentence: can I run my mobile CI on microVMs? The honest answer is that the question does not have one answer, because mobile CI is not one workload. It is a pipeline of five or six stages whose hardware requirements differ by more than an order of magnitude, and the useful thing to do is to stop treating it as a unit and go through it stage by stage. About ninety per cent of it is a JVM build that a microVM suits extremely well. The remaining ten per cent wants a hypervisor or a Mac, and no amount of clever configuration is going to conjure either one inside a Firecracker guest.

I am Ajay; I built PandaStack, a Firecracker microVM platform, and I would rather tell you where the wall is than sell you into it and watch you find out in week three. What follows is the split, the parts that work well enough to be worth moving, the parts that structurally cannot, and the pipeline shape that falls out once you accept the boundary rather than fighting it.

Split the pipeline before you shop for infrastructure

Write your pipeline out as stages and label each one with what it actually touches. A typical Android pipeline looks something like this, and the labels are the entire point of the exercise.

  1. Checkout and dependency resolution — network and disk. Gradle resolves a few hundred artefacts from Maven Central and your internal repository; npm or pnpm does the same for the JavaScript side of a React Native or Flutter app.
  2. Static analysis — pure CPU on the JVM. Android Lint, detekt, ktlint, Spotless, and whatever custom Gradle verification tasks you have accumulated.
  3. Compilation — pure CPU, memory-hungry. Kotlin and Java compilation, annotation processing or KSP, resource merging, dexing, and on the cross-platform side the Metro or Dart bundler.
  4. Unit tests — pure CPU on the JVM. testDebugUnitTest, plus Robolectric if you use it, which is the important case because Robolectric simulates the Android framework in a JVM rather than on a device.
  5. Packaging and signing — CPU, plus a secret. Assembling the APK or AAB and signing it with a key that has to get into the build environment somehow.
  6. Instrumented tests — a device or an emulator. connectedAndroidTest, Espresso, UI Automator, Appium, Maestro, anything that talks to adb.
  7. Release upload — network and a credential. Play Console, TestFlight, an internal distribution service.

Stages one through five and stage seven are ordinary Linux CPU work. They run in a microVM exactly as well as they run anywhere else, which is to say they are bounded by cores, memory and how fast you can get the dependency cache warm. Stage six is the one that breaks, and it breaks for a reason that is architectural rather than incidental.

The reason this framing matters commercially is that stage six is a small fraction of your pipeline's wall-clock time and a large fraction of its cost per minute. Device pools and Mac hardware are the expensive, scarce, poorly-parallelisable part. If you can move the other ninety per cent onto cheap ephemeral compute and shrink the device stage to only what genuinely needs a device, you have done the thing that actually saves money.

What runs fine in a microVM, and runs well

Everything JVM-shaped. A Gradle build is a Java process doing compilation and file I/O, and a Firecracker guest is a Linux machine with a real kernel, real page cache and real CPU. There is no emulation anywhere in that path — the guest's instructions execute on the host CPU at native speed, which is the whole point of hardware virtualisation. Kotlin compilation, KSP and kapt, resource merging, D8 dexing, R8 shrinking, AAPT2, Android Lint, detekt, unit tests, Robolectric, Spotless, JaCoCo coverage: all of it is unremarkable Linux work.

The cross-platform tooling is the same story on the parts that matter. React Native's JavaScript bundling through Metro, the npm or pnpm install that precedes it, Flutter's Dart compilation to native ARM code, Hermes bytecode generation — none of those need a device and none of them need a hypervisor. Flutter's Android build is a Gradle build wearing a hat. What does need a device is the widget-on-hardware testing at the end, and Flutter's own widget tests, usefully, run in a Dart VM rather than on a device, so they land on the good side of the line too.

Signing works, with a caveat worth stating carefully. apksigner and jarsigner are JVM tools; they run in a guest without complaint. The question is not whether the tool runs, it is whether you want your upload key present in the filesystem of a build environment. A per-job disposable VM is a genuinely better place for that than a long-lived shared runner — the key exists for the duration of one build in a machine that is destroyed afterwards, rather than sitting on a box that has run four hundred other people's builds. But the strongest posture is still to not put the release key in the build environment at all: sign with an upload key under Play App Signing, or hand the unsigned artefact to a signing service that holds the key and returns a signature. Treat the microVM as a reduction in exposure window, not as an excuse to relax key handling.

A Gradle build in a sandbox, concretely

Two things about the environment are worth knowing before you write this. First, our base template pre-warms Node, Python, Go and Bun through mise, but not a JDK — Java arrives on demand via mise, which reads .tool-versions or mise.toml if the repo has one. For a real Android pipeline you would bake your own template with the JDK, the Android SDK, the build-tools and the platform APIs already installed and the licences already accepted, because sdkmanager downloads are pure waste on every job. Second, and this catches people out: guest RAM is baked into the snapshot. Passing memory_mb to a create against a template that has a baked snapshot does nothing, because Firecracker cannot resize a machine at restore time. Our base template bakes at 4 GiB and 8 vCPU. Size your Gradle daemon heap under that number, not over it.

from pandastack import Sandbox

# A real Android pipeline would use its own baked template with the JDK,
# Android SDK, build-tools and accepted licences already in the image. Using
# "base" here so the example is runnable; note that base pre-warms Node/
# Python/Go/Bun via mise but NOT a JDK, so we install one.
#
# memory_mb is deliberately absent: guest RAM is a property of the baked
# snapshot, not of the create request. Passing it would be a silent no-op.
sbx = Sandbox.create(
    template="base",
    ttl_seconds=3600,          # a wedged Gradle daemon must not outlive the job
    metadata={"kind": "android-build", "commit": "9f21c0a"},
)

try:
    # Gradle's defaults assume a workstation. In a 4 GiB guest the daemon
    # heap plus the Kotlin compiler's own daemon will happily exceed the
    # machine and get OOM-killed mid-build, which surfaces as a confusing
    # "Daemon disappeared unexpectedly" rather than as an OOM.
    sbx.exec("mkdir -p /root/.gradle", check=True)
    sbx.filesystem.write(
        "/root/.gradle/gradle.properties",
        "org.gradle.jvmargs=-Xmx2500m -XX:MaxMetaspaceSize=512m\n"
        "org.gradle.daemon=false\n"
        "org.gradle.parallel=true\n"
        "kotlin.daemon.jvmargs=-Xmx1g\n",
    )

    sbx.exec("mise use -g java@temurin-17", timeout_seconds=600, check=True)
    sbx.exec(
        "git clone --depth 1 --branch main https://github.com/you/app.git /work",
        timeout_seconds=300,
        check=True,
    )

    # Everything below this line is ordinary Linux CPU work. No devices, no
    # hypervisor, no GPU.
    sbx.exec("cd /work && ./gradlew lintDebug detekt", timeout_seconds=1800, check=True)
    sbx.exec("cd /work && ./gradlew testDebugUnitTest", timeout_seconds=1800, check=True)
    sbx.exec("cd /work && ./gradlew assembleRelease", timeout_seconds=1800, check=True)

    # Pull the artefacts out before the guest dies. The APK and the R8
    # mapping file are what the device stage and your crash reporter need.
    sbx.filesystem.download(
        "/work/app/build/outputs/apk/release/app-release-unsigned.apk",
        "./out/app-release-unsigned.apk",
    )
    sbx.filesystem.download(
        "/work/app/build/outputs/mapping/release/mapping.txt",
        "./out/mapping.txt",
    )
finally:
    sbx.kill()

Nothing in that script cleans up after itself, checks for a stale build directory, or worries about what the previous job left in the Gradle user home. It does not need to. The guest did not exist a minute ago and will not exist a minute after the finally block runs, which removes an entire category of "works on my runner" bug at the cost of doing the dependency resolution again — which brings us to the part that is actually worth the migration.

The warm-parent fork, which is the real win

The expensive, boring, identical-every-time part of an Android build is the front half: resolving a few hundred Maven artefacts, populating the Gradle cache, downloading the Kotlin compiler's dependencies, and on a React Native project running an npm install over a dependency tree with thousands of entries. On a cold runner that is minutes. Across a matrix of build variants or a multi-module parallel build, you pay it once per job for no reason at all.

A microVM can do something a container cannot here. Fork a running guest and the child inherits the parent's memory and its filesystem through copy-on-write, so the child starts life with the Gradle cache already populated, the file cache already warm, and — if you set it up that way — a Gradle daemon already running with your build script configuration cached. A same-host fork lands in 400 to 750 milliseconds. That is the difference between paying dependency resolution once per pipeline and paying it once per matrix cell.

from concurrent.futures import ThreadPoolExecutor
from pandastack import Sandbox

VARIANTS = ["assembleFreeDebug", "assemblePaidDebug", "assembleFreeRelease",
            "assemblePaidRelease", "bundlePaidRelease"]

# 1. One parent does the expensive shared work exactly once: clone, resolve
#    every dependency in the graph, warm the Gradle and Kotlin caches.
#    Gradle has no built-in "resolve everything" task -- projects that care
#    about this add one. Building one variant is the crude, reliable way to
#    populate the cache, and it is what we do here.
parent = Sandbox.create(
    template="base",
    ttl_seconds=7200,
    metadata={"kind": "android-matrix", "role": "parent"},
)
parent.exec("mise use -g java@temurin-17", timeout_seconds=600, check=True)
parent.exec("git clone --depth 1 https://github.com/you/app.git /work",
            timeout_seconds=300, check=True)
parent.exec("cd /work && ./gradlew --no-daemon assembleFreeDebug",
            timeout_seconds=2400, check=True)

# 2. Fork. Children inherit memory AND disk, so /root/.gradle/caches comes
#    with them at no copy cost -- that is the whole trick. fork_tree
#    snapshots the parent ONCE and boots the children in parallel; each gets
#    a fresh network identity. The per-call child count is capped at 16, so
#    a wider matrix is a loop over batches.
children = parent.fork_tree(count=len(VARIANTS),
                            metadata={"kind": "android-matrix"})

def build(pair):
    child, task = pair
    try:
        r = child.exec(f"cd /work && ./gradlew {task}", timeout_seconds=2400)
        return {"task": task, "ok": r.exit_code == 0, "tail": r.stderr[-2000:]}
    finally:
        child.kill()   # free the host RAM before the next batch

with ThreadPoolExecutor(max_workers=len(children)) as pool:
    results = list(pool.map(build, zip(children, VARIANTS)))

parent.kill()
for r in results:
    print(("PASS " if r["ok"] else "FAIL ") + r["task"])

Two details are load-bearing. The kill() sits in a finally because the resource you are contending for is host memory, and a batch that leaks guests fails the next batch rather than itself. And the fork happens after the warming build has finished, never during it — a fork inherits state faithfully, so a half-written build directory or a Gradle daemon mid-task is inherited by all sixteen children just as reliably as the warm cache is. Fork from a clean, warm, idle parent. One side effect worth noticing: because the parent already built the first variant, that child's job is an incremental no-op, which is a free sanity check that the inherited cache is genuinely intact.

What does not run: the Android emulator

Here is the plain version. A hardware-accelerated Android emulator needs KVM on the machine it runs on. A Firecracker microVM does not expose virtualisation extensions to its guest — there is no /dev/kvm inside, kvm-ok reports nothing, and the kvm modules will not load usefully even if the guest kernel ships them. That is not a missing feature or a roadmap item. It is downstream of what Firecracker is for: a deliberately tiny VMM surface between untrusted guests and the host, which is exactly the thing a nested-virtualisation emulation path would expand. I wrote the mechanism up properly in the nested virtualisation post linked below, and I am not going to re-explain L0/L1/L2 here. The consequence is what matters for this post.

# Inside a Firecracker guest. This is the whole story in four commands.
$ ls -l /dev/kvm
ls: cannot access '/dev/kvm': No such file or directory

$ grep -c -E 'vmx|svm' /proc/cpuinfo
0

$ emulator -avd test_avd -no-window
# ... falls back to software emulation, because there is nothing to accelerate

# Nothing you run inside the guest changes this. modprobe kvm_intel is
# addressing the wrong kernel: the decision belongs to the layer below you,
# and that layer is Firecracker, which does not offer it to anyone.

You can still start the emulator. It will fall back to software emulation, and the fallback is not a mild performance penalty — it is QEMU's software CPU translation doing the work the hardware was doing, on every instruction the guest Android executes. A boot that takes tens of seconds accelerated takes long enough unaccelerated that people assume it has hung. I am deliberately not quoting you a multiplier, because it varies enormously with the workload and anyone who gives you a single number is quoting their machine rather than yours. What I will say is the practical part: every timeout in your instrumented test suite was written against the accelerated case, and none of them will hold. You will spend a week raising timeouts and still have a suite that is too slow to gate a pull request on.

There is a second wall stacked behind the first, and it catches people who were hoping to get away with the software path. The emulator wants a GPU for rendering. PandaStack sandboxes are CPU-only with no device passthrough, so you are on software rendering as well as software CPU emulation — two independent sources of slowness compounding on a UI test suite whose entire job is to render frames. If your instinct is that a big enough machine solves this, it does not: the constraint is not how many cores you have, it is that a layer which should be hardware is now software.

Watch for the architecture trap too. If your CI hosts are ARM and your AVD uses an x86_64 system image, you are cross-architecture emulating even on a host that does have KVM, because hardware virtualisation only accelerates the architecture the CPU actually is. Pick the system image that matches your host architecture, or accept that you are running a full-system translator regardless of what /dev/kvm says.

iOS is worse, and simpler

There is nothing to design around. Xcode runs on macOS, macOS runs on Apple hardware, and the licence agreement has opinions about virtualising it that are separate from whether it is technically possible. A Linux microVM does not change any part of that chain, and no Linux microVM ever will — this is not a hypervisor question, it is a question of which operating system the compiler and the SDK exist for. If your pipeline builds an iOS target, that stage needs a Mac: a hosted Mac CI provider, a Mac mini in a rack, or your own hardware. Everything in your pipeline that is not the Xcode build — the JavaScript bundling for a React Native app, the Dart compilation for Flutter, dependency resolution, linting, unit tests written in a language that does not need the Apple toolchain — can still move. The Xcode invocation cannot. That is the whole paragraph, and I would rather give you one honest one than three optimistic ones.

The split pipeline, and how to hand artefacts across it

Accept the boundary and the architecture designs itself. The compute-heavy, parallel, stateless majority of the pipeline runs on an ephemeral microVM fleet, one guest per job, forked from a warm parent for the matrix. The device and emulator stage runs on a device farm — a hosted service, or racked physical devices, or a small emulator pool on machines that genuinely have KVM. The iOS build runs on a Mac. Three environments, one artefact flowing between them.

The important design rule at the boundary is that what crosses it must be an artefact, not a machine state. Do not let the device stage clone the repository and build it again — that gives you two builds that might differ, tested bytes that are not the shipped bytes, and a second copy of your build configuration to keep in sync. Build once in the microVM stage, publish the exact APK or AAB plus the R8 mapping file plus the test APK to object storage keyed by commit SHA, and have the device stage pull those bytes by SHA and do nothing else. The contract between the two halves is a content-addressed URL, which means either half can be re-implemented without touching the other.

  • Publish the app APK, the androidTest APK, and the mapping file together. The device stage needs all three: two to install, one to symbolicate a crash.
  • Key everything by commit SHA, not by branch or build number. When a flaky device test fails on Tuesday you want to be certain which bytes it ran against.
  • Run the microVM half on every pull request and gate on it. It is fast, parallel and cheap, so there is no reason to defer it.
  • Run the device half on merge or on a schedule, not on every push. Device pools are the scarce resource; queueing every PR behind them is how you get a two-hour feedback loop.
  • Emit a machine-readable verdict from the microVM stage — a small JSON file with pass/fail, coverage and lint counts — rather than parsing build logs downstream. Logs are for humans.
  • Keep the signing step on whichever side holds the key, and make sure only one side does. Signing in both places produces two artefacts with the same version code and a very confusing afternoon.

On cost, the thing worth knowing is what the billing dimension is. Our rate card is one set of numbers across every workload class — $0.054 per vCPU-hour and $0.0162 per GiB-hour — and CPU is billed by the CPU-seconds a guest actually burns rather than by the vCPU count it was baked with. That matters for Gradle specifically, because a build is spiky: parallel during compilation, single-threaded and waiting during dependency resolution. You are not charged eight cores for the stretches where Gradle is using one. Memory is charged on committed GiB-hours, so the template size you choose is a real decision in a way the vCPU count is not.

What I would check before committing to this

  1. Time your pipeline stage by stage on the runner you have now. If instrumented tests are eighty per cent of your wall clock, moving the build half saves you less than you think and the device farm is where the work is.
  2. Bake a template with the JDK, Android SDK, build-tools and platform APIs already installed and licences accepted. Every sdkmanager download in a job is pure waste, and it is the single largest easy win.
  3. Measure your build's peak memory before choosing the template size. Kotlin compilation plus R8 on a large module is memory-hungry, and guest RAM is fixed at bake time, so getting this wrong means re-baking rather than changing a flag.
  4. Prove the fork-from-warm-parent path on your actual dependency graph. The saving is proportional to how long resolution takes, so measure it rather than assuming it.
  5. Decide where the signing key lives before you write any of it, and prefer a design where the answer is "not in the build environment".
  6. Pick the artefact contract — bucket layout, SHA keying, which files cross — and write it down. It is the interface between two halves of a pipeline that will be maintained by different people.
  7. Only then move the emulator question. In most pipelines the correct answer turns out to be a device farm for the tests that must touch a device, plus more Robolectric and more unit tests so that fewer of them must.

The summary I would like you to take away is that the microVM boundary in mobile CI is sharp and easy to predict once you stop asking about mobile CI as a whole. Anything that is a Linux process doing CPU work runs at native speed and benefits from disposable environments and copy-on-write forks. Anything that needs to be a hypervisor, or needs a GPU, or needs to be macOS, does not run, and will not later. That is three clean rules, and between them they classify every stage in your pipeline without you having to try it and find out.

The best mobile CI architectures I have seen are not the ones that found a way to run everything in one place. They are the ones that got honest about which ten per cent needs special hardware and made that ten per cent as small as possible.

Frequently asked questions

Can I run the Android emulator inside a Firecracker microVM?

Not with hardware acceleration, and the unaccelerated version is rarely worth doing. A hardware-accelerated Android emulator needs KVM on the machine it runs on, and Firecracker does not expose virtualisation extensions to its guests — there is no /dev/kvm inside a microVM, and no configuration changes that, because the decision belongs to the layer beneath you. The emulator will still start and fall back to software CPU emulation, but that means QEMU translating every instruction the guest Android executes, and on a platform with no GPU passthrough you are also on software rendering for a workload whose job is drawing frames. The practical failure mode is not that it does not work, it is that every timeout in your instrumented test suite was written for the accelerated case and none of them hold. Run instrumented tests on a device farm or on emulator hosts that genuinely have KVM, and run everything else in the microVM.

Which parts of an Android build actually work well in a sandbox?

All the JVM-shaped ones, which is most of the pipeline by task count and usually by wall-clock time too. Dependency resolution, Kotlin and Java compilation, KSP and kapt, resource merging, D8 dexing, R8 shrinking, Android Lint, detekt, ktlint, unit tests, Robolectric, JaCoCo, APK and AAB assembly, and signing all run as ordinary Linux processes at native CPU speed. On cross-platform projects, React Native's Metro bundling, Hermes bytecode generation, npm or pnpm install, Flutter's Dart compilation and Flutter's widget tests are all on the same side of the line, because none of them need a device. The rule of thumb is simple: if the task would run on a Linux CI container today, it runs in a microVM, with the added benefit that each job gets its own kernel and a filesystem that did not exist a minute ago.

Does forking a sandbox actually help with Gradle build times?

It helps in proportion to how much of your build is dependency resolution and cache warming, which on a large Android project is a great deal. The mechanism is that a fork inherits the parent's memory and filesystem through copy-on-write, so a child starts with the Gradle artefact cache already populated and the page cache already warm rather than re-downloading a few hundred Maven artefacts. A same-host fork lands in 400 to 750 milliseconds, so for a matrix of build variants you pay resolution once for the whole matrix instead of once per cell. Two rules make it work: fork from a clean, warm, idle parent — children inherit a half-written build directory just as faithfully as they inherit a warm cache — and kill children in a finally block, because host memory is the resource you are contending for. The per-call fork-tree child count is capped at sixteen, so a wider matrix is a loop over batches.

Can I build iOS apps on a Linux microVM platform?

No, and this one is not a hypervisor question. Xcode and the iOS SDK exist for macOS only, macOS runs on Apple hardware, and Apple's licence terms have their own opinions about virtualising it that are separate from technical feasibility. A Linux microVM does not change any link in that chain. What you can move is everything in an iOS pipeline that is not the Xcode invocation: JavaScript bundling for React Native, Dart compilation for Flutter, dependency resolution, linting, and any tests written in a language that does not need the Apple toolchain. The realistic architecture for a cross-platform app is a microVM fleet for the shared and Android work, a Mac for the Xcode stage, and a content-addressed artefact bucket between them.

How should artefacts move between the microVM stage and the device farm?

As bytes keyed by commit SHA, and never as a second build. The strong temptation is to let the device stage clone the repository and build it again because that is how the pipeline grew, but that gives you two builds that can diverge, tested bytes that are not the shipped bytes, and two copies of your build configuration to keep in sync. Build once in the microVM stage and publish the app APK or AAB, the androidTest APK and the R8 mapping file to object storage under the commit SHA; have the device stage pull exactly those and do nothing else. Emit a small machine-readable verdict file from the build stage rather than having downstream jobs parse build logs. The result is that the contract between the two halves is a content-addressed URL, which means you can replace either half — a different device farm, a different compute platform — without touching the other.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.