waktu baca : 18 minutes

Mengapa mengetik perintah docker secara manual ketika kita dapat mengenkapsulasi semuanya dalam tugas Gradle? Ini adalah cara saya mengindustrialisasikan konfigurasi dual-Ollama Pro saya dengan nol skrip shell eksternal. Memulai, menghentikan, identitas, sign-in, pull — semuanya lewat`./gradlew`.

Pendahuluan

Dalam artikel sebelumnya, saya menunjukkan cara memungkinkan dua akun Ollama Pro untuk coexist pada mesin yang sama dengan mengisolasi instansi kedua dalam kontainer Docker. Penyiapan berfungsi, tetapi pengelolaan sehari-hari menjadi sangat merepotkan: harus mengingat nama kontainer, menjalankan perintah yang benar.docker exec, checker port mana yang aktif…​

Anda tahu saat itu ketika Anda membuka 14 terminal, lalu Anda mengetik`docker exec ollama-instance-b ollama list`untuk yang keempat, dan Anda berpikir « pasti harus ada cara yang lebih bersih » ?

Ini adalah Gradle.

Bukan hanya untuk membangun kode. Gradle seperti seorang maestro dari infrastruktur lokal. Tugas-tugas yang melakukan provisioning, verifikasi, pengujian, dan pembersihan. Semua ini di-versionkan dalam sebuah`build.gradle.kts`yang tinggal di samping`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 "pengembang" 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 Asli" 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

Mengapa Gradle untuk infrastruktur?

Pertanyaan itu masuk akal. Gradle adalah sebuah build tool. Normalnya, ia mengkompilasi, menguji, dan memaketkan. Tidak, ia menjalankan kontainer Docker.

Namun Gradle juga merupakan mesin graf dependensi — dan itulah yang membuatnya menarik untuk kasus kita.

Kami ingin:

  1. Agar kontainer dijalankan sebelum sign-in

  2. Pastikan identitas terverifikasi sebelum pull model

  3. Agar sebuah uji API gagal secara diam-diam jika kontainer tidak aktif

Dari grafik dependensi murni. Gradle dirancang untuk itu. DSL Kotlin membuat penulisan tugas menjadi lancar, dan le`doLast`memungkinkan mengeksekusi perintah sistem seolah-olah itu adalah kode aplikatif.

Dan terutama : tidak ada bashrc yang harus dipelihara, tidak ada script yang terbuang di`~/bin`, tidak ada Makefile di samping docker-compose. Semua berada dalam satu file, di lokasi yang sama dengan kode proyek.

build.gradle.kts lengkap

Ini adalah file yang akhirnya saya tulis. Melakukan semuanya, mulai dari penyediaan volume hingga curl verifikasi, melewati proses sign-in interaktif.

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())
    }
}

Ini. Dua belas tugas, satu titik masuk unik, dan tanpa gesekan.

Anatomi Tugas Utama

startOllamaB` — Peluncur

Tidak ada yang sulit : satu`docker run -d`dengan parameter yang tepat. Triknya ada di`isIgnoreExitValue = true`.

isIgnoreExitValue = true

Jika kontainer sudah ada, perintah`docker run`gagal (exit code ≠ 0). Tanpa properti ini, Gradle akan menghentikan seluruh build dengan kesalahan. Di sini, kegagalan bersenyap — dan`doLast`mendeteksi apakah itu merupakan 'yang sudah ada' atau kesalahan yang sebenarnya

Le `doLast`adalah penting : ia berputar setelah eksekusi perintah, yang memungkinkan untuk memeriksa`executionResult.get().exitValue`dan mencatat pesan yang sesuai.

signInB` — Interaktif

Ini adalah satu-satunya tugas yang membutuhkan interaksi manusia. Gradle mengirimkan`System.in`dans processus agar prompt`ollama signin`supaya dapat ditampilkan normal :

standardInput  = System.`in`
standardOutput = System.out
errorOutput    = System.err

Tanpa itu, sign-in akan terblokir karena stdin akan ditutup dan Anda tidak akan pernah melihat prompt.

pullModelB` — Yang dapat dikonfigurasi

Sebuah tugas generik yang menerima parameter melalui`-P` :

./gradlew pullModelB -Pmodel=deepseek-v4-pro:cloud
./gradlew pullModelB                           # fallback : qwen3:0.6b

Cadangan penting :`project.findProperty("model") as? String ?: "qwen3:0.6b"`memastikan bahwa tugas tidak meledak jika kita lupa parameter.

statusOllamaAll` — diagnosis

Dua`curl`sehat di pelabuhan`11434` et 11435, dengan cadangan di`runCatching`Agar tidak crash jika salah satu instance down. Outputnya terstruktur :

✓ 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

Dengan sekilas saja, kamu tahu persis apa yang berputar dan apa yang salah.

cleanOllamaB` — Pembersihan yang Menjaga Identitas

commandLine("docker", "rm", instanceName)
isIgnoreExitValue = true

Kontainer terhapus, tetapi volume`~/ollama-b-data`(a single space)terawat. Identitas SSH bertahan saat pembersihan. Ke yang berikutnya`startOllamaB`, kunci yang sama akan digunakan kembali — tidak perlu melakukan sign-in lagi atau mendaftar ulang Device Key.

cleanOllamaB`Jangan menyentuh volume. Jika kamu ingin menghapus identitas B (dan memulai dari nol), kamu harus menghapus~/ollama-b-data`Secara manual. Ini bersifat sukarela — satu`clean`tidak seharusnya menghancurkan kredensial

Workflow Lengkap — Dari Nol Sampai Dua Sesi

Berikut adalah urutan ideal bagi seseorang yang datang ke proyek dan ingin menyelesaikan semuanya tanpa membuka dokumentasi :

# 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

Kurang dari dua menit, semua sudah disediakan. Tidak perlu membaca README, tidak perlu mengatur variabel lingkungan. Tugas`helpOllama`berfungsi sebagai dokumentasi terintegrasi.

@startuml
!theme plain
skinparam DefaultFontSize 11

actor Développeur as Dev
participant "Gradle
(build.gradle.kts)" as Gradle
participant "Docker\nDaemon" as Docker
participant "Ollama
Instans B" as OllamaB
participant "Ollama\nCloud" 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

Mengapa tidak menjadikannya sebuah plugin Gradle yang sebenarnya?

Pertanyaan yang sah. Ekstrak ini`build.gradle.kts`Mempunyai plugin Gradle mandiri akan memiliki keuntungan: penggunaan kembali antar proyek, konfigurasi deklaratif, ekstensi DSL.

Tapi untuk saat ini, ini tidak masuk akal. Build ini tidak memiliki logika bisnis kompleks — ini hanyalah pembungkus (wrapper) dari perintah Docker dan curl. Menambahkan plugin Gradle lengkap (dengan ekstensi, tes unit, publikasi) akan tidak sebanding untuk 12 tugas utilitas.

Le `build.gradle.kts`inline berada dalam proyek yang membutuhkannya. Dia mudah untuk disalin ke tempat lain. Suatu hari jika saya memiliki tiga proyek yang bergantung padanya, saya akan mengubahnya menjadi plugin. Saat ini, KISS.

Hari ketika kamu punya tiga`build.gradle.kts`sama di tiga proyek, ini adalah sinyal bahwa harus mengekstrak sebuah plugin. Sebelum itu, duplikasi lebih murah daripada abstraksi. Kearifan dev.

Pelajaran yang dipelajari

  1. Gradle bukan hanya sebuah build tool— Mesin grafik dependensi menjadikannya sebuah orchestrator infrastruktur yang menakutkan.doLast, isIgnoreExitValue, dependsOn: ketiga primitif ini cukup untuk mengendalikan kontainer Docker dengan keandalan yang sama seperti pipeline CI.

  2. isIgnoreExitValue` adalah temanmu— Dalam sebuah dunia ideal,docker run`pada kontainer yang sudah ada akan mengembalikan peringatan daripada kode keluar kesalahan. Di dunia nyata, kamu menempatkan`isIgnoreExitValue = true`dan Anda mengelola diagnosa di`doLast.

  3. Tugas yang dapat dikonfigurasi menghilangkan ambiguitas — Un pullModelB`generik dengan-Pmodel=…​`Mengganti N tugas khusus. Satu tugas, satu dokumentasi, satu perilaku.

  4. runCatching` untuk pemeriksaan kesehatanKetika Anda menguji N endpoint, crash pada endpoint pertama tidak boleh menghalangi pengujian endpoint lainnya.runCatching+`getOrElse`per endpoint, dan diagnosis tetap lengkap bahkan jika terjadi kerusakan parsial.

  5. Tugas helpOllama sebagai dokumentasi yang dapat dieksekusi — Un `println()`Yang diformat dengan baik mengganti README tiga paragraphe. Ini bersifat auto-dokumentasi, selalu terkini, dan dapat diakses tanpa keluar dari terminal.

Kesimpulan

Kami beralih dari perintah docker yang harus diketik secara manual ke dalam workflow Gradle yang lengkap, terkontrol versinya, dan dapat digunakan kembali. Belas dua tugas mencakup seluruh siklus hidup: penyediaan, identitas, masuk, tarik, uji, diagnosa, pembersihan.

Komposisi docker-compose + build.gradle.kts + opencode.json membentuk trinitas yang rapi: Docker untuk isolasi, Gradle untuk orkestrasi, OpenCode untuk konsumsi. Setiap lapisan memiliki tanggung jawabnya, dan tidak ada yang meluap ke lapisan lain.

Langkah berikutnya? Mengotomatisasi siklus hidup dalam CI sehingga instance Ollama tersedia di lingkungan dev sementara. Tapi itu adalah artikel lain.

Artikel terkait