Netlify ruilde V8-isolates in voor Firecracker. Wat dat zegt over jouw cold starts

Netlify maakte Edge Functions tot vijf keer sneller met Firecracker-microVMs. Wat .NET-developers op Azure Functions en Lambda eruit halen over netwerkhops, snapshots en warme instances.

Jean-Pierre Broeders

Freelance .NET Developer

1 oktober 202613 min. leestijd
Netlify ruilde V8-isolates in voor Firecracker. Wat dat zegt over jouw cold starts

Netlify heeft de onderkant van zijn Edge Functions vervangen. De code draaide in V8-isolates bij een externe uitvoerdienst. Nu draait hij in Firecracker-microVMs op Netlify's eigen edge-netwerk. De mediane latency zakte van 25 à 40 milliseconden naar ongeveer 5 à 6. Op Hacker News stond het stuk binnen een paar uur op ruim 150 punten.

Het interessantste vond ik de reacties. Meerdere commenters wezen erop dat isolates op papier juist sneller opstarten dan microVMs. De winst moet dus ergens anders vandaan komen. En daar heb je als .NET-developer met een Function App of een Lambda meer aan dan aan het nieuws zelf.

Wat Netlify precies veranderde

De cijfers komen uit Netlify's eigen post. Ze verwerken ongeveer een miljard edge-functie-aanroepen per dag, met pieken van tienduizenden tot honderdduizenden per seconde. Vroeger gingen die requests "naar een gehoste uitvoerdienst". Welke dat was, schrijft Netlify niet. Een HN-commenter noemt Deno, maar dat is zijn lezing.

Na de migratie:

  • p50-latency rond 5 à 6 ms, tegen 25 à 40 ms eerder
  • p99 is 47,4% sneller
  • een cold start kost gemiddeld 9 ms en gebeurt bij ongeveer 1,2% van de aanroepen
  • logs komen vijf keer sneller binnen
  • beschikbaarheid 99,998%

Drie technische keuzes maken dat mogelijk. Netlify maakt een snapshot van de microVM zodra de JavaScript-server op zijn poort luistert, en herstelt volgende instances uit die snapshot. De bestanden van een functie staan in een ongecomprimeerde EROFS-image die wordt gememory-mapt, zodat de VM alleen de stukken van de bundle leest die hij gebruikt. En een edge-node kiest de compute-node voor een service met rendezvous hashing: "the same service lands on the same node every time, which is what keeps a MicroVM warm."

Voor gebruikers verandert er niets. De limieten blijven 50 ms CPU per request, 512 MB geheugen en 20 MB gecomprimeerde code. Netlify zegt die later te willen herzien.

De grootste winst is een verdwenen netwerkhop

Iemand in de HN-thread stelde de voor de hand liggende vraag. Cloudflare Workers draaien ook op V8-isolates en zijn snel. Waarom was Netlify dan 25 tot 40 ms kwijt? Het antwoord uit de thread: Cloudflare voert de code uit in zijn eigen netwerk, Netlify stuurde elke request eerst naar een andere partij. Die extra rondreis was de rekening. Firecracker is het nieuwe uitvoermodel, maar de meeste milliseconden zijn gewonnen door de code dichter bij de request te zetten.

Dit patroon zie ik bij klanten bijna elke keer als iemand over cold starts klaagt. Er wordt naar .NET gewezen: de JIT, de DI-container, te veel assemblies. Vervolgens blijkt de startup secrets uit Key Vault te halen, een database in een andere regio te pingen en een feature-flag-dienst aan de andere kant van de oceaan te raadplegen. Dat zijn drie netwerkrondes voordat de eerste request beantwoord wordt. ReadyToRun lost daar niets aan op.

Dus eerst meten. Dat kost een middleware van twintig regels.

Meten: was deze aanroep koud, en waar ging de tijd heen?

In het isolated worker-model van Azure Functions hang je een middleware in de pipeline. Een statische teller vertelt je of dit de eerste aanroep van het proces is. De leeftijd van het proces vertelt je hoe lang de startup duurde.

using System.Diagnostics;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Extensions.Logging;

public sealed class ColdStartMiddleware(ILogger<ColdStartMiddleware> logger)
    : IFunctionsWorkerMiddleware
{
    private static int _invocations;
    private static readonly DateTime ProcessStartUtc =
        Process.GetCurrentProcess().StartTime.ToUniversalTime();

    public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
    {
        var isCold = Interlocked.Increment(ref _invocations) == 1;
        var processAgeMs = (DateTime.UtcNow - ProcessStartUtc).TotalMilliseconds;
        var sw = Stopwatch.StartNew();

        try
        {
            await next(context);
        }
        finally
        {
            logger.LogInformation(
                "Invocation {FunctionName} Cold={Cold} DurationMs={DurationMs} ProcessAgeMs={ProcessAgeMs}",
                context.FunctionDefinition.Name, isCold, sw.ElapsedMilliseconds, processAgeMs);
        }
    }
}

Registreren doe je in Program.cs:

using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;

var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.UseMiddleware<ColdStartMiddleware>();
builder.Build().Run();

ProcessAgeMs bij een koude aanroep is grofweg je startuptijd binnen de worker. DurationMs is de functie zelf. Zit het grootste deel van de tijd vóór je middleware, dan zit het in de host of in je Program.cs. In Application Insights zet je ze naast elkaar:

traces
| where message startswith "Invocation "
// via de host krijgen structured properties het prefix prop__,
// met de Application Insights-package van de worker niet
| extend cold = tobool(coalesce(customDimensions.Cold, customDimensions.prop__Cold)),
         duration = todouble(coalesce(customDimensions.DurationMs, customDimensions.prop__DurationMs))
| summarize count(), p50 = percentile(duration, 50), p99 = percentile(duration, 99) by cold, bin(timestamp, 1h)

Is het percentage koude aanroepen klein en de p99 toch slecht, dan heb je geen cold-start-probleem. Dan zit je met een trage afhankelijkheid. Kijk in dat geval naar de dependency-telemetrie en laat de startup voorlopig met rust.

Snapshots: dezelfde truc bestaat ook voor .NET

Netlify's snapshot-aanpak is niet nieuw. AWS doet hetzelfde met Lambda SnapStart, dat sinds eind 2024 ook voor de managed .NET-runtime beschikbaar is. Lambda draait je initialisatie één keer, maakt een snapshot van de Firecracker-VM en herstelt nieuwe execution environments daaruit. Bij Azure Functions bestaat dat niet als functie die je zelf aanzet. Daar zijn always-ready instances op het Flex Consumption-plan (alleen voor het isolated model) het dichtstbijzijnde alternatief, samen met ReadyToRun om JIT-werk naar de build te verschuiven.

Snapshots hebben één valkuil, en die kwam in de HN-thread meteen boven. Alles wat je vóór de snapshot in het geheugen zet, wordt in elke herstelde instance gekopieerd. Ook de interne state van een random-generator. Een commenter noemde geforkte RNG-state "catastrophic" voor UUID-generators en cryptografie. AWS schrijft in zijn eigen documentatie dat je unieke waarden na de initialisatie moet maken en voor .NET RandomNumberGenerator aanraadt.

In .NET loop je hier sneller tegenaan dan je denkt. Random.Shared en een new Random() zonder seed bewaren hun state in het geheugen. Initialiseer je ze tijdens de startup, dan geven alle instances uit dezelfde snapshot dezelfde reeks. Een statisch "instance-id"-veld heeft hetzelfde probleem. SnapStart heeft daar hooks voor in Amazon.Lambda.Core (vanaf versie 2.5.0):

using System.Security.Cryptography;
using System.Text.Json;
using Amazon.Lambda.Core;

public sealed record Order(string Id, decimal Amount);

public sealed class OrderFunction
{
    // Fout: wordt in de snapshot bevroren en is daarna overal gelijk.
    private static readonly string FrozenId = Guid.NewGuid().ToString("N");

    private string _instanceId = "";

    public OrderFunction()
    {
        SnapshotRestore.RegisterBeforeSnapshot(BeforeSnapshotAsync);
        SnapshotRestore.RegisterAfterRestore(AfterRestoreAsync);
    }

    private ValueTask BeforeSnapshotAsync()
    {
        // Duur werk dat je juist wel in de snapshot wilt: JIT en serializer-caches.
        _ = JsonSerializer.Serialize(new Order("warmup", 0m));
        return ValueTask.CompletedTask;
    }

    private ValueTask AfterRestoreAsync()
    {
        // Alles wat per instance uniek moet zijn, hoort hier.
        _instanceId = Convert.ToHexString(RandomNumberGenerator.GetBytes(8));
        return ValueTask.CompletedTask;
    }

    public string Handle(Order order, ILambdaContext context)
    {
        context.Logger.LogInformation($"Instance {_instanceId} verwerkt {order.Id}");
        return _instanceId;
    }
}

De vuistregel: wat duur is en voor iedereen gelijk, doe je vóór de snapshot. Wat uniek moet zijn of met de buitenwereld praat, doe je erna. Open TCP-verbindingen en tokens met een verloopdatum horen bij die tweede groep. Een verbinding uit de snapshot is na het herstel vaak al dicht aan de andere kant.

Warm blijven door elke keer dezelfde node te kiezen

Het rendezvous hashing-detail is het stuk dat je direct kunt overnemen, ook zonder serverless. Het idee: voor elke combinatie van sleutel en node bereken je een hash, en de node met de hoogste score wint. Valt er een node weg, dan verhuizen alleen de sleutels die op die node zaten. Met hash % aantalNodes verschuift bij een overgang van vier naar drie nodes ongeveer driekwart van alle sleutels. Met rendezvous hashing is dat een kwart.

Dat is precies wat je wilt als warmte duur is. Denk aan in-memory caches per tenant, een geladen ML-model per klant of een compiled query-plan. In YARP schrijf je zo'n policy zelf:

using System.IO.Hashing;
using System.Text;
using Yarp.ReverseProxy.LoadBalancing;
using Yarp.ReverseProxy.Model;

public sealed class TenantAffinityPolicy : ILoadBalancingPolicy
{
    public string Name => "TenantAffinity";

    public DestinationState? PickDestination(
        HttpContext context,
        ClusterState cluster,
        IReadOnlyList<DestinationState> availableDestinations)
    {
        if (availableDestinations.Count == 0)
            return null;

        var tenant = context.Request.Headers["X-Tenant-Id"].ToString();
        if (string.IsNullOrEmpty(tenant))
            return availableDestinations[Random.Shared.Next(availableDestinations.Count)];

        DestinationState? best = null;
        ulong bestScore = 0;

        foreach (var destination in availableDestinations)
        {
            var score = XxHash64.HashToUInt64(
                Encoding.UTF8.GetBytes($"{tenant}\n{destination.DestinationId}"));

            if (best is null || score > bestScore)
            {
                best = destination;
                bestScore = score;
            }
        }

        return best;
    }
}
builder.Services.AddSingleton<ILoadBalancingPolicy, TenantAffinityPolicy>();
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

In de clusterconfiguratie zet je "LoadBalancingPolicy": "TenantAffinity". XxHash64 komt uit het NuGet-pakket System.IO.Hashing. Gebruik hier geen string.GetHashCode(): die is in .NET per proces gerandomiseerd, dus twee proxy-instances zouden dan verschillende keuzes maken.

Het nadeel moet je wel kennen. Eén drukke tenant landt altijd op dezelfde node. Bij Netlify vangen snelle cold starts van 9 ms dat op. Bij jou misschien niet. Zet daarom een begrenzing op de belasting per node, of laat de policy uitwijken naar de op één na hoogste score zodra een node een drempel overschrijdt.

Een isolate is geen beveiligingsgrens, een AssemblyLoadContext ook niet

Netlify noemt naast snelheid ook isolatie als reden. Een gecompromitteerde deploy draait in zijn eigen microVM en kan bij een uitbraak uit de runtime geen andere klanten raken. "V8 isolates, no matter their name, do not provide this level of isolation." In de thread voegde iemand toe dat het V8-team isolates zelf niet als beveiligingsgrens beschouwt, en een ander vatte het samen als gedeelde kernel tegenover een eigen kernel met hardwarevirtualisatie.

Daar zit een directe parallel met .NET. Een AssemblyLoadContext is handig om plugins te laden en weer te ontladen, maar hij is geen sandbox. Code Access Security en AppDomain-sandboxing bestaan in moderne .NET niet meer, en Microsoft verwijst voor isolatie naar wat het besturingssysteem biedt: processen, containers, virtualisatie. Laat je klanten eigen scripts uitvoeren in je SaaS, via Roslyn scripting of een plugin-systeem, dan horen die in een apart proces met minimale rechten. Is de code echt onbetrouwbaar, dan een container met gVisor of een microVM. Firecracker en Kata Containers zijn daar geen exotische keuzes meer voor.

Wat ik hier bij klanten mee doe

Bij een cold-start-klacht begin ik met de middleware van hierboven en een week data. In de meeste gevallen is het percentage koude aanroepen laag en zit de pijn in een afhankelijkheid die tijdens de startup over een oceaan heen praat. Die verplaats je, cachet je of haal je lazy op.

Pas daarna kijk ik naar de runtime. ReadyToRun staat bij mij standaard aan. Always-ready instances op Flex Consumption zet ik alleen in voor HTTP-functies waar een gebruiker op zit te wachten. Native AOT kan voor kleine functies flink schelen, maar de beperkingen rond reflection maken het voor een gemiddelde zakelijke app met EF Core en een DI-container zelden de moeite. En snapshots gebruik ik op Lambda pas als ik de code heb doorgelopen op statische velden en random-state.

En draait er code van klanten in hetzelfde proces als die van jou, dan is dat het eerste wat ik verander. Nog vóór welke cold start dan ook.

Veelgestelde vragen over Firecracker, snapshots en cold starts

Kan ik Firecracker-microVMs zelf gebruiken voor een .NET-workload? Ja, Firecracker is open source en draait een gewone Linux-guest, dus een self-contained .NET-app of een Native AOT-binary werkt erin. Voor de meeste teams is het alleen zinvol als je onbetrouwbare code van klanten moet isoleren; voor je eigen services zijn containers op Kubernetes of Container Apps eenvoudiger te beheren.

Heeft Azure Functions iets dat vergelijkbaar is met Lambda SnapStart? Niet als instelling die je zelf aanzet. Het dichtstbijzijnde is het Flex Consumption-plan met always-ready instances, dat een minimum aantal warme workers per functiegroep vasthoudt, in combinatie met ReadyToRun-compilatie om JIT-werk tijdens de startup te verkleinen.

Is Random.Shared veilig na een snapshot-restore? Niet voor waarden die uniek moeten zijn, omdat de interne state van de generator in de snapshot zit en elke herstelde instance dezelfde reeks kan produceren. Gebruik RandomNumberGenerator of Guid.NewGuid() op het moment dat je de waarde nodig hebt, of maak zulke waarden opnieuw aan in een after-restore-hook.

Wanneer kies ik rendezvous hashing boven round robin? Zodra een backend-instance waardevolle warme state per sleutel opbouwt, zoals een tenant-cache of een geladen model, en het opnieuw opbouwen daarvan merkbaar tijd kost. Zonder zulke state geeft round robin of least-requests een gelijkmatigere verdeling met minder risico op één overbelaste node.

Verder lezen


Conclusie: Netlify won zijn milliseconden vooral door de code naar de request te brengen, en de keuze voor Firecracker kwam daar bovenop. Meet bij je eigen cold starts dus eerst hoeveel aanroepen echt koud zijn en welke netwerkrondes in de startup zitten, voordat je aan de runtime gaat sleutelen.

Bronnen: Netlify: 5x faster Edge Functions · Hacker News-discussie · AWS: Lambda SnapStart runtime hooks voor .NET · AWS: uniciteit bij SnapStart.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op