阅读时间 : 18 minutes

为什么要手动输入 docker 命令,而不把一切封装在 Gradle 任务中呢?以下是我如何将我的双 Ollama Pro 配置工业化,且不使用任何外部 shell 脚本。启动、停止、身份、登录、拉取 — 一切都通过`./gradlew`.

介绍

在之前的文章中,我展示了如何在同一台机器上让两个 Ollama Pro 账户共存,通过将第二个实例隔离在一个 Docker 容器中。这个设置可以工作,但日常管理很快就会变得麻烦:记得容器的名称,重新启动正确的命令`docker exec`, 检查器 哪个端口处于活动状态…​

你知道这种时刻,当你有14个终端打开,你正在输入`docker exec ollama-instance-b ollama list`第四次,你会想‘一定有更干净的方法’?

这种方法就是 Gradle。

不只是构建代码。Gradle 就像本地基础设施的指挥家。这些任务负责供应、验证、测试和清理。所有内容在一个…​中进行版本化`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 登录
Pull ..> ContainerB : ollama 拉取
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. 在拉取模型之前验证身份

  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`是至关重要的:它在命令执行*之后*运行,这使得可以检查`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

后备很重要:`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`, 相同的密钥将被重复使用 — 无需重新进行登录,也不需要重新注册 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\n守护进程" as Docker
participant "Ollama\n实例 B" as OllamaB
participant "Ollama\n云" as Cloud

== Provisionnement ==
Dev -> Gradle : helpOllama
Gradle --> Dev : 帮助和说明

Dev -> Gradle : startOllamaB
Gradle -> Docker : docker run -d ollama/ollama:0.20.2
Docker -> OllamaB : 密钥生成 SSH (id_ed25519)
Docker --> Gradle : 已启动的容器
Gradle --> Dev : OK

Dev -> Gradle : checkIdentity
Gradle -> Docker : cat id_ed25519.pub
Gradle --> Dev : 公开密钥 SSH

Dev -> Cloud : 关键记录 SSH\n(在 ollama.com/settings/keys)

== Authentification ==
Dev -> Gradle : signInB
Gradle -> Docker : ollama 登录 (交互式)
Dev <-> Cloud : 登录浏览器(账户 B)
Docker -> Cloud : 令牌交换 ⇔ 设备密钥
Gradle --> Dev : 已认证

== Vérification ==
Dev -> Gradle : pullModelB
Gradle -> Docker : ollama pull qwen3:0.6b
Gradle --> Dev : 已拉取的模型

Dev -> Gradle : testOllamaB
Gradle -> Docker : curl /v1/chat/completions
Gradle --> Dev : ✓ 实例 B 正常

== Production ==
Dev -> Gradle : pullAllProModels
Gradle -> Docker : ollama pull deepseek-v4-pro:cloud
Gradle -> Docker : ollama pull gemma4:31b-cloud
Gradle --> Dev : 已拉取的专业模型

Dev -> Gradle : statusOllamaAll
Gradle --> Dev : ✓ 本地 ✓ Docker B
@enduml

为什么不把它做成一个真正的 Gradle 插件?

合理的问题.提取这个`build.gradle.kts`作为一个独立的 Gradle 插件会有以下优势:在项目之间可重复使用、声明式配置、DSL 扩展。

但目前来说,这没有意义。这个构建没有复杂的业务逻辑 — — 它只是 Docker 和 curl 命令的包装。为这 12 个实用任务添加一个完整的 Gradle 插件层(带扩展、单元测试、发布)会显得过度。

Le `build.gradle.kts`inline 位于需要它的项目中。复制到其他地方非常简单。如果有一天我有三个项目依赖它,我会把它变成插件。目前,保持简单(KISS)。

您有三的那天`build.gradle.kts`在三个项目中相同,这就是提取插件的信号。在此之前,复制的成本低于抽象。开发者的智慧。

经验教训

  1. Gradle 不仅仅是一个构建工具— 依赖图引擎使其成为强大的基础设施编排器。doLast, isIgnoreExitValue, `dependsOn`这些三个基本原语足以以与 CI 流水线相同的可靠性驱动 Docker 容器。

  2. isIgnoreExitValue` 是你的朋友— 在一个理想的世界,docker run`在已存在的容器上会返回警告而不是错误退出码。在实际世界中,您会`isIgnoreExitValue = true`和你处理诊断在`doLast。

  3. 可配置的任务消除了歧义 — Un pullModelB`通用与-Pmodel=…​`替换 N 项专门任务。单一任务,单一文档,单一行为。

  4. runCatching` 用于健康检查— 当您测试 N 个端点时,第一个的崩溃不应阻止您测试其他端点。runCatching+`getOrElse`每个端点,即使在部分故障的情况下,诊断仍然完整。

  5. 任务 helpOllama 作为可执行的文档 — Un `println()`格式良好的内容替代了三段落的 README。它自带文档,始终保持最新,且无需离开终端即可访问。

结论

我们从手动输入 Docker 命令过渡到完整的、版本化且可重用的 Gradle 工作流。十二个任务涵盖了整个生命周期:配置、身份、登录、拉取、测试、诊断、清理。

docker-compose + build.gradle.kts + opencode.json 构成了一种纯净的三位一体:Docker 用于隔离,Gradle 用于编排,OpenCode 用于消费。每一层都有其职责,且没有一层越界到另一层。

下一步? 在CI中自动化生命周期,使Ollama实例在临时开发环境中可用。但那是另一篇文章。

相关文章