پلاگین Gradle Maison برای اجرای دو نمونه Ollama Pro به صورت موازی
منتشر شده در 08 May 2026
چرا دستورات docker را به صورت دستی تایپ کنیم وقتی میتوانیم همه را را در وظایف Gradle بستهبندی کنیم؟ این است که چگونه من پیکربندی dual-Ollama Pro خود را با صفر اسکریپت شل خارجی صنعتسازی کردم. شروع، توقف، هویت، ورود، pull — همه از طریق`./gradlew`.
مقدمه
در مقاله قبلی، نشان دادم چگونه دو حساب Ollama Pro را روی یک ماشین به اشتراک بگذارید با قرار دادن نمونه دوم آن در یک conteneur Docker. راهاندازی کار میکند اما مدیریت روزمره بهسرعت مشکلزا میشود: به یاد آوردن نام conteneur و اجرای دستورات مناسب.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 "بررسی هویت" as Check
usecase "signInB" as SignIn
usecase "pullModelB" as Pull
usecase "تستاولاماب" 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
Zéro script bash externe.
Tout est dans le build.gradle.kts,
versionné avec le projet.
end note
@enduml
چرا Gradle برای اینفرا؟
سوال مشروع است. Gradle، یک ابزار ساخت است. معمولاً، آن را کامپایل میکند، تست میگیرد و بستهبندی میکند. نه، آن کانتینرهای Docker را راهاندازی نمیکند.
اما Gradle همچنین یک موتور گراف وابستگی است — و اینجاست که برای مورد ما جالب میشود.
میخواهیم :
-
که کانتینر قبل از یک sign-in شروع شود
-
هویت باید قبل از pull مدل بررسی شود
-
اگر یک تست API بهصورت ساکت ناموفق شود زمانی که کانتینر آفلاین باشد
از گراف وابستگیهای خالص. Gradle برای این تنظیم شده است. DSL Kotlin نوشتن وظایف را روان میسازد، و`doLast`اجازهٔ اجرای دستورات سیستم را میدهد، بهطوری که گویی کد برنامهای است.
و بهویژه: هیچ bashrc برای نگهداری وجود ندارد و هیچ اسکریپتی که در … گمشده باشد`~/bin`, هیچ Makefileای کنار docker-compose نیست. همه چیز در یک فایل واحد زندگی میکند، در همان مکان که کد پروژه قرار دارد.
build.gradle.kts کامل
این فایلی است که سرانجام نوشتم. این فایل همه کاری را از تهیه-volume تا 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
بدون آن، ورود به سیستم (sign-in) به دلیل بستن stdin مسدود میشود و هرگز پیام را نمیبینید.
pullModelB` — قابل تنظیم
یک وظیفهٔ عمومی که یک پارامتر را از طریق میپذیرد`-P`(Empty output)
./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, بهعنوان یک fallback در`runCatching`برای جلوگیری از کرش، اگر یکی از نمونهها در وضعیت down باشد. خروجی ساختاریافته است:
✓ 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`, les mêmes clés seront réutilisées — pas besoin de refaire le sign-in ni de ré-enregistrer la Device Key.
|
|
Workflow کامل — از صفر تا دو جلسه
این دنباله ایدهآل برای کسی است که به پروژه میرسد و میخواهد همه چیز را بدون باز کردن مستندات راهاندازی کند :
# 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 (build.gradle.kts)" as Gradle participant "Docker\nدیامون" 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.
اما در حال حاضر، این معنی ندارد. این بیلد منطق کاری پیچیدهای ندارد — این یک wrapper حول دستورات Docker و curl است. افزودن لایهای کامل از پلاگین Gradle (با افزونه، تستهای واحد، انتشار) برای ۱۲ کار utility بیتناسب خواهد بود.
Le `build.gradle.kts`inline در پروژهای که به آن نیاز دارد وجود دارد. کپی کردن آن در جای دیگر ساده است. اگر روزی سه پروژهای داشته باشم که به آن وابسته باشند، آن را به پلاگین تبدیل خواهم کرد. در حال حاضر، KISS.
|
روز که سه داری`build.gradle.kts`همان در سه پروژه، این نشانه است که باید یک پلاگین استخراج کنیم. پیش از آن، تکرار کمتر هزینهبارتر از انتزاع است. حکمت توسعهدهنده. |
دروس آموخته
-
Gradle صرف یک ابزار ساخت نیست.— موتور گراف وابستگی آن را یک orquestratör زیرساخت ترسناک میسازد.
doLast,isIgnoreExitValue,dependsOn: این سه اولیه کافی است تا یک کانتینر Docker را با همان قابلیت اعتماد یک خط لوله CI کنترل کند. -
isIgnoreExitValue` دوستت است— در یک جهان ایدهآل,
docker run`در یک کنтэینر موجود، به جای کد خروجی خطا، یک هشدار برمیگرداند. در دنیای واقعی، تو قرار میدهی`isIgnoreExitValue = true`و شما تشخیص را در مدیریت میکنید`doLast. -
وظائف پارامترپذیر ambiguity را نابود میکنند — Un
pullModelB`عمومی با-Pmodel=…`N وظیفه تخصصی را جایگزین میکند. یک وظیفه، یک مستند، یک رفتار. -
runCatching` برای بررسیهای سلامت— وقتی N endpoint را تست میکنید، خراب شدن اولین endpoint نباید مانع تست بقیه شود.
runCatching+`getOrElse`بر اساس هر endpoint، تشخیص کامل میماند حتی در صورت خرابی جزئی. -
وظیفه
helpOllamaبهعنوان مستندات قابل اجرا — Un `println()`فرمتبندی بهتر، یک README سهپاراگرافی را جایگزین میکند. خود اسناد دارد، همیشه بهروز است و بدون خروج از ترمینال قابل دسترسی است.
نتیجه
ما از دستورات داکر که به صورت دستی تایپ میشدیم به یک workflow کامل Gradle، نسخهٔدار و قابل استفاده مجدد، منتقل شدیم. دوازده وظیفه تمام چرخه زندگی را پوشش میدهند: تأمین، هویت، sign-in، pull، تست، تشخیص (diagnostic) و پاکسازی.
ترکیب docker-compose + build.gradle.kts + opencode.json یک سهگانه تمیز است: Docker برای عزل، Gradle برای همگامسازی، OpenCode برای مصرف. هر لایه مسئولیت خود را دارد و هیچ کدام بر لایهٔ دیگری ریزش نمیکند.
مرحله بعدی؟ خودکارسازی چرخه عمر در CI برای این که مثالهای Ollama در محیطهای توسعه موقت در دسترس باشند. اما این، مقاله دیگریست.
منابع
مقاله منتشر شده در 2026-05-08