время чтения : 18 minutes

Почему вводить команды Docker вручную, если можно всё инкапсулировать в задачи Gradle? Вот как я индустриализировал свою конфигурацию dual-Ollama Pro без внешних скриптов оболочки. Запуск, остановка, идентификация, sign-in, pull — всё проходит через`./gradlew`.

Введение

В предыдущей статье я показал, как заставить два аккаунта Ollama Pro сосуществовать на одной машине, изолировав второй экземпляр в Docker-контейнере. Настройка работает, но ежедневное управление быстро становится утомительным: нужно помнить имя контейнера и запускать нужные команды.docker exec, checker какой порт активен…​

Вы знаете этот момент, когда у вас открыто 14 терминалов, и вы печатаете`docker exec ollama-instance-b ollama list`в четвёртый раз, и вы думаете « должен существовать более чистый способ » ?

Это Gradle.

Не только для построения кода. Gradle — как дирижёр локальной инфраструктуры. Задачи, которые обеспечивают provisioning, проверяют, тестируют и очищают. Всё это версионируется в`build.gradle.kts`кто живет рядом с`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 "Разработчик" 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
Никаких внешних bash-скриптов.
Все находится в `build.gradle.kts`,
версионируется вместе с проектом.
end note

@enduml

Почему Gradle для инфраструктуры?

Вопрос законный. Gradle — это инструмент сборки. Обычно он компилирует, тестирует, упаковывает. Не запускает контейнеры Docker.

Однако Gradle также является движком графа зависимостей — и именно здесь это становится интересным для нашего случая.

Хотим:

  1. Пусть контейнер будет запущен перед входом

  2. Идентичность должна быть проверена перед pull модели

  3. Если тест API завершается ошибкой без вывода, когда контейнер выключен

Чистый граф зависимостей. Gradle предназначен именно для этого. Kotlin DSL делает написание задач плавным, и`doLast`позволяет выполнять системные команды как если бы это был прикладной код.

И главное: не нужно поддерживать bashrc, нет скриптов, валяющихся в`~/bin`, нет Makefile рядом с docker-compose. Всё находится в одном файле, в том же месте, где находится код проекта.

Полный build.gradle.kts

Вот файл, который я наконец написал. Он делает всё, от подготовки тома до проверочного curl, включая интерактивный вход.

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

Вот. Двенадцать задач, одна точка входа и ноль трения.

Анатомия ключевых задач

startOllamaB` — Запуск

Ничего сложного : один`docker run -d`с правильными настройками. Хитрость заключается в`isIgnoreExitValue = true`.

isIgnoreExitValue = true

Если контейнер уже существует, команда`docker run`завершается с ошибкой (код выхода ≠ 0). Без этого свойства Gradle остановил бы всю сборку с ошибкой. Здесь ошибка молчалива — и`doLast`обнаруживает было ли это "уже существующее" или настоящая ошибка.

Le `doLast`esto критично : он запускается после выполнения команды, что позволяет проверить`executionResult.get().exitValue`и записать соответствующее сообщение в журнал.

signInB` — Интерактив

Это единственная задача, которая требует человеческого взаимодействия. Gradle передаёт`System.in`в процесс, чтобы запрос`ollama signin`пусть отображается нормально :

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

Без этого вход в систему блокировался бы, потому что stdin был бы закрыт, и вы никогда бы не увидели запрос.

pullModelB` — Настраиваемый

Генерическая задача, которая принимает параметр через`-P`:

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

Fallback важен:`project.findProperty("model") as? String ?: "qwen3:0.6b"`гарантирует, что задача не сломается, если забыть параметр.

statusOllamaAll` — Диагностика

Два`curl`в здоровье на портах`11434` et 11435, с откатом в`runCatching`чтобы не падать, если одна из инстанций недоступна. Вывод структурирован :

✓ 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

Одного взгляда, и ты точно знаешь, что работает, а что нет.

cleanOllamaB` — Очистка, которая сохраняет идентичность

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

Контейнер удален, но том`~/ollama-b-data`естьсохранённый. Идентичность SSH переживает очистку. При следующем`startOllamaB`, те же самые ключи будут повторно использованы — не нужно повторно выполнять sign-in и повторно регистрировать Device Key.

cleanOllamaB`Если ты хочешь стереть идентичность B (и начать всё сначала), нужно удалить~/ollama-b-data`вручную. Это добровольно — один`clean`не должно уничтожать учётные данные

Полный рабочий процесс — от нуля до двух сессий

Вот идеальная последовательность для того, кто приходит в проект и хочет всё настроить, не открывая документацию:

# 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

Менее чем за две минуты всё готово. Не нужно читать README, не нужно задавать переменные окружения. Задача`helpOllama`служит встроенной документацией.

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

Почему бы не сделать из него настоящий плагин Gradle ?

Легитимный вопрос. Извлечь это`build.gradle.kts`в отдельном плагине Gradle были бы преимущества: повторное использование между проектами, декларативная конфигурация, расширение DSL.

Но пока это не имеет смысла. Эта сборка не имеет сложной бизнес-логики — это оболочка вокруг команд Docker и curl. Добавление полного плагина Gradle (с расширением, юнит-тестами, публикацией) было бы чрезмерно для 12 утилитарных задач.

Le `build.gradle.kts`inline находится в проекте, где он нужен. Его легко скопировать в другом месте. Если когда‑нибудь у меня будет три проекта, зависящих от него, я превращу его в плагин. Пока KISS.

День, когда у тебя три`build.gradle.kts`одинаковые в трёх проектах, это сигнал, что нужно извлечь плагин. До этого дублирование менее затратно, чем абстракция. Мудрость разработчика.

Уроки извлеченные

  1. Gradle — это не просто инструмент сборки— Движок графа зависимостей делает его грозным оркестратором инфраструктуры.doLast, isIgnoreExitValue, dependsOn: Эти три примитива достаточно, чтобы управлять контейнером Docker с той же надёжностью, что и у CI-пайплайна.

  2. isIgnoreExitValue` твой друг— В идеальном мире,docker run`на уже существующем контейнере вернёт предупреждение вместо кода ошибки выхода. В реальном мире ты ставишь`isIgnoreExitValue = true`и ты управляешь диагнозом в`doLast.

  3. Настраиваемые задачи устраняют неоднозначность — Un pullModelB`общий с-Pmodel=…​`Заменяет N специализированных задач. Одна задача, одна документация, одно поведение.

  4. runCatching` для проверок состояния— Когда ты тестируешь N конечных точек, сбой первого не должен мешать тестировать остальные.runCatching+`getOrElse`на endpoint, и диагностика остаётся полной даже при частичном сбое.

  5. Задача helpOllama как исполняемая документация — Un `println()`хорошо отформатированный заменяет README из трёх абзацев. Он самодокументирован, всегда актуален и доступен без выхода из терминала.

Вывод

Мы перешли от ручного ввода команд docker к полностью завершенному, версионированному и повторно используемому workflow Gradle. Двенадцать задач охватывают весь жизненный цикл: подготовка, идентификация, вход в систему, загрузка, тестирование, диагностика, очистка.

Комбинация docker-compose + build.gradle.kts + opencode.json образует чистую триаду: Docker для изоляции, Gradle для оркестрации, OpenCode для потребления. Каждый слой отвечает за свою зону ответственности, и ни один не выходит за пределы другого.

Следующий шаг? Автоматизировать жизненный цикл в CI, чтобы экземпляры Ollama были доступны во временных средах разработки. Но это другая статья.

Похожие статьи