tiempo de lectura : 18 minutes

¿Por qué escribir comandos Docker a mano cuando se puede encapsular todo en tareas Gradle? Así es como industrialicé mi configuración dual-Ollama Pro sin scripts de shell externos. Arranque, parada, identidad, inicio de sesión, pull — todo pasa por`./gradlew`.

Introducción

En el artículo anterior mostré cómo hacer que dos cuentas Ollama Pro convivan en la misma máquina al aislar la segunda instancia en un contenedor Docker. El montaje funciona, pero la gestión diaria resulta rápidamente engorrosa: recordar el nombre del contenedor, volver a ejecutar los comandos correctos.docker exec, checker qué puerto está activo…​

Conoces ese momento en el que tienes 14 terminales abiertos, que estás tecleando`docker exec ollama-instance-b ollama list`para la cuarta vez, y que te digas « debe haber una manera más limpia » ?

Este método es Gradle.

No solo para construir código. Gradle como un director de orquesta de infraestructura local. Tareas que proveen, verifican, prueban y limpian. Todo esto versionado en un`build.gradle.kts`que vive al lado del`docker-compose.yml`.

architecture gradle ollama

¿Por qué Gradle para Infra?

La pregunta es legítima. Gradle es una herramienta de compilación. Normalmente compila, prueba y empaqueta. No lanza contenedores Docker.

Excepto que Gradle también es un motor de grafo de dependencias — y ahí es donde se vuelve interesante para nuestro caso.

Queremos :

  1. Que el contenedor se inicie antes de un sign-in

  2. Que se verifique la identidad antes de un pull de modelo

  3. Que una prueba de API falle silenciosamente si el contenedor está down

Del grafo de dependencias puro. Gradle está diseñado para eso. El DSL de Kotlin hace que escribir las tareas sea fluido, y el`doLast`permite ejecutar comandos del sistema como si fuera código de aplicación.

Y sobre todo: sin bashrc que mantener, sin scripts que estén tirados por ahí`~/bin`, no hay Makefile al lado del docker-compose. Todo vive en un solo archivo, en el mismo lugar que el código del proyecto.

El build.gradle.kts completo

Este es el archivo que finalmente escribí. Hace todo, desde el aprovisionamiento del volumen hasta el curl de verificación, pasando por el inicio de sesión interactivo.

import java.io.ByteArrayOutputStream

plugins {
    base
}

// -------------------------------------------------------------------------
// Configuration
// -------------------------------------------------------------------------
val instanceName   = "ollama-instance-b"
val instancePort   = 11435
val instanceImage  = "ollama/ollama:0.20.2"
val dataDir        = file("${System.getProperty("user.home")}/ollama-b-data")
val nativePort     = 11434

// Modèles à gérer — cloud Pro + un modèle local gratuit pour les tests
val proModels      = listOf("deepseek-v4-pro:cloud", "gemma4:31b-cloud")
val freeModels     = listOf("qwen3:0.6b")

// -------------------------------------------------------------------------
// Tâche préparatoire — création du volume si absent
// -------------------------------------------------------------------------
val prepareVolume by tasks.registering {
    group       = "ollama"
    description = "Crée le répertoire de volume pour l'instance B si absent"
    doLast {
        if (!dataDir.exists()) {
            dataDir.mkdirs()
            println("Volume créé : ${dataDir.absolutePath}")
        } else {
            println("Volume existant : ${dataDir.absolutePath}")
        }
    }
}

// -------------------------------------------------------------------------
// Démarrage du conteneur Docker
// -------------------------------------------------------------------------
val startOllamaB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Lance le conteneur Docker pour l'instance Ollama B"
    dependsOn(prepareVolume)

    commandLine(
        "docker", "run", "-d",
        "--name", instanceName,
        "-p", "$instancePort:11434",
        "-v", "${dataDir.absolutePath}:/root/.ollama",
        "-e", "OLLAMA_HOST=0.0.0.0",
        "--restart", "always",
        instanceImage
    )
    isIgnoreExitValue = true

    doLast {
        val alreadyRunning = executionResult.get().exitValue != 0
        if (alreadyRunning) {
            logger.lifecycle("Le conteneur '$instanceName' existe déjà — tentative de démarrage")
        } else {
            logger.lifecycle("Conteneur '$instanceName' lancé")
        }
    }
}

// -------------------------------------------------------------------------
// Arrêt du conteneur
// -------------------------------------------------------------------------
val stopOllamaB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Arrête le conteneur Docker de l'instance B"

    commandLine("docker", "stop", instanceName)
    isIgnoreExitValue = true

    doLast {
        logger.lifecycle("Conteneur '$instanceName' arrêté")
    }
}

// -------------------------------------------------------------------------
// Affichage de la clé publique (Device Key)
// -------------------------------------------------------------------------
val checkIdentity by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Affiche la clé publique SSH de l'instance B"
    dependsOn(startOllamaB)

    commandLine("docker", "exec", instanceName, "cat", "/root/.ollama/id_ed25519.pub")
    standardOutput = ByteArrayOutputStream()

    doLast {
        val pubKey = standardOutput.toString().trim()
        println("=".repeat(60))
        println("Device Key (Instance B)")
        println("=".repeat(60))
        println(pubKey)
        println("=".repeat(60))
        println("→ Enregistre cette clé sur https://ollama.com/settings/keys")
        println("→ Puis exécute : ./gradlew signInB")
        println("=".repeat(60))
    }
}

// -------------------------------------------------------------------------
// Sign-in interactif (Ouvrira le navigateur)
// -------------------------------------------------------------------------
val signInB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Lance l'authentification interactive Ollama pour le compte B"
    dependsOn(startOllamaB)

    standardInput  = System.`in`
    standardOutput = System.out
    errorOutput    = System.err

    commandLine("docker", "exec", "-it", instanceName, "ollama", "signin")

    doFirst {
        logger.lifecycle("Authentification pour le Compte Pro B...")
        logger.lifecycle("Ouvre ton navigateur et connecte-toi avec l'email du compte B")
    }
}

// -------------------------------------------------------------------------
// Pull d'un modèle (paramétrable via propriété Gradle)
// -------------------------------------------------------------------------
val pullModelB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Pull un modèle sur l'instance B. Usage : ./gradlew pullModelB -Pmodel=nom:tag"
    dependsOn(startOllamaB)

    val modelName = project.findProperty("model") as? String ?: "qwen3:0.6b"

    commandLine("docker", "exec", instanceName, "ollama", "pull", modelName)

    doFirst {
        logger.lifecycle("Pull du modèle '$modelName' sur l'instance B...")
    }

    doLast {
        logger.lifecycle("Modèle '$modelName' pullé avec succès")
    }
}

// -------------------------------------------------------------------------
// Pull de tous les modèles Pro (batch)
// -------------------------------------------------------------------------
val pullAllProModels by tasks.registering {
    group       = "ollama"
    description = "Pull tous les modèles cloud Pro sur l'instance B"
    dependsOn(startOllamaB)

    doLast {
        proModels.forEach { model ->
            logger.lifecycle("→ Pull $model...")
            val process = ProcessBuilder("docker", "exec", instanceName, "ollama", "pull", model)
                .inheritIO()
                .start()
            process.waitFor()
        }
        logger.lifecycle("Tous les modèles Pro pullés")
    }
}

// -------------------------------------------------------------------------
// Pull de tous les modèles gratuits (batch / première install)
// -------------------------------------------------------------------------
val pullAllFreeModels by tasks.registering {
    group       = "ollama"
    description = "Pull tous les modèles locaux gratuits sur l'instance B"
    dependsOn(startOllamaB)

    doLast {
        freeModels.forEach { model ->
            logger.lifecycle("→ Pull $model...")
            val process = ProcessBuilder("docker", "exec", instanceName, "ollama", "pull", model)
                .inheritIO()
                .start()
            process.waitFor()
        }
        logger.lifecycle("Tous les modèles gratuits pullés")
    }
}

// -------------------------------------------------------------------------
// Liste des modèles disponibles sur l'instance B
// -------------------------------------------------------------------------
val listModelsB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Liste les modèles disponibles sur l'instance B"
    dependsOn(startOllamaB)

    commandLine("docker", "exec", instanceName, "ollama", "list")
}

// -------------------------------------------------------------------------
// Health check — test API sur les deux instances
// -------------------------------------------------------------------------
val testOllamaB by tasks.registering {
    group       = "ollama"
    description = "Teste la connectivité API de l'instance B avec un simple chat"
    dependsOn(startOllamaB)

    doLast {
        val model = project.findProperty("model") as? String ?: "qwen3:0.6b"
        logger.lifecycle("Test de l'instance B avec le modèle '$model'...")

        val payload = """
            {
              "model": "$model",
              "messages": [{"role": "user", "content": "Réponds uniquement par OK"}],
              "max_tokens": 5
            }
        """.trimIndent()

        val process = ProcessBuilder(
            "curl", "-s", "http://localhost:$instancePort/v1/chat/completions",
            "-H", "Content-Type: application/json",
            "-d", payload
        ).start()

        val response = process.inputStream.bufferedReader().readText()
        process.waitFor()

        if (response.contains("\"id\":\"chatcmpl") || response.contains("\"choices\"")) {
            println("✓ Instance B OK — port $instancePort répond")
            println("  Response: ${response.take(120)}...")
        } else {
            println("✗ Instance B INACTIVE — vérifie le conteneur avec './gradlew statusOllamaAll'")
            println("  Raw: $response")
        }
    }
}

// -------------------------------------------------------------------------
// Health check sur les deux instances simultanément
// -------------------------------------------------------------------------
val statusOllamaAll by tasks.registering {
    group       = "ollama"
    description = "Vérifie le statut des deux instances Ollama (native + Docker)"

    doLast {
        // Instance native
        runCatching {
            val process = ProcessBuilder(
                "curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
                "http://localhost:$nativePort/api/tags"
            ).start()
            val code = process.inputStream.bufferedReader().readText().trim()
            process.waitFor()
            println(if (code == "200") "✓ Instance Native   — port $nativePort OK (HTTP $code)"
                    else "✗ Instance Native — port $nativePort (HTTP $code)")
        }.getOrElse {
            println("✗ Instance Native — injoignable sur le port $nativePort")
        }

        // Instance Docker
        runCatching {
            val process = ProcessBuilder(
                "curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
                "http://localhost:$instancePort/api/tags"
            ).start()
            val code = process.inputStream.bufferedReader().readText().trim()
            process.waitFor()
            println(if (code == "200") "✓ Instance Docker B — port $instancePort OK (HTTP $code)"
                    else "✗ Instance Docker B — port $instancePort (HTTP $code)")
        }.getOrElse {
            println("✗ Instance Docker B — injoignable sur le port $instancePort")
        }

        // Info conteneur
        runCatching {
            val process = ProcessBuilder("docker", "ps", "--filter", "name=$instanceName",
                "--format", "table {{.Names}}\\t{{.Status}}\\t{{.Ports}}").start()
            val status = process.inputStream.bufferedReader().readText()
            process.waitFor()
            println()
            println("Conteneur Docker :")
            println(status)
        }
    }
}

// -------------------------------------------------------------------------
// Nettoyage complet
// -------------------------------------------------------------------------
val cleanOllamaB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Arrête et supprime le conteneur Docker de l'instance B"
    dependsOn(stopOllamaB)

    commandLine("docker", "rm", instanceName)
    isIgnoreExitValue = true

    doLast {
        logger.lifecycle("Conteneur '$instanceName' supprimé")
        logger.lifecycle("Le volume ${dataDir.absolutePath} est conservé (identité persistante)")
    }
}

// -------------------------------------------------------------------------
// Affichage des instructions
// -------------------------------------------------------------------------
val helpOllama by tasks.registering {
    group       = "ollama"
    description = "Affiche l'aide des tâches Ollama disponibles"

    doLast {
        println("""
        ╔══════════════════════════════════════════════════════════════╗
        ║         Tâches Gradle — Pilotage Dual Ollama Pro            ║
        ╠══════════════════════════════════════════════════════════════╣
        ║  ./gradlew startOllamaB      Démarrer l'instance Docker B   ║
        ║  ./gradlew stopOllamaB       Arrêter l'instance Docker B    ║
        ║  ./gradlew checkIdentity     Afficher la clé SSH publique   ║
        ║  ./gradlew signInB           Sign-in interactif compte B    ║
        ║  ./gradlew pullModelB        Pull un modèle (-Pmodel=...)   ║
        ║  ./gradlew pullAllProModels  Pull tous les modèles Pro      ║
        ║  ./gradlew pullAllFreeModels Pull tous les modèles gratuits ║
        ║  ./gradlew listModelsB       Lister les modèles instance B  ║
        ║  ./gradlew testOllamaB       Test API avec un chat simple   ║
        ║  ./gradlew statusOllamaAll   Santé des 2 instances          ║
        ║  ./gradlew cleanOllamaB      Supprimer le conteneur         ║
        ║  ./gradlew helpOllama        Cette aide                     ║
        ╚══════════════════════════════════════════════════════════════╝
        """.trimIndent())
    }
}

He aquí. Doce tareas, un punto de entrada único y cero fricción.

Anatomía de las Tareas Clave

startOllamaB` — El Lanzador

Nada de hechicero: un`docker run -d`con los parámetros correctos. El truco está en`isIgnoreExitValue = true`.

isIgnoreExitValue = true

Si el contenedor ya existe, el comando`docker run`falle (código de salida ≠ 0). Sin esta propiedad, Gradle detendría toda la construcción en error. Aquí, el fallo es silencioso — y el`doLast`detecta si era un 'ya existente' o un verdadero error.

Le `doLast`es crucial : se ejecuta después de la ejecución del comando, lo que permite inspeccionar`executionResult.get().exitValue`y registrar un mensaje apropiado.

signInB` — El Interactivo

Es la única tarea que requiere una interacción humana. Gradle transmite`System.in`al proceso para que el prompt`ollama signin`pueda mostrarse normalmente:

standardInput  = System.`in`
standardOutput = System.out
errorOutput    = System.err

Sin eso, el inicio de sesión se bloquearía porque stdin estaría cerrado y nunca verías el prompt.

pullModelB` — Lo configurable

Una tarea genérica que acepta un parámetro vía`-P` :

./gradlew pullModelB -Pmodel=deepseek-v4-pro:cloud
./gradlew pullModelB                           # fallback : qwen3:0.6b

El fallback es importante :`project.findProperty("model") as? String ?: "qwen3:0.6b"`garantiza que la tarea no falle si se olvida el parámetro.

statusOllamaAll` — El Diagnóstico

Dos`curl`en salud en los puertos`11434` et 11435, con un fallback en`runCatching`para no fallar si una de las instancias está caída. La salida está estructurada :

✓ Instance Native   — port 11434 OK (HTTP 200)
✓ Instance Docker B — port 11435 OK (HTTP 200)

Conteneur Docker :
NAMES               STATUS         PORTS
ollama-instance-b   Up 3 hours     0.0.0.0:11435->11434/tcp

Una mirada, y sabes exactamente qué funciona y qué falla.

cleanOllamaB` — La Limpieza que Preserva la Identidad

commandLine("docker", "rm", instanceName)
isIgnoreExitValue = true

El contenedor se elimina, pero el volumen`~/ollama-b-data`esconservado. La identidad SSH sobrevive a la limpieza. Al próximo`startOllamaB`, las mismas llaves se reutilizarán — no es necesario volver a iniciar sesión ni volver a registrar la clave del dispositivo.

cleanOllamaB`no toques el volumen. Si quieres borrar la identidad B (y volver a empezar desde cero), tienes que eliminar~/ollama-b-data`manualmente. Es voluntario — un`clean`no debería destruir credenciales.

Flujo de trabajo completo — De cero a dos sesiones

Esta es la secuencia ideal para alguien que llega al proyecto y quiere poner todo en marcha sin abrir documentación :

# 1. Premier contact — que faire ?
$ ./gradlew helpOllama

# 2. On démarre l'instance B
$ ./gradlew startOllamaB

# 3. On récupère la Device Key et on l'enregistre sur ollama.com
$ ./gradlew checkIdentity
# → copier-coller de la clé sur https://ollama.com/settings/keys

# 4. Authentification interactive
$ ./gradlew signInB

# 5. Pull d'un petit modèle gratuit pour tester la plomberie
$ ./gradlew pullModelB

# 6. Test API
$ ./gradlew testOllamaB

# 7. Si tout est vert, pull des modèles Pro
$ ./gradlew pullAllProModels

# 8. Vérification finale des deux instances
$ ./gradlew statusOllamaAll

Menos de dos minutos, todo está provisionado. No hay README que leer, no hay variables de entorno que configurar. La tarea`helpOllama`hace las veces de documentación integrada.

Failed to generate image: PlantUML preprocessing failed: [From <input> (line 6) ]

@startuml




...
... ( skipping 126 lines )
...
skinparam StereotypeE {
  BackgroundColor white
  BorderColor black
}
skinparam StereotypeI {
  BackgroundColor white
  BorderColor black
}
skinparam StereotypeN {
  BackgroundColor white
  BorderColor black
}
skinparam UseCaseStereoType {
  FontColor black
  FontName Verdana
}
skinparam DefaultFontSize 11

actor Développeur as Dev
participant "Gradle
^^^^^
 Syntax Error? (Assumed diagram type: sequence)

@startuml
!theme plain
skinparam DefaultFontSize 11

actor Développeur as Dev
participant "Gradle
(build.gradle.kts)" as Gradle
participant "Docker\nDaemon" as Docker
participant "Ollama
Instancia B" as OllamaB
participant "Ollama
Nube" as Cloud

== Provisionnement ==
Dev -> Gradle : helpOllama
Gradle --> Dev : Aide et instructions

Dev -> Gradle : startOllamaB
Gradle -> Docker : docker run -d ollama/ollama:0.20.2
Docker -> OllamaB : Génération clés SSH (id_ed25519)
Docker --> Gradle : conteneur lancé
Gradle --> Dev : OK

Dev -> Gradle : checkIdentity
Gradle -> Docker : cat id_ed25519.pub
Gradle --> Dev : Clé publique SSH

Dev -> Cloud : Enregistrement clé SSH\n(sur ollama.com/settings/keys)

== Authentification ==
Dev -> Gradle : signInB
Gradle -> Docker : ollama signin (interactif)
Dev <-> Cloud : Login navigateur (compte B)
Docker -> Cloud : Échange token ⇔ Device Key
Gradle --> Dev : Authentifié

== Vérification ==
Dev -> Gradle : pullModelB
Gradle -> Docker : ollama pull qwen3:0.6b
Gradle --> Dev : Modèle pullé

Dev -> Gradle : testOllamaB
Gradle -> Docker : curl /v1/chat/completions
Gradle --> Dev : ✓ Instance B OK

== Production ==
Dev -> Gradle : pullAllProModels
Gradle -> Docker : ollama pull deepseek-v4-pro:cloud
Gradle -> Docker : ollama pull gemma4:31b-cloud
Gradle --> Dev : Modèles Pro pullés

Dev -> Gradle : statusOllamaAll
Gradle --> Dev : ✓ Native ✓ Docker B
@enduml

¿Por qué no hacer un verdadero plugin Gradle ?

Pregunta legítima. Extraer esto`build.gradle.kts`Convertirlo en un plugin Gradle autónomo tendría ventajas: reutilización entre proyectos, configuración declarativa, extensión del DSL.

Pero por ahora, no tiene sentido. Este build no tiene una lógica de negocio compleja — es un envoltorio alrededor de comandos Docker y curl. La capa adicional de un plugin Gradle completo (con extensión, pruebas unitarias, publicación) sería desproporcionada para 12 tareas de utilidad.

Le `build.gradle.kts`inline vive en el proyecto que lo necesita. Es trivial copiarlo en otro sitio. Si algún día tengo tres proyectos que dependen de él, lo convertiré en un plugin. Por ahora, KISS.

El día en que tienes tres`build.gradle.kts`Idénticos en tres proyectos, esa es la señal de que hay que extraer un plugin. Antes de eso, la duplicación es menos costosa que la abstracción. Sabiduría de desarrollador.

Lecciones aprendidas

  1. Gradle no es solo una build tool— El motor de grafo de dependencias lo convierte en un orquestador de infraestructura formidable.doLast, isIgnoreExitValue, dependsOn: estas tres primitivas son suficientes para gestionar un contenedor Docker con la misma fiabilidad que una canalización CI.

  2. isIgnoreExitValue` es tu amigo— En un mundo ideal,docker run`en un contenedor ya existente devolvería una advertencia en lugar de un código de salida de error. En el mundo real, tú pones`isIgnoreExitValue = true`y tú gestionas el diagnóstico en`doLast.

  3. Las tareas parametrables eliminan la ambigüedad — Un pullModelB`genérico con-Pmodel=…​`reemplaza N tareas especializadas. Una sola tarea, una sola documentación, un solo comportamiento.

  4. runCatching` para los health checks— Cuando pruebas N endpoints, el fallo del primero no debe impedir probar los otros.runCatching+`getOrElse`por endpoint, y el diagnóstico sigue siendo completo incluso en caso de falla parcial.

  5. La tarea helpOllama como documentación ejecutable — Un `println()`bien formateado sustituye a un README de tres párrafos. Está auto-documentado, siempre actualizado y accesible sin salir de la terminal.

Conclusión

Pasamos de comandos Docker a teclear a mano a un flujo de trabajo Gradle completo, versionado y reutilizable. Doce tareas cubren todo el ciclo de vida: provisión, identidad, sign-in, pull, prueba, diagnóstico, limpieza.

El combo docker-compose + build.gradle.kts + opencode.json forma una trinidad limpia: Docker para el aislamiento, Gradle para la orquestación, OpenCode para el consumo. Cada capa tiene su responsabilidad, y ninguna se desborda sobre la otra.

¿Próximo paso? Automatizar el ciclo de vida en CI para que las instancias de Ollama estén disponibles en los entornos de desarrollo efímeros. Pero eso es otro artículo.

Articles connexes