إضافة Gradle Maison لتشغيل مثيلين من Ollama Pro بالتوازي
نُشر في 08 May 2026
لماذا تكتب أوامر Docker يدويًا بينما يمكن تغليف كل شيء في مهام Gradle؟ هذا هو كيف صنّفت إعداد dual-Ollama Pro الخاص بي دون أي سكريبت شِل خارجي. البدء، الإيقاف، الهوية، تسجيل الدخول، السحب — كل شيء يمر عبر`./gradlew`.
مقدمة
في المقال السابق، أظهرت كيفية جعل حسابين Ollama Pro يتعايشان على نفس الجهاز من خلال عزل المثيل الثاني في حاوية Docker. يعمل التثبيت، لكن الإدارة اليومية تصبح مملة بسرعة: تذكر اسم الحاوية، وإعادة تشغيل الأوامر الصحيحة`docker exec`, checker ما هو المنفذ النشط…
أنت تعرف تلك اللحظة عندما يكون لديك 14 محطات طرفية مفتوحة، وتكتب`docker exec ollama-instance-b ollama list`للمرّة الرابعة، وتقول لنفسك «يجب أن تكون هناك طريقة أنظف».
هذا الوسيلة هي Gradle.
ليس مجرد لبناء الكود. Gradle مثل قائد أوركسترا للبنية التحتية المحلية. مهام تُجهِّد، تتحقق، تختبر، وتنظف. الكل مُصدَّر في un`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 pull
Test ..> ContainerB : curl /v1/chat/completions
Status ..> ContainerB : docker ps
Status ..> Native : curl :11434
Clean ..> ContainerB : docker rm -f
note bottom of GradleFile
لا يوجد سكريبت باش خارجي.
كل شيء في build.gradle.kts,
مُصدَّر مع المشروع.
end note
@enduml
لماذا Gradle للابنية التحتية؟
السؤال مشروع. جريلد هو أداة بناء. عادةً ما يجمع، يختبر، يحزم. لا يُطلق حاويات Docker.
إلا أن Gradle هو أيضًا محرك رسم بياني للاعتمادات — وهنا يصبح الأمر مثيرًا لاهتمامنا في حالتنا.
نريد:
-
سواء كان يتم تشغيل الحاوية قبل تسجيل الدخول
-
يجب التحقق من الهوية قبل سحب النموذج
-
إذا فشل اختبار API بصمت عندما يكون الحاوية متوقفًا
من الرسم البياني النقي للاعتمادات. Gradle مُصمَّم لذلك. يتيح DSL Kotlin كتابة المهام بشكل سلس، وال`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، مع fallback في`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`, سيتم إعادة استخدام نفس المفاتيح — لا حاجة لإعادة تسجيل الدخول ولا لإعادة تسجيل مفتاح الجهاز
|
|
سير عمل كامل — من الصفر إلى جلستين
هذا هو التسلسل المثالي لشخص يصل إلى المشروع ويريد إعداد كل شيء دون فتح أي وثيقة
# 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 : حسنا Dev -> Gradle : checkIdentity Gradle -> Docker : قطة id_ed25519.pub Gradle --> Dev : مفتاح عام SSH Dev -> Cloud : تسجيل المفتاحSSH\n(على ollama.com/settings/keys) == Authentification == Dev -> Gradle : signInB Gradle -> Docker : ollama تسجيل الدخول (تفاعلي) Dev <-> Cloud : تسجيل الدخول للمتصفح (الحساب ب) Docker -> Cloud : إبدال الرمز ⇔ مفتاح الجهاز Gradle --> Dev : مُوثّق == Vérification == Dev -> Gradle : pullModelB Gradle -> Docker : ollama سحب qwen3:0.6b Gradle --> Dev : نموذج مُسحب Dev -> Gradle : testOllamaB Gradle -> Docker : curl /v1/chat/completions Gradle --> Dev : ✓ مثيل B OK == Production == Dev -> Gradle : pullAllProModels Gradle -> Docker : ollama اسحب deepseek-v4-pro:cloud Gradle -> Docker : ollama pull gemma4:31b-cloud Gradle --> Dev : النماذج الاحترافية المسحوبة Dev -> Gradle : statusOllamaAll Gradle --> Dev : ✓ Native ✓ Docker B @enduml
لماذا لا نجعلها إضافة حقيقية لـ Gradle ?
سؤال مشروع. استخراج هذا`build.gradle.kts`أن وجود برنامج إضافي Gradle مستقل سيكون له مزايا : إعادة الاستخدام بين المشاريع، التكوين التصريحي، توسيع DSL .
لكن في الوقت الحالي، لا معنى لذلك. لا يحتوي هذا البناء على منطق عمل معقد—إنه مجرد غلاف حول أوامر Docker وcurl. إضافة طبقة من مكوّن Gradle كامل (مع امتداد، واختبارات وحدة، ونشر) ستكون غير متناسبة لـ 12 مهمة مساعدة.
Le `build.gradle.kts`إنّها تعيش في المشروع الذي يحتاج إليها. من السهل نسخها في أي مكان آخر. إذا كان لدي يومًا ما ثلاثة مشاريع تعتمد عليها، سأحوّلها إلى إضافة. حتى الآن، KISS.
|
اليوم الذي لديك ثلاثة`build.gradle.kts`متطابقة في ثلاثة مشاريع، هذه الإشارة تعني أنه يجب استخراج مكوّن إضافي. قبل ذلك، الاستنساخ أقل تكلفة من التجريد. حكمة المطور. |
الدروس المستفادة
-
Gradle ليس مجرد أداة بناء— محرك رسم بياني للتبعيات يجعله منسقاً مخيفاً للبنية التحتية.
doLast,isIgnoreExitValue, `dependsOn`هذه الثلاث بدائل كافية لتدارة حاوية Docker بنفس موثوقية خط أنابيب CI. -
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 في بيئات التطوير المؤقتة. لكن ذلك مقال آخر.