用于并行运行两个 Ollama Pro 实例的 Gradle Maison 插件
发布于 08 May 2026
为什么要手动输入 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 也是一个依赖图引擎 — 而这正是对我们来说有趣的地方。
我们想要:
-
确保容器在登录前启动
-
在拉取模型之前验证身份
-
如果容器宕机,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.
|
|
完整工作流程 — 从零到两次会议
以下是对于刚加入项目、想在不打开任何文档的情况下完成所有设置的人的理想步骤:
# 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`在三个项目中相同,这就是提取插件的信号。在此之前,复制的成本低于抽象。开发者的智慧。 |
经验教训
-
Gradle 不仅仅是一个构建工具— 依赖图引擎使其成为强大的基础设施编排器。
doLast,isIgnoreExitValue, `dependsOn`这些三个基本原语足以以与 CI 流水线相同的可靠性驱动 Docker 容器。 -
isIgnoreExitValue` 是你的朋友— 在一个理想的世界,
docker run`在已存在的容器上会返回警告而不是错误退出码。在实际世界中,您会`isIgnoreExitValue = true`和你处理诊断在`doLast。 -
可配置的任务消除了歧义 — Un
pullModelB`通用与-Pmodel=…`替换 N 项专门任务。单一任务,单一文档,单一行为。 -
runCatching` 用于健康检查— 当您测试 N 个端点时,第一个的崩溃不应阻止您测试其他端点。
runCatching+`getOrElse`每个端点,即使在部分故障的情况下,诊断仍然完整。 -
任务
helpOllama作为可执行的文档 — Un `println()`格式良好的内容替代了三段落的 README。它自带文档,始终保持最新,且无需离开终端即可访问。
结论
我们从手动输入 Docker 命令过渡到完整的、版本化且可重用的 Gradle 工作流。十二个任务涵盖了整个生命周期:配置、身份、登录、拉取、测试、诊断、清理。
docker-compose + build.gradle.kts + opencode.json 构成了一种纯净的三位一体:Docker 用于隔离,Gradle 用于编排,OpenCode 用于消费。每一层都有其职责,且没有一层越界到另一层。
下一步? 在CI中自动化生命周期,使Ollama实例在临时开发环境中可用。但那是另一篇文章。
资源
相关文章
31 May 2026
14 May 2026
12 May 2026