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`.

@startuml
!theme plain

skinparam handwritten false
skinparam shadowing false
skinparam BoxPadding 8
skinparam DefaultFontSize 11
skinparam ArrowThickness 1.5

left to right direction

actor "Desenvolvedor" 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
Zero scripts bash externos.
Tudo está no `build.gradle.kts`,
versionado com o projeto.
end note

@enduml

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.

@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\nInstância B" as OllamaB
participant "Ollama\nNuvem" as Cloud

== Provisionnement ==
Dev -> Gradle : helpOllama
Gradle --> Dev : Ajuda e instruções

Dev -> Gradle : startOllamaB
Gradle -> Docker : docker run -d ollama/ollama:0.20.2
Docker -> OllamaB : Geração de chaves SSH (id_ed25519)
Docker --> Gradle : contêiner lançado
Gradle --> Dev : OK

Dev -> Gradle : checkIdentity
Gradle -> Docker : gato id_ed25519.pub
Gradle --> Dev : Chave pública SSH

Dev -> Cloud : Registro chave SSH\n(em ollama.com/settings/keys)

== Authentification ==
Dev -> Gradle : signInB
Gradle -> Docker : ollama login (interativo)
Dev <-> Cloud : Login do navegador (conta B)
Docker -> Cloud : Troca token ⇔ Chave do Dispositivo
Gradle --> Dev : Autenticado

== Vérification ==
Dev -> Gradle : pullModelB
Gradle -> Docker : ollama pull qwen3:0.6b
Gradle --> Dev : Modelo puxado

Dev -> Gradle : testOllamaB
Gradle -> Docker : curl /v1/chat/completions
Gradle --> Dev : ✓ Instância B OK

== Production ==
Dev -> Gradle : pullAllProModels
Gradle -> Docker : ollama pull deepseek-v4-pro:cloud
Gradle -> Docker : ollama pull gemma4:31b-cloud
Gradle --> Dev : Modelos Pro puxados

Dev -> Gradle : statusOllamaAll
Gradle --> Dev : ✓ Nativo ✓ 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.

Artigos relacionados