두 개의 Ollama Pro 인스턴스를 병렬로 실행하는 집에서 만든 Gradle 플러그인
게시: 08 May 2026
도커 명령어를 직접 입력할 필요가 있나요? 모든 것을 Gradle 작업에 캡슐화할 수 있는데요. 외부 셸 스크립트 없이 내 듀얼-Ollama Pro 설정을 산업화한 방법을 보여드리겠습니다. 시작, 중지, 식별, 로그인, 풀 — 모든 것이`./gradlew`.
소개
이전 글에서는 Ollama Pro 계정 두 개를 동일한 머신에서 공존시키기 위해 두 번째 인스턴스를 Docker 컨테이너에 격리하는 방법을 보여 주었습니다. 설정은 작동하지만 일상적인 관리가 빨리 번거로워집니다: 컨테이너 이름을 기억하고 올바른 명령을 다시 실행해야 합니다.docker exec, checker 어떤 포트가 활성화되어 있는지…
당신은 14개의 터미널이 열려 있는 순간, 그때 입력하고 있는 것을 알고 있죠`docker exec ollama-instance-b ollama list`네 번째로, 그리고 당신은 « 더 깨끗한 방법이 반드시 있어야 해 »라고 생각하나요?
이 방법이 그레이들입니다.
코드를 빌드하는 것만이 아니다. 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 "신원 확인" 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`, docker-compose 옆에 Makefile이 없습니다. 모든 것이 하나의 파일에 있으며, 프로젝트 코드와 같은 위치에 있습니다.
전체 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`실패합니다 (exit code ≠ 0). 이 속성이 없으면 Gradle은 전체 빌드를 오류로 중단합니다. 여기서 실패는 조용합니다 — 그리고`doLast`이미 존재하는"인지 진짜 오류인지 감지합니다.
Le `doLast`그는 명령어 실행 후에 après 실행된다, 이로 인해 검사할 수 있게 된다`executionResult.get().exitValue`그리고 적절한 메시지를 로그합니다.
signInB` — 대화형
이것은 인간의 상호작용이 필요한 유일한 작업입니다. Gradle은 전송합니다.`System.in`프롬프트가 되도록 프로세스에`ollama signin`정상적으로 표시될 수 있도록 :
standardInput = System.`in`
standardOutput = System.out
errorOutput = System.err
그렇지 않으면 stdin이 닫혀서 sign-in이 차단될 것이고, 프롬프트를 결코 볼 수 없을 것입니다.
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`, 동일한 키가 재사용됩니다 — sign‑in을 다시 할 필요도 없고 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\n(build.gradle.kts)" as Gradle participant "Docker Daemon" 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 플러그인(확장, 단위 테스트, publish 포함)의 추가 계층은 12개의 유틸리티 작업에 비해 과도할 것입니다.
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 워크플로우로 전환했습니다. 12개의 작업이 전체 수명 주기를 포괄합니다: 프로비저닝, 아이덴티티, 로그인, pull, 테스트, 진단, 정리.
docker-compose + build.gradle.kts + opencode.json 조합은 깔끔한 삼위일체를 이룹니다: Docker는 격리를 위해, Gradle은 오케스트레이션을 위해, OpenCode는 소비를 위해 사용됩니다. 각 레이어는 각각의 책임을 가지고 있으며, 어느 하나도 다른 영역을 침범하지 않습니다.
다음 단계? CI에서 라이프사이클을 자동화하여 임시 개발 환경에서 Ollama 인스턴스를 사용할 수 있게 하는 것입니다. 하지만 이건 또 다른 글입니다.
자원
관련 기사
31 May 2026
14 May 2026