ホーム Gradle プラグイン:2つの Ollama Pro インスタンスを並列で制御する
公開日: 08 May 2026
なぜ手動でdockerコマンドを打つのですか?Gradleのタスクにすべてをカプセル化できるのに。これが、外部のシェルスクリプトをゼロにしてデュアルOllama Proの設定をどのように産業化したかです。起動、停止、アイデンティティ、サインイン、プル — すべてこれが経由します`./gradlew`。
紹介
前の記事では、Ollama Pro の二つのアカウントを同じマシンで共存させる方法を示しました。その際、二番目のインスタンスを Docker コンテナ内に隔離しました。設定は機能しますが、日常の管理はすぐに面倒になります:コンテナの名前を覚えておく必要があり、適切なコマンドを再実行しなければなりません。docker exec, チェッカー どのポートがアクティブか…
あなたは、14のターミナルを開いているその瞬間を知っているでしょう、あなたはタイプしている`docker exec ollama-instance-b ollama list`4回目で、そしてあなたは「もっときれいな方法があるはずだ」と思いますか?
この方法は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 "OllamaBを開始" 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 デーモン" as DockerDaemon {
node "ollama-instance-b" as ContainerB {
component ":11435" as Port
database "~/ollama-b-data" as Volume
}
}
package "Ollama ネイティブ" 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
Zéro script bash externe.
Tout est dans le build.gradle.kts,
versionné avec le projet.
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())
}
}
はい、12のタスク、単一のエントリーポイント、摩擦ゼロ。
キータスクの解剖
startOllamaB` — ランチャー
何も難しくない:一`docker run -d`正しい設定で。コツはそこにある`isIgnoreExitValue = true`。
isIgnoreExitValue = true
コンテナが既に存在している場合、コマンド`docker run`失敗します (exit code ≠ 0)。このプロパティがない場合、Gradle はすべてのビルドをエラーで停止します。ここでは、失敗はサイレントです — そして`doLast`それが既に存在していたものか、本当のエラーかを検出する。
Le `doLast`重要:コマンドの実行後に回転し、検査を可能にします。`executionResult.get().exitValue`適切なメッセージをログに出力する。
signInB` — インタラクティブ
これは人間のインタラクションが必要な唯一のタスクです。 Gradle は伝送します`System.in`プロンプトが出るようにプロセスに`ollama signin`正常に表示されるように:
standardInput = System.`in`
standardOutput = System.out
errorOutput = System.err
それをしなければ、sign-in は 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
2分未満ですべてプロビジョニングされます。READMEを読む必要も、環境変数を設定する必要もありません。タスク`helpOllama`組み込まれたドキュメントとして機能します。
@startuml !theme plain skinparam DefaultFontSize 11 actor Développeur as Dev participant "Gradle (build.gradle.kts)" as Gradle participant "Docker デーモン" as Docker participant "Ollama インスタンス B" as OllamaB participant "Ollama クラウド" 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 必要なプロジェクトに存在します。他のところにコピーするのは簡単です。ある日私がそれに依存する3つのプロジェクトを持ったら、それをプラグインに変換します。今のところ、KISS。
|
三つ持っている日`build.gradle.kts`3つのプロジェクトで同じであることは、プラグインを抽出すべきだというシグナルです。それより前に、重複は抽象化よりもコストが低い。開発者の知恵。 |
学んだ教訓
-
Gradle は単なるビルドツールではない。— 依存グラフエンジンは、恐るべきインフラストラクチャオーケストレーターとなる。
doLast,isIgnoreExitValue,dependsOn: これらの三つのプリミティブは、CI パイプラインと同じ信頼性で Docker コンテナを操作するのに十分です。 -
isIgnoreExitValue`は君の友達— 理想的な世界で、
docker run`既存のコンテナー上で実行すると、エラー終了コードではなく警告が返されます。実際の世界では、あなたは`isIgnoreExitValue = true`そしてあなたは管理している診断で`doLast。 -
パラメータ化可能なタスクは曖昧さを殺す — Un
pullModelB`ジェネリックと-Pmodel=…`N 個の専門タスクを置き換えます。一つだけのタスク、一つだけのドキュメント、一つだけの動作。 -
runCatching`health checksのために— Nのエンドポイントをテストするとき、最初のクラッシュが他のテストを妨げてはいけません。
runCatching+`getOrElse`エンドポイントごとに、そして診断は部分的な故障でも完全のままです。 -
helpOllama` タスクとしての実行可能なドキュメンテーション — Un
println()`bien formatéが 3 段落の README を置き換えます。自己文書化されており、常に最新状態で、ターミナルを離れずにアクセスできます。
結論
手入力のDockerコマンドから、完全でバージョン管理され再利用可能なGradleワークフローに移行しました。12のタスクがライフサイクル全体をカバーします:プロビジョニング、アイデンティティ、サインイン、プル、テスト、診断、クリーニング。
docker-compose + build.gradle.kts + opencode.json の組み合わせはきれいな三位一体を形成します:Dockerは隔離のため、Gradleはオーケストレーションのため、OpenCodeは消費のため。各レイヤーにはそれぞれの責任があり、いずれも他のレイヤーに干渉しません。
次のステップは? CI でライフサイクルを自動化し、一時的な開発環境で Ollama インスタンスを利用可能にすること。ただし、それは別の記事の話です。
リソース
関連記事
31 May 2026
14 May 2026