Lesezeit : 18 minutes

Warum Docker-Befehle von Hand eingeben, wenn man alles in Gradle-Tasks kapseln kann? So habe ich meine dual-Ollama-Pro-Konfiguration ohne externe Shell-Skripte industrialisiert. Start, Stopp, Identität, Sign-in, Pull — alles läuft über`./gradlew`.

Einleitung

Im vorherigen Artikel habe ich gezeigt, wie man zwei Ollama Pro‑Konten auf derselben Maschine nebeneinander betreiben kann, indem man die zweite Instanz in einem Docker‑Container isoliert. Die Einrichtung funktioniert, aber die tägliche Verwaltung wird schnell lästig: Man muss sich den Namen des Containers merken und die richtigen Befehle ausführen.docker exec, checker welcher Port ist aktiv…​

Du kennst diesen Moment, in dem du 14 Terminals geöffnet hast, die du eintippst.`docker exec ollama-instance-b ollama list`für das vierte Mal und du denkst dir: „Es muss doch einen saubereren Weg geben“ ?

Dieses Mittel ist Gradle.

Nicht nur zum Code bauen. Gradle wie ein Dirigent der lokalen Infrastruktur. Aufgaben, die bereitstellen, überprüfen, testen und aufräumen. Das Ganze versioniert in einem`build.gradle.kts`der neben dem wohnt`docker-compose.yml`.

architecture gradle ollama

Warum Gradle für die Infrastruktur?

Die Frage ist legitim. Gradle ist ein Build-Tool. Normalerweise kompiliert es, es testet, es paketiert. Es startet keine Docker-Container.

Ausser dass Gradle auch ein Dependency‑Graphen‑Engine ist — und das ist der Punkt, an dem es für unseren Fall interessant wird.

Wir wollen:

  1. Dass der Container vor einem Sign-in gestartet wird.

  2. Dass die Identität vor einem Pull des Modells überprüft wird

  3. Ein API-Test schlägt stillschweigend fehl, wenn der Container down ist

Von dem reinen Abhängigkeitsgraphen. Gradle ist darauf zugeschnitten. Das Kotlin-DSL macht das Schreiben der Aufgaben flüssig, und das`doLast`ermöglicht das Ausführen von Systembefehlen, als wäre es Anwendungscode.

Und vor allem: kein bashrc zu warten, keine herumliegenden Skripte in`~/bin`, kein Makefile neben dem docker-compose. Alles lebt in einer einzigen Datei, an derselben Stelle wie der Projektcode.

Der vollständige build.gradle.kts

Hier ist die Datei, die ich schließlich geschrieben habe. Sie erledigt alles, von der Bereitstellung des Volumes bis zum Curl zur Überprüfung, einschließlich des interaktiven Anmeldens.

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())
    }
}

Da. Zwölf Aufgaben, ein einzelner Einstiegspunkt, und null Reibung.

Anatomie der Schlüsselaufgaben

startOllamaB` — Der Launcher

Nichts Hexenwerk: ein`docker run -d`mit den richtigen Einstellungen. Der Trick liegt in`isIgnoreExitValue = true`.

isIgnoreExitValue = true

Wenn der Container bereits existiert, der Befehl`docker run`schlägt fehl (exit code ≠ 0). Ohne diese Eigenschaft, Gradle würde den gesamten Build mit einem Fehler abbrechen. Hier, der Fehler ist stillschweigend—und der`doLast`erkennt, ob es ein \"déjà existant\" oder ein echter Fehler war.

Le `doLast`ist entscheidend: er läuft nach der Ausführung des Befehls, was die Inspektion ermöglicht.`executionResult.get().exitValue`und eine angemessene Nachricht zu protokollieren.

signInB` — Der Interaktive

Das ist die einzige Aufgabe, die eine menschliche Interaktion erfordert. Gradle überträgt`System.in`zum Prozess, damit der Prompt`ollama signin`normal angezeigt werden kann:

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

Ohne das würde der Sign-in blockieren, weil stdin geschlossen wäre, und Sie würden den Prompt niemals sehen.

pullModelB` — Der Parametrisierbare

Eine generische Aufgabe, die einen Parameter über`-P`:

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

Der Fallback ist wichtig`project.findProperty("model") as? String ?: "qwen3:0.6b"`Es garantiert, dass die Aufgabe nicht schiefgeht, wenn man den Parameter vergisst.

statusOllamaAll` — Die Diagnose

Zwei`curl`in Gesundheit auf den Häfen`11434` et 11435, mit einem Fallback in`runCatching`Um nicht abzustürzen, falls eine der Instanzen ausgefallen ist. Die Ausgabe ist strukturiert:

✓ 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

Ein Blick, und du weißt genau, was läuft und was nicht stimmt.

cleanOllamaB` — Die Reinigung, die die Identität bewahrt

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

Der Container ist gelöscht, aber das Volume`~/ollama-b-data`istkonserviert. L’Identität SSH überlebt am Reinigung. Am nächsten`startOllamaB`, dieselben Schlüssel werden wiederverwendet — es ist nicht nötig, sich erneut anzumelden oder den Device Key neu zu registrieren.

cleanOllamaB`Berühre die Lautstärke nicht. Wenn du die Identität B löschen willst (und alles von vorne beginnen willst), musst du löschen.~/ollama-b-data`manuell. Es ist freiwillig — ein`clean`sollte keine Anmeldedaten zerstören.

Workflow vollständig — Von null bis zwei Sitzungen

Hier ist die ideale Sequenz für jemanden, der zum Projekt kommt und alles einrichten möchte, ohne die Dokumentation zu öffnen :

# 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

In weniger als zwei Minuten ist alles bereitgestellt. Kein README zum Lesen, keine Umgebungsvariablen zum Setzen. Die Aufgabe`helpOllama`dient als integrierte Dokumentation

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

@startuml




...
... ( skipping 128 lines )
...
  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\n(build.gradle.kts)" as Gradle
participant "Docker\nDaemon" as Docker
participant "Ollama
^^^^^
 Syntax Error? (Assumed diagram type: sequence)

@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
Instanz B" as OllamaB
participant "Ollama\nWolke" 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

Warum nicht daraus ein echtes Gradle-Plugin machen?

Legitime Frage. Extrahiere dies`build.gradle.kts`Ein eigenständiges Gradle-Plugin hätte Vorteile: Wiederverwendung zwischen Projekten, deklarative Konfiguration, DSL‑Erweiterung.

Aber im Moment macht das keinen Sinn. Dieser Build hat keine komplexe Geschäftslogik — er ist lediglich ein Wrapper um Docker- und curl-Befehle. Eine Schicht eines vollständigen Gradle-Plugins (mit Erweiterung, Einheitstests, Veröffentlichung) wäre für 12 Dienstaufgaben überdimensioniert.

Le `build.gradle.kts`inline befindet sich im Projekt, das es benötigt. Es ist trivial, es anderswo zu kopieren. Wenn ich einmal drei Projekte habe, die darauf angewiesen sind, werde ich es in ein Plugin umwandeln. Momentan, KISS.

Der Tag, an dem du drei hast`build.gradle.kts`Identisch in drei Projekten – das ist das Signal, ein Plugin zu extrahieren. Zuvor ist die Duplizierung günstiger als die Abstraktion. Entwickler-Weisheit.

Gelernte Lektionen

  1. Gradle ist nicht nur ein Build-ToolDer Abhängigkeitsgraph-Motor macht ihn zu einem gefürchteten Orchestrator der Infrastruktur.doLast, isIgnoreExitValue, dependsOn: diese drei Primitive reichen aus, um einen Docker-Container mit derselben Zuverlässigkeit wie eine CI-Pipeline zu steuern.

  2. isIgnoreExitValue` ist dein Freund— In einer idealen Welt`docker run`Beim Aufruf eines bereits vorhandenen Containers würde eine Warnung statt eines Fehler-Exit-Codes zurückgegeben. In der realen Welt setzt du`isIgnoreExitValue = true`und du verwaltest die Diagnose in`doLast`.

  3. Die konfigurierbaren Aufgaben töten die Ambiguität — Un pullModelB`generisch mit-Pmodel=…​`ersetzt N spezialisierte Aufgaben. Eine einzige Aufgabe, eine einzige Dokumentation, ein einziges Verhalten.

  4. runCatching` für die health checksWenn du N Endpunkte testest, sollte ein Absturz beim ersten dich nicht daran hindern, die übrigen zu testen.runCatching+`getOrElse`pro Endpunkt, und die Diagnose bleibt vollständig, selbst bei partiellen Ausfällen.

  5. Die Aufgabe helpOllama als ausführbare Dokumentation — Un `println()`Gut formatiert ersetzt es ein README aus drei Absätzen. Es ist selbstdokumentierend, immer auf dem neuesten Stand und ist ohne das Terminal zu verlassen zugänglich.

Fazit

Wir sind von manuell eingegebenen Docker-Befehlen zu einem vollständigen, versionierten und wiederverwendbaren Gradle-Workflow übergegangen. Zwölf Aufgaben decken den gesamten Lebenszyklus ab: Bereitstellung, Identität, Anmeldung, Pull, Test, Diagnose, Bereinigung.

Die Kombination aus docker-compose + build.gradle.kts + opencode.json bildet eine saubere Dreifaltigkeit: Docker für die Isolation, Gradle für die Orchestrierung, OpenCode für den Verbrauch. Jede Schicht hat ihre Verantwortung, und ninguna überlappt mit einer anderen.

Nächster Schritt? Den Lebenszyklus in der CI automatisieren, damit die Ollama-Instanzen in temporären Entwicklungsumgebungen verfügbar sind. Aber das ist ein anderer Artikel.

Verwandte Artikel