Huisgemaakte Gradle-plugin voor het parallel uitvoeren van twee Ollama Pro‑instanties
Gepubliceerd op 08 May 2026
Waarom Docker-commando’s handmatig intypen als je alles kunt encapsuleren in Gradle-taken? Dit is hoe ik mijn dual-Ollama Pro-config heb geïndustrialiseerd zonder externe shellscripts. Opstarten, stoppen, identiteit, sign-in, pull — alles gaat via`./gradlew`.
Inleiding
In het vorige artikel heb ik laten zien hoe je twee Ollama Pro-accounts op dezelfde machine kunt laten samenwerken door de tweede instantie te isoleren in een Docker-container. De opstelling werkt, maar het dagelijks beheer wordt al snel vervelend: je moet de naam van het container onthouden en de juiste opdrachten opnieuw uitvoeren.docker exec, controleer welke poort actief is…
Je kent dat moment waarop je 14 terminals open hebt, waar je typt`docker exec ollama-instance-b ollama list`voor de vierde keer, en je denkt « er moet wel een nettere manier zijn » ?
Dit is Gradle.
Niet alleen voor het bouwen van code. Gradle als een dirigent van de lokale infrastructuur. Taken die provisioneren, controleren, testen en opruimen. Alles versiebeheerd in een`build.gradle.kts`die naast de`docker-compose.yml`.
@startuml
!theme plain
skinparam handwritten false
skinparam shadowing false
skinparam BoxPadding 8
skinparam DefaultFontSize 11
skinparam ArrowThickness 1.5
left to right direction
actor "Ontwikkelaar" as Dev
package "build.gradle.kts" as GradleFile {
usecase "startOllamaB" as Start
usecase "stopOllamaB" as Stop
usecase "checkIdentity" as Check
usecase "signInB" as SignIn
usecase "pullModelB" as Pull
usecase "testOllamaB" as Test
usecase "statusOllamaAll" as Status
usecase "cleanOllamaB" as Clean
}
package "Docker Daemon" as DockerDaemon {
node "ollama-instance-b" as ContainerB {
component ":11435" as Port
database "~/ollama-b-data" as Volume
}
}
package "Ollama Native" as Native {
component ":11434" as NatPort
}
Dev --> Start
Dev --> SignIn
Dev --> Pull
Dev --> Test
Dev --> Status
Dev --> Check
Dev --> Stop
Dev --> Clean
Start ..> ContainerB : docker run -d
Stop ..> ContainerB : docker stop
Check ..> Volume : cat id_ed25519.pub
SignIn ..> ContainerB : ollama signin
Pull ..> ContainerB : ollama pull
Test ..> ContainerB : curl /v1/chat/completions
Status ..> ContainerB : docker ps
Status ..> Native : curl :11434
Clean ..> ContainerB : docker rm -f
note bottom of GradleFile
Geen extern bash-script.
Alles staat in de `build.gradle.kts`,
geversioneerd met het project.
end note
@enduml
Waarom Gradle voor infra?
De vraag is legitiem. Gradle is een buildtool. Normaal gesproken compileert hij, test hij en verpakt hij. Hij lanceert geen Docker-containers.
Behalve dat Gradle ook een engine van een afhankelijkheidsgraaf is — en dat is waar het interessant wordt voor ons geval.
We willen :
-
Laat de container worden gestart vóór een sign-in
-
Zorg ervoor dat de identiteit wordt geverifieerd vóór een pull van een model
-
Als een API-test stilzwijgend mislukt wanneer de container down is
Van het pure afhankelijkheidsgraaf. Gradle is erop gebouwd. De Kotlin DSL maakt het schrijven van taken vloeiend, en het`doLast`Hiermee kunt u systeemopdrachten uitvoeren alsof het applicatiecode is.
En vooral: geen bashrc om te onderhouden, geen scripts die rondslingeren in`~/bin`, geen Makefile naast docker-compose. Alles staat in één bestand, op dezelfde plek als de projectcode.
Het volledige build.gradle.kts
Hier is het bestand dat ik uiteindelijk heb geschreven. Het doet alles, van de provisionering van het volume tot de controle-curl, inclusief interactief inloggen.
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())
}
}
Nou. Twaalf taken, één enkel ingangspunt, en geen wrijving.
Anatomie van de sleuteltaken
startOllamaB` — De Lanceerder
Niets ingewikkelds: een`docker run -d`met de juiste parameters. De truc zit in`isIgnoreExitValue = true`.
isIgnoreExitValue = true
Als de container al bestaat, de opdracht`docker run`faalt (exit code ≠ 0). Zonder deze eigenschap zou Gradle de hele build afbreken met een fout. Hier is het falen stilzwijgend — en het`doLast`détecteert of het een "al bestaand" of een echte fout was.
Le `doLast`is cruciaal: het draait na de uitvoering van de opdracht, wat inspectie mogelijk maakt`executionResult.get().exitValue`en een passend bericht te loggen.
signInB` — Interactief
Dit is de enige taak die menselijke interactie vereist. Gradle verzendt`System.in`aan het proces zodat de prompt`ollama signin`kan normaal worden weergegeven :
standardInput = System.`in`
standardOutput = System.out
errorOutput = System.err
Zonder dat zou de sign-in blokkeren omdat stdin gesloten zou zijn en je de prompt nooit zou zien.
pullModelB` — Het parameteriseerbaar
Een generieke taak die een parameter via`-P`:
./gradlew pullModelB -Pmodel=deepseek-v4-pro:cloud
./gradlew pullModelB # fallback : qwen3:0.6b
De fallback is belangrijk :`project.findProperty("model") as? String ?: "qwen3:0.6b"`Zorgt ervoor dat de taak niet ontploft als je de parameter vergeet.
statusOllamaAll` — de diagnose
Twee`curl`in gezondheid op de havens`11434` et 11435, met een fallback in`runCatching`om niet te crashen als een van de instanties down is. De uitvoer is gestructureerd :
✓ 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
Een blik, en je weet precies wat er draait en wat er mis is.
cleanOllamaB` — De reiniging die de identiteit behoudt
commandLine("docker", "rm", instanceName)
isIgnoreExitValue = true
De container is verwijderd, maar het volume`~/ollama-b-data`isgeconserveerd. De SSH-identiteit blijft behouden bij opschoning. Aan het volgende`startOllamaB`, dezelfde sleutels zullen worden hergebruikt — geen nood om opnieuw in te tekenen noch de Device Key opnieuw te registreren.
|
|
Volledige workflow — Van nul tot twee sessies
Hier is de ideale volgorde voor iemand die het project betreedt en alles wil instellen zonder documentatie te openen :
# 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
Minder dan twee minuten, alles is ingericht. Geen README om te lezen, geen omgevingsvariabelen om in te stellen. De taak`helpOllama`dient als geïntegreerde documentatie
@startuml !theme plain skinparam DefaultFontSize 11 actor Développeur as Dev participant "Gradle\n(build.gradle.kts)" as Gradle participant "Docker\nDaemon" as Docker participant "Ollama\nInstantie B" as OllamaB participant "Ollama\nWolk" as Cloud == Provisionnement == Dev -> Gradle : helpOllama Gradle --> Dev : Hulp en instructies Dev -> Gradle : startOllamaB Gradle -> Docker : docker run -d ollama/ollama:0.20.2 Docker -> OllamaB : Generatie sleutels SSH (id_ed25519) Docker --> Gradle : gelanceerde container Gradle --> Dev : OK Dev -> Gradle : checkIdentity Gradle -> Docker : cat id_ed25519.pub Gradle --> Dev : Openbare sleutel SSH Dev -> Cloud : Sleutelregistratie SSH\n(op ollama.com/settings/keys) == Authentification == Dev -> Gradle : signInB Gradle -> Docker : ollama aanmelden (interactief) Dev <-> Cloud : Inloggen browser (rekening B) Docker -> Cloud : Uitwisseling token ⇔ Device Key Gradle --> Dev : Geauthenticeerd == Vérification == Dev -> Gradle : pullModelB Gradle -> Docker : ollama pull qwen3:0.6b Gradle --> Dev : Getrokken model 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 : Getrokken Pro-modellen Dev -> Gradle : statusOllamaAll Gradle --> Dev : ✓ Oorspronkelijk ✓ Docker B @enduml
Waarom maak je er geen echt Gradle-plugin van?
Geldige vraag. Extraheer dit`build.gradle.kts`Een zelfstandige Gradle-plugin zou voordelen hebben: hergebruik tussen projecten, declaratieve configuratie, DSL-uitbreiding.
Maar voorlopig heeft dat geen zin. Deze build heeft geen complexe bedrijfslogica — het is een wrapper rond Docker- en curl-commando’s. Een extra laag rond een volledig Gradle-plugin (met extensie, unit tests, publicatie) zou onevenredig zijn voor 12 hulpprogramma’s.
Le `build.gradle.kts`inline zit in het project dat het nodig heeft. Het is triviaal om elders te kopiëren. Als er ooit drie projecten zijn die ervan afhankelijk zijn, zal ik er een plugin van maken. Voor nu, KISS.
|
De dag waarop je drie hebt`build.gradle.kts`identiek in drie projecten, dat is het signaal dat je een plugin moet extraheren. Voor die tijd is duplicatie minder kostbaar dan abstractie. Wijsheid van een ontwikkelaar. |
geleerde lessen
-
Gradle is niet alleen een buildtool— De afhankelijkheidsgrafmotor maakt het een vormidabel infrastructuurorchestrator.
doLast,isIgnoreExitValue, `dependsOn`Deze drie primitieven volstaan om een Docker-container te besturen met dezelfde betrouwbaarheid als een CI-pijplijn. -
isIgnoreExitValue` is jouw vriend— In een ideale wereld,
docker run`op een bestaande container zou een warning geven in plaats van een foutieve exit code. In de praktijk, je zet`isIgnoreExitValue = true`en je beheert de diagnose in`doLast. -
Instelbare taken doden de ambiguïteit — Un
pullModelB`generiek met-Pmodel=…`vervang N gespecialiseerde taken. Één enkele taak, één enkele doc, één enkel gedrag. -
runCatching` voor de health checks— Als je N endpoints test, mag de crash van het eerste niet verhinderen dat je de anderen test.
runCatching+`getOrElse`Per endpoint, en de diagnose blijft compleet, zelfs bij een gedeeltelijke storing. -
De taak
helpOllamaals uitvoerbare documentatie — Un `println()`goed opgemaakt vervangt een README van drie alinea’s. Het is zelfdocumenterend, altijd up-to-date, en toegankelijk zonder de terminal te verlaten.
Conclusie
Van het typen van Docker-opdrachten met de hand zijn we overgegaan naar een volledig Gradle-workflow, versiebeheerd en herbruikbaar. Twaalf taken bestrijken de hele levenscyclus: inrichting, identiteit, sign-in, pull, test, diagnose, opschoning.
De combo docker-compose + build.gradle.kts + opencode.json vormt een zuivere trinité: Docker voor isolatie, Gradle voor orchestratie, OpenCode voor consumptie. Elke laag heeft zijn verantwoordelijkheid, en geen enkele overlapt de ander.
Volgende stap? Automatiseer de levenscyclus in de CI zodat de Ollama-instanties beschikbaar zijn in de tijdelijke dev-omgevingen. Maar dat is een ander artikel.
bronnen
Artikel gepubliceerd op 2026-05-08