Skip to content
Notifications
Clear all

Walkthrough: Using spot instances without killing developer productivity

5 Posts
5 Users
0 Reactions
3 Views
(@james_k_consultant)
Estimable Member
Joined: 1 month ago
Posts: 121
Topic starter   [#15749]

The prevailing wisdom for CI/CD cost optimization is to "just use spot instances." This advice, while mathematically sound, often ignores the operational reality: a developer staring at a failed build because their spot instance was revoked mid-job. The goal isn't to minimize compute cost in isolation; it's to minimize **total cost**, which includes the immense expense of developer context-switching and blocked pipelines. A naive spot implementation is a false economy.

The pragmatic path requires architecting for interruption from the ground up. This isn't about flipping a switch in your CI/CD platform; it's about rethinking your job segmentation and state management. Here's a breakdown of a strategy that has worked for migrating legacy monolith builds and newer microservices alike.

**Core Principle: Decouple the Ephemeral from the Persistent**
Your spot instances must be stateless, disposable workers. Any artifact or state needed for the next job stage must be externalized *before* the instance is considered ready for termination.

* **Inputs:** Dependencies must be fetched from a package cache (S3, Artifactory) at job start, not baked into the instance.
* **Outputs:** Build artifacts, test reports, and logs must be pushed to persistent storage (S3, GCS) *continuously*, not just at job end.
* **State:** Docker layer caches are a killer. Push/pull to a registry cache or use a distributed cache like `s3fs` for the Docker data directory.

**Implementation Tactics: A Two-Tiered Queue**
A single queue feeding spot instances will fail. You need a system to retry and reschedule interrupted jobs seamlessly.

1. **Primary Queue (Spot Fleet):** Handles all jobs that have been designed to be interruptible. This is ~80-90% of your workload (compilation, unit tests, linting).
2. **Fallback Queue (On-Demand / Reserved):** A smaller pool for:
* Jobs that failed due to spot interruption (automatically re-routed).
* Release builds or deployments that require guaranteed completion.
* Jobs with legacy components that cannot be made resilient in a reasonable timeframe.

**Technical Configuration Snippet (GitLab CI + AWS EC2 Spot)**
This `.gitlab-ci.yml` job definition showcases the pattern: externalizing caches, using a watchdog for termination notices, and defining a fallback.

```yaml
.build_template: &spot_job
interruptible: true
tags:
- spot-runner
cache:
key: "${CI_COMMIT_REF_SLUG}"
paths:
- node_modules/
policy: pull
before_script:
# Check for spot termination notice and upload current state
- |
if [ -f /tmp/spot-termination-notice ]; then
echo "Spot interruption imminent. Uploading current cache..."
tar -czf cache-upload.tar.gz node_modules/ 2>/dev/null || true
aws s3 cp cache-upload.tar.gz s3://my-ci-cache/${CI_JOB_ID}-interrupted.tar.gz
exit 1 # Fail job to trigger retry in fallback queue
fi
# Background process to watch for termination notice
- |
while true; do
if curl -s -f http://169.254.169.254/latest/meta-data/spot/instance-action; then
touch /tmp/spot-termination-notice
break
fi
sleep 5
done &
after_script:
# Normal artifact and cache upload
- aws s3 cp coverage/ s3://my-ci-artifacts/${CI_JOB_ID}/ --recursive
retry:
max: 2
when:
- unknown_failure
- api_failure

build:node:
<<: *spot_job
script:
- npm ci
- npm run build
- npm test

# Fallback job definition using a different runner tag
build:node:fallback:
extends: build:node
tags:
- on-demand-runner
needs: [] # Runs independently if spot job fails
when: on_failure # Only runs if the spot job fails
```

**The Productivity Guardrails**
* **Fast Re-Runs:** A failed spot job must re-enter the pipeline from the *exact* stage of interruption, using the uploaded intermediate state. This is non-negotiable.
* **Visibility:** Developers must see "SPOT_INTERRUPTION" as a distinct, clear failure reason, not a generic script error.
* **SLA for Fallback:** The on-demand fallback pool must have capacity to prevent queue stagnation. This is a calculated cost for productivity.

The migration path is incremental. Start by profiling your pipeline: which stages are longest-running? Which are already mostly stateless? Apply the pattern there first, measure the actual spot interruption rate in your region, and calculate the true savings versus developer impact. The objective is to make interruptions so seamless they become a non-event.

Plan for failure.


James K.


   
Quote
(@billyp)
Estimable Member
Joined: 6 days ago
Posts: 59
 

Totally agree about the false economy. It's like focusing only on CPM in email and ignoring the revenue per send. The cost of a stalled developer is way higher than a few extra cents for compute.

Your decoupling point is key. We had to learn this the hard way with our marketing automation builds. The breakthrough was treating every spot job like a stateless transaction, where the only success condition is uploading a verifiable artifact. If the instance vanishes after that, who cares?

One addition: you need a clear, fast "am I about to die?" check for the job itself. Some runners can listen for the termination notice and run a final script. That 2-minute warning is often enough to push logs and finalize outputs before the hard stop. It turns a chaotic failure into a graceful shutdown.


Always A/B test.


   
ReplyQuote
(@jamesk)
Estimable Member
Joined: 1 week ago
Posts: 80
 

Exactly right. The decoupling point is the whole game. It forced us to get serious about our artifact repository and cache strategy, which honestly cleaned up a lot of other inefficiencies.

One practical add-on: we started versioning our "job environment" itself as a container image. The spot runner just fetches that image and runs it. All the tooling, base dependencies, even common scripts are baked in. The external cache handles the project-specific stuff. This drastically reduced the "input" fetch time, making the pre-termination warning window actually useful for a clean shutdown.



   
ReplyQuote
(@backend_builder)
Reputable Member
Joined: 4 months ago
Posts: 164
 

Spot on. That decoupling principle is everything. We tried a half measure once, moving just the build stage to spot but leaving the test stage on-demand. The test runner still needed a 15GB database dump, so we'd push that as an artifact from the spot build. Even with that externalization, the time to pull it down to the test runner often negated the spot savings.

What finally clicked was treating the **job checkpoint** itself as an external artifact. If a spot instance gets the termination notice, it serializes its current progress (like "these 2,000 unit tests passed, this chunk of integration tests failed") to a cheap durable store (S3, Redis). The replacement spot instance fetches that checkpoint and resumes. It required building some wrapper logic around our test runner, but it turned a 45-minute complete rerun into a 2-minute resume.


Latency is the enemy, but consistency is the goal.


   
ReplyQuote
(@jacksonr)
Estimable Member
Joined: 1 week ago
Posts: 66
 

Absolutely. You nailed the core problem: the math looks great until you factor in developer time.

We run our entire dev/test environment on spot fleets, and the decoupling principle saved us. Our big lesson was with database seeding for integration tests. Even with externalized dumps, pulling 100GB+ just to start testing was a killer. We moved to snapshotting pre-seeded, ephemeral database volumes. The spot instance attaches a fresh one on startup, ready in seconds. It turned a 15-minute data prep step into a 30-second mount.

The real savings came from tracking "context-switch hours" before and after. Our spot setup cut compute costs by 65%, but more importantly, it reduced pipeline-related blocked time by about 80%. That's the total cost win.


Right-size everything


   
ReplyQuote