Skip to content

Commit 0b36773

Browse files
authored
Merge pull request #11 from corespan/podman-compatibility
Podman compatibility
2 parents 4b5470e + b8ee228 commit 0b36773

11 files changed

Lines changed: 597 additions & 144 deletions

File tree

‎README.md‎

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@ ai-studio-cli setup dependencies
4040

4141
> If the installation of drivers occurs, the system reboots at the end of Phase 1.
4242
43+
**Choosing a container runtime (Docker / Podman)**
44+
45+
Phase 1 installs the NVIDIA Container Toolkit and configures GPU access for the
46+
container runtime of your choice. Select it with `--runtime`:
47+
48+
```bash
49+
# Docker (default) — configures the NVIDIA container runtime
50+
ai-studio-cli setup dependencies --runtime docker
51+
52+
# Podman — generates a CDI spec (/etc/cdi/nvidia.yaml) and installs podman
53+
ai-studio-cli setup dependencies --runtime podman
54+
55+
# Both
56+
ai-studio-cli setup dependencies --runtime both
57+
```
58+
59+
The toolkit package is identical for both; only the access mechanism differs —
60+
Docker uses the NVIDIA container runtime, while Podman uses the Container Device
61+
Interface (CDI). With Podman, run GPU containers via
62+
`podman run --device nvidia.com/gpu=all ...`. The CDI spec is regenerated
63+
automatically after driver updates/reboots when the `nvidia-cdi-refresh`
64+
service is available (NVIDIA Container Toolkit ≥ 1.18).
65+
4366
**Phase 2 — Build nvbandwidth**
4467

4568
Run this to verify drivers, clone `nvbandwidth` from GitHub, and compile it.
@@ -50,10 +73,17 @@ ai-studio-cli setup nvbandwidth
5073

5174
**Phase 3 — Install vLLM Dependencies (Optional)**
5275

53-
If you intend to run inference benchmarks using the Docker fallback strategy, you must install Docker Engine and the NVIDIA Container Toolkit.
76+
If you intend to run inference benchmarks using the container fallback strategy, you must install a container runtime and the NVIDIA Container Toolkit. Choose the runtime with `--runtime` (default `docker`):
5477

5578
```bash
79+
# Docker (default)
5680
ai-studio-cli setup vllm
81+
82+
# Podman — installs podman + podman-compose and generates a CDI spec
83+
ai-studio-cli setup vllm --runtime podman
84+
85+
# Both
86+
ai-studio-cli setup vllm --runtime both
5787
```
5888

5989
---
@@ -114,7 +144,7 @@ The `bench` subcommand provides an automated way for testing LLM serving perform
114144

115145
### Quick Start — Zero-Config Benchmark
116146

117-
The CLI comes bundled with a default, optimized `docker-compose.yaml` (configured for `Qwen2.5-32B-Instruct` with FP8 and PP-4 and TP-1). If you don't provide a compose file, it will use this default automatically:
147+
The CLI comes bundled with a default, optimized compose file (configured for `Qwen2.5-32B-Instruct` with FP8 and PP-4 and TP-1). If you don't provide a compose file, it will use this default automatically:
118148

119149
```bash
120150
# Simplest case — uses the built-in default compose
@@ -124,6 +154,17 @@ ai-studio-cli bench --requests 200 --concurrency 20 --max-tokens 1024
124154
ai-studio-cli ab-bench --requests 200 --concurrency 20 --max-tokens 1024
125155
```
126156

157+
#### Container runtime (Docker or Podman)
158+
159+
The `bench`, `ab-bench`, and `vllm` commands work with either runtime. By default the runtime is **auto-detected** (Docker preferred, then Podman); override it with `--runtime`:
160+
161+
```bash
162+
# Force Podman — uses podman-compose and CDI device passthrough
163+
ai-studio-cli bench --runtime podman --requests 200 --concurrency 20
164+
```
165+
166+
Under Podman the bundled compose file is automatically adapted (the GPU `deploy.resources` reservation is rewritten to the CDI `devices: [nvidia.com/gpu=all]` form, which `podman-compose` understands). If you supply your *own* `--compose-file` for Podman, use the CDI device syntax yourself.
167+
127168
To keep the server running after the benchmark (e.g., for follow-up runs):
128169
```bash
129170
ai-studio-cli bench --keep-server --requests 200
@@ -140,7 +181,7 @@ ai-studio-cli bench --compose-file docker-compose.yaml --requests 200 --concurre
140181

141182
### Prerequisites (One-Time Setup)
142183

143-
Before running benchmarks with the built-in vLLM server, ensure you have **Docker Engine** and the **NVIDIA Container Toolkit** installed on your system.
184+
Before running benchmarks with the built-in vLLM server, ensure you have a container runtime (**Docker Engine** or **Podman** + `podman-compose`) and the **NVIDIA Container Toolkit** installed. Run `ai-studio-cli setup vllm --runtime <docker|podman|both>` to install them.
144185

145186
### Manual Server Management
146187

@@ -192,7 +233,7 @@ ai-studio-cli bench --endpoint http://<ip>:<port> --dataset sharegpt --dataset-p
192233

193234
The benchmarking tool automatically determines the best way to run the test:
194235
1. **Local Execution**: If `vllm` is installed locally in your Python environment (e.g., via `pip install vllm`), it will run the benchmark natively to avoid overhead.
195-
2. **Docker Fallback**: If `vllm` is missing (common on fresh nodes), it automatically falls back to pulling and running the `vllm/vllm-openai:latest` Docker image. The Docker container uses host networking to reach your inference server.
236+
2. **Container Fallback**: If `vllm` is missing (common on fresh nodes), it automatically falls back to pulling and running the `vllm/vllm-openai:latest` image with the active runtime (Docker or Podman, per `--runtime`). The benchmark client container uses host networking to reach your inference server (no GPU is requested — the client is a plain load generator).
196237

197238
### Auto-Detection & Structured Results
198239

‎ai-studio-cli/cmd/ab_bench.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ func init() {
6565
abBenchCmd.Flags().IntVar(&abRequests, "requests", 1000, "Total number of requests to send (-n)")
6666
abBenchCmd.Flags().StringVar(&abOutputFile, "output", "", "Write ab gnuplot output to this file (ab -g flag); empty = skip")
6767
abBenchCmd.Flags().StringArrayVar(&abExtraArgs, "ab-arg", nil, "Additional raw argument for ab; can be repeated (advanced: do not interpolate untrusted input)")
68-
abBenchCmd.Flags().StringVar(&abComposeFile, "compose-file", "", "Path to a custom docker-compose.yml; uses bundled default if server is unreachable and this is unset")
68+
abBenchCmd.Flags().StringVar(&abComposeFile, "compose-file", "", "Path to a custom compose file; uses bundled default if server is unreachable and this is unset")
69+
abBenchCmd.Flags().StringVar(&containerRuntime, "runtime", "auto", "Container engine: auto, docker, or podman")
6970
abBenchCmd.Flags().BoolVar(&abKeepServer, "keep-server", false, "Leave the vLLM server running after the benchmark completes")
7071
abBenchCmd.Flags().IntVar(&abServerTimeout, "server-timeout", 900, "Maximum seconds to wait for the vLLM server to become ready (default allows for large model load times)")
7172

‎ai-studio-cli/cmd/bench.go‎

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,9 @@ func (r *benchRunner) registerFlags(cmd *cobra.Command) {
9696
cmd.Flags().StringArrayVar(&r.additionalArgs, "vllm-arg", nil, "Additional raw argument for vllm bench serve; can be repeated (advanced: do not interpolate untrusted input)")
9797
cmd.Flags().StringVar(&r.gpuTag, "gpu-tag", "", "GPU tag for structured result path (auto-detected if empty)")
9898
// Server lifecycle flags
99-
cmd.Flags().StringVar(&r.server.vllmImage, "vllm-image", "vllm/vllm-openai:latest", "Docker image to use for benchmarking if vllm is not installed locally")
100-
cmd.Flags().StringVar(&r.server.composeFile, "compose-file", "", "Path to a custom docker-compose.yml; uses bundled default if server is unreachable and this is unset")
99+
cmd.Flags().StringVar(&containerRuntime, "runtime", "auto", "Container engine: auto, docker, or podman")
100+
cmd.Flags().StringVar(&r.server.vllmImage, "vllm-image", "vllm/vllm-openai:latest", "Container image to use for benchmarking if vllm is not installed locally")
101+
cmd.Flags().StringVar(&r.server.composeFile, "compose-file", "", "Path to a custom compose file; uses bundled default if server is unreachable and this is unset")
101102
cmd.Flags().BoolVar(&r.server.keepServer, "keep-server", false, "Leave the vLLM server running after the benchmark completes")
102103
cmd.Flags().IntVar(&r.server.timeoutSec, "server-timeout", 900, "Maximum seconds to wait for the vLLM server to become ready (default allows for large model load times)")
103104
// Dashboard
@@ -305,11 +306,12 @@ func (r *benchRunner) execute(ctx context.Context, args []string, structuredDir
305306
return nil
306307
}
307308

308-
if isDockerAvailable() {
309-
return r.runInDocker(ctx, args, structuredDir)
309+
engine := currentEngine()
310+
if isContainerEngineAvailable(engine) {
311+
return r.runInContainer(ctx, engine, args, structuredDir)
310312
}
311313

312-
return fmt.Errorf("neither 'vllm' nor 'docker' commands found; please install vLLM locally or install Docker")
314+
return fmt.Errorf("neither 'vllm' nor a container engine (%s) was found; install vLLM locally or install %s", engine.cli(), engine.cli())
313315
}
314316

315317
// applyPromptDataset writes the --prompt string to a temporary JSONL file
@@ -347,33 +349,40 @@ func (r *benchRunner) applyPromptDataset(args *[]string) (func(), error) {
347349
return func() { _ = os.RemoveAll(dir) }, nil
348350
}
349351

350-
func (r *benchRunner) runInDocker(ctx context.Context, args []string, resultDir string) error {
351-
if err := ensureImagePulled(ctx, r.server.vllmImage); err != nil {
352+
// runInContainer runs the vLLM benchmark client in a container when vLLM is not
353+
// installed locally. The client is a plain HTTP load generator (it talks to the
354+
// already-running server endpoint), so it does not request GPU access itself.
355+
func (r *benchRunner) runInContainer(ctx context.Context, engine containerEngine, args []string, resultDir string) error {
356+
if err := ensureImagePulled(ctx, engine, r.server.vllmImage); err != nil {
352357
return err
353358
}
354359

355-
dockerArgs := []string{"run", "--rm", "--network", "host", "--entrypoint", "vllm"}
360+
runArgs := []string{"run", "--rm", "--network", "host", "--entrypoint", "vllm"}
361+
if engine.isPodman() {
362+
// Avoid SELinux denials on bind-mounted dirs (no-op on AppArmor hosts).
363+
runArgs = append(runArgs, "--security-opt=label=disable")
364+
}
356365

357366
if absDir, err := filepath.Abs(resultDir); err == nil {
358-
dockerArgs = append(dockerArgs, "-v", fmt.Sprintf("%s:%s", absDir, absDir))
367+
runArgs = append(runArgs, "-v", fmt.Sprintf("%s:%s", absDir, absDir))
359368
}
360369

361370
for i, arg := range args {
362371
if arg == "--dataset-path" && i+1 < len(args) {
363372
if absDataset, err := filepath.Abs(args[i+1]); err == nil {
364373
datasetDir := filepath.Dir(absDataset)
365-
dockerArgs = append(dockerArgs, "-v", fmt.Sprintf("%s:%s", datasetDir, datasetDir))
374+
runArgs = append(runArgs, "-v", fmt.Sprintf("%s:%s", datasetDir, datasetDir))
366375
}
367376
break
368377
}
369378
}
370379

371-
dockerArgs = append(dockerArgs, r.server.vllmImage)
372-
dockerArgs = append(dockerArgs, args...)
380+
runArgs = append(runArgs, r.server.vllmImage)
381+
runArgs = append(runArgs, args...)
373382

374-
fmt.Printf("Launching Docker: docker %s\n", shellJoin(dockerArgs))
383+
fmt.Printf("Launching container: %s %s\n", engine.cli(), shellJoin(runArgs))
375384

376-
cmd := exec.CommandContext(ctx, "docker", dockerArgs...)
385+
cmd := exec.CommandContext(ctx, engine.cli(), runArgs...)
377386
cmd.Stdout = os.Stdout
378387
cmd.Stderr = os.Stderr
379388
cmd.Stdin = os.Stdin
@@ -489,16 +498,16 @@ func isVllmAvailable() bool {
489498
return exec.Command("vllm", "--version").Run() == nil
490499
}
491500

492-
func isDockerAvailable() bool {
493-
return exec.Command("docker", "--version").Run() == nil
501+
func isContainerEngineAvailable(engine containerEngine) bool {
502+
return exec.Command(engine.cli(), "--version").Run() == nil
494503
}
495504

496-
func ensureImagePulled(ctx context.Context, image string) error {
497-
if exec.CommandContext(ctx, "docker", "image", "inspect", image).Run() == nil {
505+
func ensureImagePulled(ctx context.Context, engine containerEngine, image string) error {
506+
if exec.CommandContext(ctx, engine.cli(), "image", "inspect", image).Run() == nil {
498507
return nil
499508
}
500509
fmt.Printf("Pulling %s (this may take a while on first run)...\n", image)
501-
pull := exec.CommandContext(ctx, "docker", "pull", image)
510+
pull := exec.CommandContext(ctx, engine.cli(), "pull", image)
502511
pull.Stdout = os.Stdout
503512
pull.Stderr = os.Stderr
504513
return pull.Run()

‎ai-studio-cli/cmd/compose_template.go‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import (
1111
//go:embed assets/docker-compose.yaml
1212
var defaultComposeFile []byte
1313

14-
// generateComposeFile extracts the bundled docker-compose.yaml to a stable config directory.
14+
// generateComposeFile extracts the bundled compose file to a stable config
15+
// directory, adapting the GPU passthrough syntax to the active container
16+
// runtime (Docker reservation vs. podman CDI devices).
1517
func generateComposeFile() (path string, cleanup func(), err error) {
1618
configDir, err := os.UserConfigDir()
1719
if err != nil {
@@ -22,14 +24,21 @@ func generateComposeFile() (path string, cleanup func(), err error) {
2224
return "", nil, fmt.Errorf("creating config dir: %w", err)
2325
}
2426

25-
path = filepath.Join(dir, "docker-compose.yaml")
27+
engine := currentEngine()
28+
content := composeForRuntime(defaultComposeFile, engine)
29+
30+
name := "docker-compose.yaml"
31+
if engine.isPodman() {
32+
name = "podman-compose.yaml"
33+
}
34+
path = filepath.Join(dir, name)
2635

2736
needWrite := true
2837
if existing, readErr := os.ReadFile(path); readErr == nil {
29-
needWrite = !bytes.Equal(existing, defaultComposeFile)
38+
needWrite = !bytes.Equal(existing, content)
3039
}
3140
if needWrite {
32-
if err := os.WriteFile(path, defaultComposeFile, 0644); err != nil {
41+
if err := os.WriteFile(path, content, 0644); err != nil {
3342
return "", nil, fmt.Errorf("writing compose file: %w", err)
3443
}
3544
}

‎ai-studio-cli/cmd/lifecycle.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ func serverLifecycle(composeFile, endpoint string, timeoutSec int, keepServer bo
1212
if effectiveCompose == "" {
1313
if !isVLLMReachable(endpoint) {
1414
fmt.Println("No --compose-file provided and server is unreachable.")
15-
fmt.Println("Starting vLLM using bundled default docker-compose.yaml...")
15+
fmt.Printf("Starting vLLM using bundled default compose file (%s runtime)...\n", currentEngine().cli())
1616
path, cleanup, err := generateComposeFile()
1717
if err != nil {
1818
return "", nil, fmt.Errorf("extracting default compose file: %w", err)

‎ai-studio-cli/cmd/runtime.go‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package cmd
2+
3+
import (
4+
"os/exec"
5+
"strings"
6+
)
7+
8+
// containerRuntime selects the container engine for vLLM/bench operations.
9+
// Values: "auto" (default — detect installed engine), "docker", or "podman".
10+
// It is bound to the --runtime flag on the bench, ab-bench, and vllm commands.
11+
var containerRuntime = "auto"
12+
13+
// containerEngine describes how to invoke compose and the container CLI for a
14+
// concrete runtime ("docker" or "podman").
15+
//
16+
// The two runtimes diverge in how compose is invoked and how GPUs are exposed:
17+
// - Docker uses the `docker compose` plugin and the NVIDIA container runtime.
18+
// - Podman uses the standalone `podman-compose` binary and CDI devices
19+
// (`nvidia.com/gpu=all`). Note: `podman compose` (the subcommand that
20+
// delegates to docker-compose) silently drops CDI devices, so we always
21+
// shell out to `podman-compose` instead.
22+
type containerEngine struct {
23+
rt string // "docker" | "podman"
24+
}
25+
26+
// currentEngine resolves the active engine from the --runtime flag, falling
27+
// back to auto-detection (Docker preferred, then Podman).
28+
func currentEngine() containerEngine {
29+
switch strings.ToLower(strings.TrimSpace(containerRuntime)) {
30+
case "podman":
31+
return containerEngine{rt: "podman"}
32+
case "docker":
33+
return containerEngine{rt: "docker"}
34+
default: // auto
35+
if commandAvailable("docker") {
36+
return containerEngine{rt: "docker"}
37+
}
38+
if commandAvailable("podman-compose") || commandAvailable("podman") {
39+
return containerEngine{rt: "podman"}
40+
}
41+
// Nothing detected — default to docker so callers surface a clear
42+
// "docker not found" error rather than a confusing one.
43+
return containerEngine{rt: "docker"}
44+
}
45+
}
46+
47+
func commandAvailable(bin string) bool {
48+
_, err := exec.LookPath(bin)
49+
return err == nil
50+
}
51+
52+
// cli returns the container CLI binary (docker/podman) used for run/pull/inspect.
53+
func (e containerEngine) cli() string { return e.rt }
54+
55+
// isPodman reports whether this engine is Podman.
56+
func (e containerEngine) isPodman() bool { return e.rt == "podman" }
57+
58+
// composeArgv returns the binary and full argument list for a compose
59+
// subcommand against the given file.
60+
func (e containerEngine) composeArgv(file string, args ...string) (string, []string) {
61+
if e.rt == "podman" {
62+
full := []string{}
63+
if file != "" {
64+
full = append(full, "-f", file)
65+
}
66+
return "podman-compose", append(full, args...)
67+
}
68+
full := []string{"compose"}
69+
if file != "" {
70+
full = append(full, "-f", file)
71+
}
72+
return "docker", append(full, args...)
73+
}
74+
75+
// composeCommand builds an *exec.Cmd for a compose subcommand against file.
76+
func (e containerEngine) composeCommand(file string, args ...string) *exec.Cmd {
77+
bin, full := e.composeArgv(file, args...)
78+
return exec.Command(bin, full...)
79+
}
80+
81+
// composeDisplay returns a human-readable command string for logging.
82+
func (e containerEngine) composeDisplay(file string, args ...string) string {
83+
bin, full := e.composeArgv(file, args...)
84+
return bin + " " + strings.Join(full, " ")
85+
}
86+
87+
// composeAvailable reports whether the compose tooling for this engine is
88+
// installed, returning a helpful message when it is not.
89+
func (e containerEngine) composeAvailable() (bool, string) {
90+
if e.rt == "podman" {
91+
if !commandAvailable("podman-compose") {
92+
return false, "podman runtime selected but 'podman-compose' is not installed.\n" +
93+
" Install it with 'pip install podman-compose' (or 'apt-get install podman-compose'),\n" +
94+
" or provision with: ai-studio-cli setup vllm --runtime podman"
95+
}
96+
return true, ""
97+
}
98+
if !commandAvailable("docker") {
99+
return false, "docker runtime selected but 'docker' is not installed.\n" +
100+
" Install it with: ai-studio-cli setup vllm"
101+
}
102+
return true, ""
103+
}
104+
105+
// dockerGPUBlock and podmanGPUBlock are the GPU passthrough stanzas for the
106+
// bundled compose file. Docker uses the compose `deploy.resources` reservation;
107+
// podman-compose honours CDI devices instead. composeForRuntime swaps one for
108+
// the other so the rest of the bundled compose file stays byte-identical.
109+
const dockerGPUBlock = ` deploy:
110+
resources:
111+
reservations:
112+
devices:
113+
- driver: nvidia
114+
count: all
115+
capabilities: [gpu]`
116+
117+
const podmanGPUBlock = ` devices:
118+
- nvidia.com/gpu=all
119+
security_opt:
120+
- label=disable`
121+
122+
// composeForRuntime adapts the bundled compose bytes for the target runtime.
123+
// For docker it is a no-op; for podman it rewrites the GPU block to the CDI
124+
// device form that podman-compose understands.
125+
func composeForRuntime(raw []byte, e containerEngine) []byte {
126+
if !e.isPodman() {
127+
return raw
128+
}
129+
return []byte(strings.Replace(string(raw), dockerGPUBlock, podmanGPUBlock, 1))
130+
}

0 commit comments

Comments
 (0)