tempo de leitura : 18 minutes

Por que digitar comandos Docker manualmente quando podemos encapsular tudo em tarefas Gradle? Aqui está como eu industrializei minha configuração dual-Ollama Pro com zero script shell externo. Inicialização, parada, identidade, sign-in, pull — tudo passa por`./gradlew`.

Introdução

No artigo anterior, eu mostrei como fazer com que duas contas Ollama Pro coexistam na mesma máquina isolando a segunda instância em um contêiner Docker. A configuração funciona, mas a gestão diária torna-se rapidamente cansativa: lembrar o nome do contêiner, executar novamente os comandos corretos`docker exec`, verificar qual porta está ativa…​

Você conhece esse momento em que você tem 14 terminais abertos, que você digita`docker exec ollama-instance-b ollama list`para a quarta vez, e você pensa « deve haver um jeito mais limpo »?

Este método é o Gradle.

Não apenas para construir código. Gradle como um maestro da infraestrutura local. Tarefas que provisionam, verificam, testam e limpam. Tudo versionado em um`build.gradle.kts`que vive ao lado do`docker-compose.yml`.

architecture gradle ollama

Por que Gradle para da Infra ?

A pergunta é legítima. Gradle, é uma ferramenta de build. Normalmente, ele compila, ele testa, ele empacota. Ele não lança contêineres Docker.

Exceto que o Gradle também é um motor de grafo de dependências — e é aí que isso se torna interessante para o nosso caso.

Queremos:

  1. Que o contêiner seja iniciado antes de um sign-in

  2. Que a identidade seja verificada antes de um pull de modelo

  3. Que um teste de API falhe silenciosamente se o contêiner estiver fora do ar.

Do grafo de dependências puro. Gradle é feito para isso. O DSL Kotlin torna a escrita das tarefas fluida, e o`doLast`permite executar comandos do sistema como se fossem código de aplicação.

Sobretudo: sem bashrc para manter, sem scripts que fiquem espalhados em`~/bin`, sem Makefile ao lado do docker-compose. Tudo vive em um único arquivo, no mesmo local que o código do projeto.

O build.gradle.kts completo

Aqui está o arquivo que acabei por escrever. Ele faz tudo, do provisionamento de volume até o curl de verificação, passando pelo sign-in interativo.

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

Aqui está. Doze tarefas, um único ponto de entrada e zero atrito.

Anatomia das Tarefas Chave

startOllamaB` — O Lançador

Nada de especial: um`docker run -d`com os parâmetros corretos. O truque está em`isIgnoreExitValue = true`.

isIgnoreExitValue = true

Se o contêiner já existir, o comando`docker run`falha (código de saída ≠ 0). Sem essa propriedade, o Gradle interromperia todo o build com erro. Aqui, a falha é silenciosa — e o`doLast`detecta se era um "já existente" ou um erro real.

Le `doLast`é crucial : ele é executado após a execução do comando, o que permite inspecionar`executionResult.get().exitValue`e de registrar uma mensagem apropriada.

signInB` — O Interativo

É a única tarefa que requer interação humana. Gradle transmite`System.in`ao process para que o prompt`ollama signin`possa aparecer normalmente :

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

Sem isso, o sign-in bloquearia porque o stdin estaria fechado e você nunca veria o prompt.

pullModelB` — O configurável

Uma tarefa genérica que aceita um parâmetro via`-P`:

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

O fallback é importante:`project.findProperty("model") as? String ?: "qwen3:0.6b"`garante que a tarefa não quebre se esquecermos o parâmetro.

statusOllamaAll` — O Diagnóstico

Dois`curl`em saúde nos portos`11434` et 11435, com um fallback em`runCatching`para não travar caso uma das instâncias esteja down. A saída é estruturada:

✓ 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

Um olhar, e você sabe exatamente o que está acontecendo e o que está errado.

cleanOllamaB` — A limpeza que preserva a identidade

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

O contêiner foi excluído, mas o volume`~/ollama-b-data`éconservado. A identidade SSH sobrevive à limpeza. Ao próximo`startOllamaB`, as mesmas chaves serão reutilizadas — não é preciso refazer o sign-in nem re-registrar a Device Key.

cleanOllamaB`não toque no volume. Se quiser apagar a identidade B (e recomeçar tudo do zero), é preciso excluir~/ollama-b-data`manualmente. É voluntário — um`clean`não deve destruir as credenciais.

Workflow Completo — De Zero a Duas Sessões

Aqui está a sequência ideal para alguém que chega ao projeto e quer colocar tudo em funcionamento sem abrir nenhuma documentação:

# 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 dois minutos, tudo está provisionado. Não há README para ler, não há variáveis de ambiente a definir. A tarefa`helpOllama`funciona como documentação 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
Instância B" as OllamaB
participant "Ollama\nNuvem" 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 que não transformá-lo em um plugin Gradle real?

Pergunta legítima. Extrair isso`build.gradle.kts`em um plugin Gradle autônomo teria vantagens: reutilização entre projetos, configuração declarativa, extensão DSL.

Mas por enquanto, não faz sentido. Este build não tem lógica de negócios complexa — é um wrapper em torno de comandos Docker e curl. A sobrecarga de um plugin Gradle completo (com extensão, testes unitários, publicação) seria desproporcional para 12 tarefas utilitárias.

Le `build.gradle.kts`inline está no projeto que precisa dele. É trivial copiar em outro lugar. Se um dia eu tiver três projetos que dependem dele, eu o transformarei em um plugin. Por enquanto, KISS.

O dia em que tens três`build.gradle.kts`identiques em três projetos, isso é o sinal de que é necessário extrair um plugin. Antes disso, a duplicação é menos custosa que a abstração. Sabedoria de dev.

Lições Aprendidas

  1. Gradle não é apenas uma ferramenta de build— O motor de grafo de dependências o torna um orquestrador de infraestrutura formidável.doLast, isIgnoreExitValue, dependsOn: estas três primitivas são suficientes para gerenciar um contêiner Docker com a mesma confiabilidade de um pipeline CI.

  2. isIgnoreExitValue` é teu amigo— Num mundo ideal,docker run`em um contêiner já existente retornaria um aviso em vez de um código de saída de erro. No mundo real, você coloca`isIgnoreExitValue = true`e você gerencia o diagnóstico em`doLast.

  3. As tarefas configuráveis matam a ambiguidade — Un pullModelB`genérico com-Pmodel=…​`substitui N tarefas especializadas. Uma única tarefa, uma única documentação, um único comportamento.

  4. runCatching` para as verificações de saúde— Quando você testa N endpoints, a falha do primeiro não deve impedir de testar os outros.runCatching+`getOrElse`por endpoint, e o diagnóstico permanece completo mesmo em caso de falha parcial.

  5. A tarefa helpOllama como documentação executável — Un `println()`Bem formatado substitui um README de três parágrafos. É auto-documentado, sempre atualizado e acessível sem sair do terminal.

Conclusão

Passamos de comandos docker a digitar à mão para um workflow Gradle completo, versionado, reutilizável. Doze tarefas cobrem todo o ciclo de vida: provisionamento, identidade, sign-in, pull, teste, diagnóstico, limpeza.

O combo docker-compose + build.gradle.kts + opencode.json forma uma trindade limpa: Docker para isolamento, Gradle para orquestração, OpenCode para consumo. Cada camada tem sua responsabilidade, e nenhuma transborda sobre a outra.

Próxima etapa? Automatizar o ciclo de vida na CI para que as instâncias Ollama estejam disponíveis nos ambientes de desenvolvimento efêmeros. Mas isso é outro artigo.

Articles connexes