Migrando un pipeline de Azure DevOps a un agent self-hosted: cuatro lecciones que la nube te oculta

Al pasar un pipeline Microsoft-hosted a un agent self-hosted en un NAS, dos tests que llevaban meses verdes empezaron a parpadear por jitter del scheduler. La migración funcionó, pero por el camino salieron a la luz cuatro problemas latentes que el hardware dedicado de Microsoft enmascaraba. Este post los documenta con ejemplos para que no te cuesten lo mismo.


El detonante: el free tier ya no llegaba

Si tu organización está en Azure DevOps con plan gratuito, tienes un único parallel job a tu disposición. No importa si lo eliges Microsoft-hosted (en la nube de MS) o self-hosted (en tu infra): es uno y solo uno. Si dos pipelines se disparan simultáneamente, uno espera al otro en cola.

Para muchos equipos pequeños esto basta — pero el día que pides incrementar el grant y tarda en llegar, o cuando los tiempos de cola empiezan a comerse parte de la jornada, la opción más práctica deja de ser «esperar» y pasa a ser «monto un agent en una máquina que ya tengo».

En nuestro caso esa máquina era un NAS de QNAP con suficientes recursos libres para correr Docker. Suficiente para un pipeline de build + deploy de una API .NET y su panel web.

El plan inicial parecía corto:

  1. Levantar un agent self-hosted en el NAS, registrarlo contra Azure DevOps.
  2. Cambiar el pool de los YAML.
  3. Pushear y mirar.

Spoiler: cada uno de esos pasos abrió una grieta.


El paso 1 (sin sorpresas): cambiar el pool

El cambio en el YAML es de manual. Donde antes ponía:

pool:
  vmImage: 'ubuntu-latest'

ahora pone:

pool:
  name: OFI-NAS-Linux
  demands:
    - dotnet.sdk -equals 8.0
    - azure.cli -equals true

Dos cosas que conviene hacer aquí y muchos pipelines no:

demands declara qué capabilities tiene que ofrecer el agent para que el job se le asigne. Como el pool solo tiene un agent, parece superfluo — pero el día que añadas un segundo (por failover, por especialización, por lo que sea) los demands son lo que evita que un job que requiere azure.cli aterrice en un agent que no la tiene.

batch: true en el trigger agrupa pushes consecutivos cuando ya hay un run en curso, en lugar de encolar uno por commit. Con un solo parallel job es la diferencia entre tener cinco runs encolados a las 15:00 o tener uno solo que entra cuando termine el actual:

trigger:
  batch: true
  branches:
    include:
      - main

También conviene no apurar el clean de workspace si el agent es persistente. En Microsoft-hosted cada run empieza desde cero, así que clean no aporta nada (ya estaba limpio). En self-hosted, un agent con workspace persistente reaprovecha lo descargado entre runs:

jobs:
  - job: BuildTest
    workspace:
      clean: outputs   # limpia bin/obj entre runs, no las sources
    steps:
      - checkout: self
        clean: false   # reaprovecha el clon del repo

Hasta aquí, todo lo que cualquier guía oficial te cuenta. Es lo que pasa después lo que no.


Lección 1: los feeds privados necesitan credenciales explícitas en CI

Primer push, primer error:

error NU1301: Unable to load the service index for source
https://nuget.ejemplo.com/v2/index.json

nuget.ejemplo.com es el feed comercial de un proveedor que requiere API key. Ese restore funcionaba en local desde hacía meses. ¿Qué cambió?

Nada en el código. Lo que cambió es dónde corre el restore:

  • En tu máquina, NuGet resuelve credenciales del feed privado mediante el nuget.config global del usuario (%APPDATA%\NuGet\NuGet.Config en Windows), donde la API key está cifrada por usuario.
  • En un agent (cualquier agent — Microsoft-hosted o self-hosted) no hay credenciales globales del usuario: el proceso de NuGet sólo ve el nuget.config versionado del repo, que típicamente declara el feed pero no contiene la key (porque versionarla sería un suicidio de seguridad).

El primer pipeline en hosted nunca había pasado del restore porque era el primer run real. La migración a self-hosted fue lo que disparó el problema, pero el problema no era de self-hosted: era latente y habría petado igual en hosted.

El fix sin meter secretos en el repo

Tres movimientos:

1. Subir la API key a un secret store. En Azure ese sitio natural es Key Vault:

az keyvault secret set \
    --vault-name company-kv \
    --name EjemploNuGetKey \
    --value "<api-key-real>"

2. Crear un Variable Group en Azure DevOps que enlace ese secret. En «Manage Key Vault secrets» se marca el secret y queda expuesto al pipeline como $(EjemploNuGetKey).

3. Crear un nuget.ci.config separado (versionado, sin secretos):

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
    <add key="ejemplo" value="https://nuget.ejemplo.com/v3/index.json" />
  </packageSources>
  <packageSourceCredentials>
    <ejemplo>
      <add key="Username" value="api-key" />
      <add key="ClearTextPassword" value="%EJEMPLO_NUGET_KEY%" />
    </ejemplo>
  </packageSourceCredentials>
</configuration>

Lo crítico: el valor %EJEMPLO_NUGET_KEY% no es la key, es una referencia a una variable de entorno que NuGet expande en runtime. El archivo va a git tal cual, sin riesgo.

En el pipeline:

- script: dotnet restore src/MyApp.API/MyApp.API.csproj --configfile pipelines/nuget.ci.config
  env:
    EJEMPLO_NUGET_KEY: $(EjemploNuGetKey)

¿Por qué un nuget.ci.config separado y no modificar el nuget.config raíz? Porque si en el raíz pones <packageSourceCredentials> con %EJEMPLO_NUGET_KEY% y un dev local hace dotnet restore sin la env var definida, NuGet expande la variable a literal %EJEMPLO_NUGET_KEY% y la auth falla con un mensaje confuso. Mantener dos archivos —uno raíz para dev local con su mecanismo de credenciales del SO, y uno pipelines/nuget.ci.config para CI— preserva los dos flujos sin colisión.


Lección 2: dotnet test no entiende --configfile

Push, segundo run, segundo error:

MSBUILD : error MSB1001: Unknown switch.
    Full command line: '...MSBuild.dll ... tests/MyApp.Tests/MyApp.Tests.csproj --configfile pipelines/nuget.ci.config ...'
Switches appended by response files:
Switch: --configfile

Aquí el copy-paste de la solución anterior no llegó. La intuición razonable es: si dotnet restore --configfile funciona, dotnet test --configfile también — al fin y al cabo dotnet test hace un restore implícito.

La intuición es razonable y está mal.

dotnet test invoca MSBuild internamente y los argumentos desconocidos los pasa crudos al MSBuild subyacente. --configfile es un switch que sólo entiende el comando dotnet restore (parseado en el CLI antes de delegar). MSBuild ve --configfile y dice «no sé qué es esto» → MSB1001.

La forma correcta para dotnet test (y para dotnet build, dotnet publish, cualquier comando que delegue a MSBuild) es la propiedad MSBuild equivalente:

- script: |
    dotnet test tests/MyApp.Tests/MyApp.Tests.csproj \
      -c Release \
      -p:RestoreConfigFile=pipelines/nuget.ci.config \
      --logger trx
  env:
    EJEMPLO_NUGET_KEY: $(EjemploNuGetKey)

-p:RestoreConfigFile=... es propiedad MSBuild estándar que el target Restore lee. Funciona en test, build y publish. La diferencia con --configfile no es estilística: es que pertenecen a parsers distintos.

Es de las cosas que parecen «documentación oficial seguro la cuenta» pero rara vez la cuenta lado a lado. Una vez quemado, la regla mental es simple:

Para herramientas wrapper alrededor de MSBuild, los flags propios (-c, --logger, --no-restore) siguen su gramática. Cualquier configuración fina del restore va por propiedad MSBuild con -p:.


Lección 3: los tests timing-sensitive saltan en hardware más lento

Tercer run, los YAML ya pasan el restore, los tests se ejecutan, y justo cuando creía que la migración estaba cerrada:

Failed!  - Failed: 2, Passed: 426, Skipped: 0, Total: 428
  Failed: PhotoNoCaption_ThenSeparateText_FlushesAsSingleBatch
  Failed: B36_PhotoNoCaption_ThenSeparateText_FlowEngineExecutedOnce

Dos tests rojos. En local pasaban. ¿Sospechoso? Mucho. Mirando los tests, la trampa es evidente:

// Pseudocódigo simplificado del test que fallaba
var debouncer = new MessageDebouncer(window: TimeSpan.FromMilliseconds(200));

// Encola un mensaje "tipo A"
var ownerTask = debouncer.EnqueueAsync(messageA);

// Espera "un poco" — menos que la ventana del debouncer
await Task.Delay(40);

// Encola un mensaje "tipo B" que debe unirse al batch del A
var followUp = await debouncer.EnqueueAsync(messageB);

// Aserción: A y B forman un único batch consolidado
await ownerTask;
Assert.Equal(2, owner.Batch.Count);

La lógica del test:

  • Ventana del debouncer: 200 ms.
  • Gap entre encolar A y encolar B: 40 ms.
  • Margen disponible: 160 ms.

Si el thread pool tarda más de 160 ms en procesar el Task.Delay(40) y devolver el control al test, el batch del primer mensaje ya se flusheó antes de que llegue el segundo. Eso no pasa nunca en una VM de Microsoft-hosted con CPU dedicada. Pasa habitualmente en un agent corriendo en Docker dentro de un NAS que comparte CPU con otros servicios.

Estos son tests flaky por diseño: dependen de wall-clock real para algo que la lógica de producción modela como «una ventana de tiempo». El bug no es del código: es de la suite, que asume que Task.Delay(40) significa exactamente 40 ms cuando puede significar 200 ms en un contenedor con CPU saturada. En hardware dedicado el test es estable, en hardware compartido es lotería.

El parche fácil y la solución correcta

Parche fácil: ampliar las ventanas (200 ms → 1000 ms, gap 40 ms → 200 ms) manteniendo el ratio. Funciona, no toca producción, lo aplica un trainee en 5 minutos. Pero no es solución — es diferir el problema. Cuando el NAS tenga más carga, el ratio se romperá igual y los tests volverán a fallar. Y mientras tanto, los tests son más lentos.

Solución correcta: hacer que el debouncer no dependa de Task.Delay real. Para eso .NET 8 introdujo el tipo abstracto TimeProvider, que sustituye los DateTime.UtcNow y Task.Delay puros por una abstracción mockeable. En producción se usa TimeProvider.System (idéntico al wall-clock). En tests se usa FakeTimeProvider (del paquete Microsoft.Extensions.TimeProvider.Testing), que no avanza solo: el tiempo lo controla el test con time.Advance(...).

El cambio en producción es trivial:

public sealed class MessageDebouncer
{
    private readonly TimeProvider _timeProvider;

    public MessageDebouncer(
        ILogger<MessageDebouncer> logger,
        TimeSpan? delay = null,
        TimeProvider? timeProvider = null)
    {
        _delay = delay ?? TimeSpan.FromMilliseconds(3000);
        _timeProvider = timeProvider ?? TimeProvider.System; // default: wall-clock real
    }

    private async Task FlushAfterDelayAsync(CancellationToken ct)
    {
        // antes: await Task.Delay(_delay, ct);
        // después:
        await Task.Delay(_delay, _timeProvider, ct);
    }
}

Task.Delay(TimeSpan, TimeProvider, CancellationToken) es sobrecarga nativa de .NET 8+, sin paquete extra en producción. El comportamiento por defecto (con TimeProvider.System) es idéntico al Task.Delay(TimeSpan, CancellationToken) original. Cero impacto en runtime real.

El test se vuelve determinista:

var time = new FakeTimeProvider();
var debouncer = new MessageDebouncer(window: TimeSpan.FromMilliseconds(200), timeProvider: time);

var ownerTask = debouncer.EnqueueAsync(messageA);
// (ya no hay Task.Delay real entre llegadas)
var followUp = await debouncer.EnqueueAsync(messageB);

// Ceder al thread pool para que el Task.Delay del debouncer se registre
// con el FakeTimeProvider antes de avanzarlo:
await Task.Delay(20);

// Avanzar el reloj fake más allá de la ventana del debouncer
time.Advance(TimeSpan.FromMilliseconds(250));

await ownerTask;
Assert.Equal(2, owner.Batch.Count);

Resultados antes/después:

MétricaTests con Task.Delay realTests con FakeTimeProvider
Duración por test200–2000 ms30–80 ms
Determinismo en hostedOKOK
Determinismo en self-hosted bajo cargaFlakyOK
Suite completa (428 tests)depende del CI502 ms

Es la diferencia entre «un test que pasa siempre que tengas suerte» y «un test que verifica un invariante de la lógica, sin contaminación del entorno».

Nota de implementación: FakeTimeProvider con Task.Run interno requiere un pequeño settle (await Task.Delay(20) real, no del provider) para dar al thread pool tiempo de registrar el Task.Delay(time, ...) antes de avanzar el reloj. Es la única concesión al wall-clock — no afecta a la lógica del test, sólo cede el scheduler.


Lección 4: lo que la nube te oculta

La meta-lección de los tres puntos anteriores: un agent Microsoft-hosted te da garantías de entorno que enmascaran problemas latentes:

Síntoma latentePor qué no salía en hosted
Auth de feed privadoSi el agent siempre arranca limpio, la primera vez que el restore tira del feed comercial es la primera vez que el problema sale
dotnet test --configfile MSB1001Sólo lo ves cuando tienes razón para usar --configfile (que en hosted nunca habías necesitado porque el feed era anónimo)
Tests flaky por timingCPU dedicada, sin contención con vecinos. Tu Task.Delay(40) realmente espera ~40 ms con jitter mínimo

No son bugs introducidos por la migración: son bugs preexistentes que la abundancia de recursos del hosted disimulaba. Migrar a self-hosted es, entre otras cosas, un test de robustez involuntario del código y de la suite de tests.


¿Cuándo merece la pena pasarse a self-hosted?

Después de pagar el precio de las cuatro lecciones, mi heurística:

Sí, conviene si:

  • Ya tienes una máquina infrautilizada (NAS, servidor de oficina, VM idle) con Docker.
  • Tu pipeline necesita acceso a recursos internos de tu red (BD, servicios) sin tunneling.
  • Te molesta el grant pendiente de Microsoft y prefieres autonomía.
  • Estás dispuesto a invertir media jornada en endurecer el código a un entorno menos generoso (saca a la luz problemas reales que tarde o temprano salen).

No, mejor pagar Microsoft-hosted parallelism si:

  • No tienes infra de servidor estable.
  • Tu pipeline depende de tools preinstaladas en imágenes hosted que no quieres mantener tú (Android SDKs, Xcode, etc.).
  • Tu equipo no tiene tiempo para mantener el host del agent (actualizaciones, PAT renovals, reinicios tras cortes de red).

En cualquier caso, el primer run en self-hosted es un saneamiento gratuito: si tu pipeline pasa allí, también pasará en cualquier sitio. Si falla, los problemas que te encuentres ya estaban ahí — sólo que eran invisibles.


Apéndice: checklist de migración

Si vas a hacer este movimiento mañana, los puntos críticos:

  • [ ] Imagen del agent con todas las herramientas que el pipeline usa (dotnet sdk, azure-cli si tocas Azure, docker, git, jq, etc.). Lo que falta sale como command not found el día del primer run.
  • [ ] Volúmenes persistentes para caché de NuGet/npm en el agent. Sin esto, cada run descarga todo de cero y pierdes la ventaja de tener un host estable.
  • [ ] Capabilities declaradas en el portal del agent + demands en el pipeline. La duplicación parece redundante hasta que tienes dos agents y uno difiere.
  • [ ] Variable group con secretos enlazados desde Key Vault (o equivalente). No metas secretos en el repo aunque sea temporal.
  • [ ] Approval manual en environments para deploys productivos durante las primeras semanas. Cuando lleves 5-10 deploys verdes, lo quitas.
  • [ ] Una pasada por los tests [Fact] que usan Task.Delay, DateTime.UtcNow, Stopwatch. Cualquiera de ellos es flaky candidate en hardware compartido.
  • [ ] Plan de rotación del PAT del agent (caducan al año por defecto). Programarlo en el calendario hoy, no la semana que caduque.

El refactor completo nos llevó algo más de lo planeado precisamente por las tres lecciones intermedias. La cuarta —la meta— es la que justifica el esfuerzo: un pipeline más robusto, sobre un código probado en condiciones más cercanas a la realidad. La nube es muy cómoda; pero a veces es demasiado cómoda.

azure_devops_pipeline sobre 2026

    Deja una respuesta

    Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *


    El periodo de verificación de reCAPTCHA ha caducado. Por favor, recarga la página.