време за читање : 18 minutes

Zašto kucati docker komande ručno kada možemo sve da encapsulamo u Gradle zadatke? Ovde je kako sam industrializovao svoju dual-Ollama Pro konfiguraciju bez vanjskih shell skriptova. Pokretanje, zaustavljanje, identitet, sign-in, pull — sve ide kroz`./gradlew`.

Увод

U prethodnom članku sam pokazao kako učiniti dva Ollama Pro naloga da koexistiraju na istoj mašini isolovanjem druge instance u Docker kontejneru. Podešavanje radi, ali svakodnevno upravljanje postaje brzo mučno: zapamtiti ime kontejnera, ponovo pokrenuti ispravne komande.docker exec, proveri koji port je aktivan…​

Знаеш овај момент када имаш 14 отворених терминала и укуцаш`docker exec ollama-instance-b ollama list`за четврти пут, и да говорите самог с себе: « мора постојати чистији начин » ?

Ovo sredstvo, to je Gradle.

Nije samo za izgradnju koda. Gradle kao dirigent lokalne infrastrukture. Zadaci koji provisioniraju, proveravaju, testiraju i čiste. Sve to verzionisano u`build.gradle.kts`koji živi pored`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 "Razvijalac" 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 demon" 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
  Zéro script bash externe.
  Tout est dans le build.gradle.kts,
  versionné avec le projet.
end note

@enduml

Zašto Gradle za infrastrukturu?

Pitanje je legitimno. Gradle, to je alat za gradnju. Normalno, on kompajlira, testira i pakuje. Ne pokreće Docker kontejnere.

Osim toga, Gradle je i motor za graf zavisnosti — i to je mesto gde postaje zanimljivo za naš slučaj.

Hoćemo:

  1. Neka kontejner bude pokrenut pre prijave

  2. Neka se identitet proveri pre preuzimanja modela

  3. Da test API tiho ne uspe ako je kontejner iskљуčen

Iz čistog grafa zavisnosti. Gradle je prilagođen za to. Kotlin DSL čini pisanje zadataka fluidnim, i`doLast`Omogućava izvršavanje sistemskih komandi kao da je to aplikativni kod.

I najvažnije: ne treba održavati bashrc, nema skriptova koji se povaljaju u`~/bin`, nema Makefile uz docker-compose. Sve se nalazi u jednom fajlu, na istom mestu kao i kod projekta.

Пуна build.gradle.kts

Ovo je fajl koji sam na kraju napisao. Sve radi, od dodeljivanja volumena do curl proveravanja, uključujući interaktivni sign-in.

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

Evo. dvanaest zadataka, jedna ulazna tačka i nula trenja.

Anatomija ključnih zadataka

startOllamaB` — Pokretač

Ništa čudno : jedan`docker run -d`с правилним параметрима. Савјет је у`isIgnoreExitValue = true`.

isIgnoreExitValue = true

Ako kontejner već postoji, komanda`docker run`neuspešno (exit code ≠ 0). Bez ove svojstva, Gradle bi prekinuo cijeli build zbog greške. Ovdje, neuspeh je tiho — i`doLast`detektuje da li je bio 'već postojeći' ili prava greška.

Le `doLast`Bitno: Pokreće se posle izvršenja komande, što omogućava inspekciju`executionResult.get().exitValue`и да се логова adekvatna poruka.

signInB` — Interaktivni

Ово је jedini zadatak koji zahteva interakciju čoveka. Gradle prenosi`System.in`za proces kako bi prompt`ollama signin`da se normalno prikaže :

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

Bez toga, sign-in bi se blokirao jer bi stdin bio zatvoren i nikada ne biste videli prompt.

pullModelB` — Конфигуришиво

Opšti zadatak koji prima parametar putem`-P`:

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

Fallback je važan:`project.findProperty("model") as? String ?: "qwen3:0.6b"`garantuje da zadatak ne eksplodira ako se zaboravi parametar.

statusOllamaAll` — Dijagnostika

dva`curl`у здрављу на пристаништима`11434` et 11435, sa rezervnim u`runCatching`Да не падне ако је једна од инстанци down. Излаз је структуриран :

✓ 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

Jedan pogled, i znaš tačno što se okreće i što nije u redu.

cleanOllamaB` — Čišćenje koje čuva identitet

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

Kontejner je obrisan, ali volumen`~/ollama-b-data`jesačuvan. SSH identitet prežive čišćenje. U sledećem`startOllamaB`, isti kljućevi će se ponovo koristiti — nije potrebno ponovo uraditi sign-in ni ponovo sačuvati Device Key.

cleanOllamaB`Ne dodiruj volumen. Ako želiš da obrišeš identitet B (i da ponovo počneš od nule), moraš da ga obrišeš.~/ollama-b-data`ručno. To je dobrovoljno — jedan`clean`Не треба да брише учетнике.

Kompletan radni tok — Od nule do dve sesije

Ovo je idealna sekvenca za nekoga ko stiže na projekat i želi da sve podesi bez otvaranja dokumentacije :

# 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

Manje od dva minuta, sve je obezbeđeno. Nema README za čitanje, nema varijabli okruženja za postavljanje. Zadatak`helpOllama`služi kao integrisana dokumentacija.

@startuml
!theme plain
skinparam DefaultFontSize 11

actor Développeur as Dev
participant "Gradle\n(build.gradle.kts)" as Gradle
participant "Docker\ndajmon" as Docker
participant "Ollama
Instanca B" as OllamaB
participant "Ollama
Oblak" 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

Zašto ne napraviti pravi Gradle plugin?

Питање оправдано. Извуци овај`build.gradle.kts`Samostalni Gradle plugin bi imao prednosti: ponovna upotreba između projekata, deklarativna konfiguracija, proširenje DSL.

Ali za sada, to nema smisla. Ovaj build nema složenu poslovnu logiku — je omotač oko Docker i curl komandi. Dodatni sloj potpunog Gradle plugin-a (sa ekstenzijom, unit testovima, publikacijom) bi bio nepomerano za 12 utilitarnih zadataka.

Le `build.gradle.kts`inline живи у пројекту који га потребује. Тривијално га је копирати где и да. Ако једног дана имам три пројекта која на њему зависе, претраћу га у додатак. За сада, KISS.

Дан када имаш три`build.gradle.kts`идентични у три пројекта, овај сигнал да се извече плагин. Пре toga, дуплирање је јефтиније од абстракције. Мудрост девелопера.

Научене лекције

  1. Gradle nije samo alat za gradnju— Механизам графа зависности чини из њега страшњи оркестратор инфраструктуре.doLast, isIgnoreExitValue, `dependsOn`Ove tri primitivne operacije su dovoljne za upravljanje Docker kontejnerom sa istom pouzdanošću kao i CI pipeline.

  2. isIgnoreExitValue` je tvoj prijatelj— U idealnom svetu,docker run`na već postojećem kontejneru vratio bi upozorenje umesto koda greške. U stvarnom svetu, staviš`isIgnoreExitValue = true`и ти управљаш дијагностиком у`doLast.

  3. Parametrizabilni zadaci ubijavaju nejednoznačnost — Un pullModelB`generički sa-Pmodel=…​`Zamenjuje N specijalizovanih zadataka. Jedan zadatak, jedna dokumentacija, jedno ponašanje.

  4. runCatching` za proveru zdravlja— Kada testiraš N endpointa, pad prvog ne sme da spriječava testiranje ostalih.runCatching+`getOrElse`po endpoint, i dijagnostika ostaje kompletna čak i u slučaju delimičnog kvara.

  5. Zadatak helpOllama kao izvršna dokumentacija — Un `println()`Dobro formatiran zamena za README od tri pasusa. On je samodokumentovan, uvek ažuriran i pristupačan bez izlaska iz terminala.

Zaključak

Prešli smo od ručnog upisivanja Docker komandi do kompletog, verzionisanog i ponovnog korišćenja Gradle workflowa. Dvanaest zadataka pokriva ceo životni ciklus: provisioniranje, identitet, prijava, pull, testiranje, dijagnostika, čišćenje.

Kombinacija docker-compose + build.gradle.kts + opencode.json čini čistu trojku: Docker za izolaciju, Gradle za orkestraciju, OpenCode za konzumaciju. Svaki sloj ima svoju odgovornost, i nijedan ne prelazi na drugi.

Sledeći korak? Automatizovati ciklus života u CI da bi Ollama instance bile dostupne u privremenim razvojnim okruženjima. Ali to je tema za drugi članak.

Повезани чланци