Skip to content

Commit 2c2604b

Browse files
authored
Merge pull request #807 from Dstack-TEE/codex/nerdctl-compose-runner
feat: add nerdctl Compose runner with stargz support
2 parents 6944db8 + dceca70 commit 2c2604b

17 files changed

Lines changed: 443 additions & 42 deletions

File tree

docs/nerdctl-compose.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# nerdctl Compose and lazy image pulling
2+
3+
dstack supports two independent Compose runners. The runner is part of
4+
`app-compose.json`, so it is included in the compose hash and attestation.
5+
6+
| `runner` | Image manager | `snapshotter` support |
7+
|---|---|---|
8+
| `docker-compose` | Docker Engine | none (Docker's overlayfs store) |
9+
| `nerdctl-compose` | containerd | `overlayfs` or `stargz` |
10+
11+
## Deploy with stargz
12+
13+
Build and push an eStargz image before deployment. For example, with Buildx:
14+
15+
```bash
16+
docker buildx build -t registry.example.com/example/app:estargz \
17+
--output type=registry,oci-mediatypes=true,compression=estargz,force-compression=true \
18+
.
19+
```
20+
21+
Reference that image from `docker-compose.yaml`, then deploy it with:
22+
23+
```bash
24+
dstack deploy -c docker-compose.yaml \
25+
--runner nerdctl-compose \
26+
--snapshotter stargz
27+
```
28+
29+
The resulting application manifest contains:
30+
31+
```json
32+
{
33+
"manifest_version": "3",
34+
"runner": "nerdctl-compose",
35+
"snapshotter": "stargz"
36+
}
37+
```
38+
39+
Use containerd without lazy pulling by selecting overlayfs:
40+
41+
```bash
42+
dstack deploy -c docker-compose.yaml \
43+
--runner nerdctl-compose \
44+
--snapshotter overlayfs
45+
```
46+
47+
If `snapshotter` is omitted for `nerdctl-compose`, it defaults to `overlayfs`.
48+
Setting `snapshotter` with `docker-compose` is rejected rather than silently
49+
ignored.
50+
51+
## Compatibility
52+
53+
`nerdctl compose` implements the commonly used Docker Compose features, but it
54+
is not a drop-in implementation of every Docker-specific extension. Test
55+
applications that use Docker socket mounts, custom runtimes, or advanced
56+
networking before switching runners. The `nerdctl-compose` runner requires
57+
pre-built images and rejects Compose `build` sections. This avoids depending
58+
on an in-guest BuildKit daemon and ensures lazy-pull images were converted
59+
before deployment.
60+
61+
Both backends keep their own image and container metadata. Changing the runner
62+
recreates the application through the selected backend; it does not migrate
63+
existing Docker containers into containerd.

dstack/crates/dstack-cli-core/src/compose.rs

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,28 @@ use serde_json::json;
1313
/// `kms_enabled` selects KMS mode (deterministic, upgradeable per-app keys);
1414
/// gateway and local-key-provider are off for the direct-port single-node flow.
1515
pub fn build_app_compose(name: &str, docker_compose_yaml: &str, kms_enabled: bool) -> String {
16-
let manifest = json!({
17-
"manifest_version": 2,
16+
build_app_compose_with_runtime(
17+
name,
18+
docker_compose_yaml,
19+
kms_enabled,
20+
"docker-compose",
21+
None,
22+
)
23+
}
24+
25+
/// Build an app-compose manifest with an explicitly selected compose frontend.
26+
/// `snapshotter` is meaningful only for `nerdctl-compose`.
27+
pub fn build_app_compose_with_runtime(
28+
name: &str,
29+
docker_compose_yaml: &str,
30+
kms_enabled: bool,
31+
runner: &str,
32+
snapshotter: Option<&str>,
33+
) -> String {
34+
let mut manifest = json!({
35+
"manifest_version": if runner == "nerdctl-compose" { json!("3") } else { json!(2) },
1836
"name": name,
19-
"runner": "docker-compose",
37+
"runner": runner,
2038
"docker_compose_file": docker_compose_yaml,
2139
"kms_enabled": kms_enabled,
2240
"gateway_enabled": false,
@@ -31,7 +49,30 @@ pub fn build_app_compose(name: &str, docker_compose_yaml: &str, kms_enabled: boo
3149
// (NTS is also currently broken in guest images — see dstack#745.)
3250
"secure_time": false,
3351
});
52+
if let Some(snapshotter) = snapshotter {
53+
manifest["snapshotter"] = json!(snapshotter);
54+
}
3455
// pretty-print via Value's Display (`{:#}`) — infallible, and byte-identical
3556
// to serde_json::to_string_pretty (avoids an expect on an unfailable Result).
3657
format!("{manifest:#}")
3758
}
59+
60+
#[cfg(test)]
61+
mod tests {
62+
use super::*;
63+
64+
#[test]
65+
fn nerdctl_manifest_uses_v3_and_records_snapshotter() {
66+
let body = build_app_compose_with_runtime(
67+
"test",
68+
"services: {}",
69+
false,
70+
"nerdctl-compose",
71+
Some("stargz"),
72+
);
73+
let value: serde_json::Value = serde_json::from_str(&body).unwrap();
74+
assert_eq!(value["manifest_version"], "3");
75+
assert_eq!(value["runner"], "nerdctl-compose");
76+
assert_eq!(value["snapshotter"], "stargz");
77+
}
78+
}

dstack/crates/dstack-cli/src/main.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
//! `logs`, a global `-j/--json`).
1212
1313
use anyhow::{bail, Context, Result};
14-
use clap::{Parser, Subcommand};
14+
use clap::{Parser, Subcommand, ValueEnum};
1515
use dstack_cli_core::layout::InstallLayout;
1616
use dstack_cli_core::vmm::{Vmm, DEFAULT_HOST};
1717
use dstack_cli_core::{compose, ports, rpc};
@@ -46,6 +46,37 @@ struct Cli {
4646
command: Command,
4747
}
4848

49+
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
50+
enum ComposeRunner {
51+
#[default]
52+
DockerCompose,
53+
NerdctlCompose,
54+
}
55+
56+
impl ComposeRunner {
57+
fn as_str(self) -> &'static str {
58+
match self {
59+
Self::DockerCompose => "docker-compose",
60+
Self::NerdctlCompose => "nerdctl-compose",
61+
}
62+
}
63+
}
64+
65+
#[derive(Clone, Copy, Debug, ValueEnum)]
66+
enum Snapshotter {
67+
Overlayfs,
68+
Stargz,
69+
}
70+
71+
impl Snapshotter {
72+
fn as_str(self) -> &'static str {
73+
match self {
74+
Self::Overlayfs => "overlayfs",
75+
Self::Stargz => "stargz",
76+
}
77+
}
78+
}
79+
4980
#[derive(Subcommand)]
5081
enum Command {
5182
/// Deploy an app from a docker-compose file.
@@ -84,6 +115,12 @@ enum Command {
84115
/// build + hash the compose and print it, without deploying.
85116
#[arg(long)]
86117
dry_run: bool,
118+
/// compose frontend used inside the guest.
119+
#[arg(long, value_enum, default_value = "docker-compose")]
120+
runner: ComposeRunner,
121+
/// containerd snapshotter (supported only with --runner nerdctl-compose).
122+
#[arg(long, value_enum)]
123+
snapshotter: Option<Snapshotter>,
87124
},
88125
/// List deployed apps.
89126
Apps,
@@ -144,6 +181,8 @@ async fn main() -> Result<()> {
144181
no_kms,
145182
allowlist,
146183
dry_run,
184+
runner,
185+
snapshotter,
147186
} => {
148187
let compose = resolve_compose_arg(compose, compose_file)?;
149188
let image = if use_local_defaults {
@@ -174,6 +213,8 @@ async fn main() -> Result<()> {
174213
allowlist.as_deref(),
175214
dry_run,
176215
json,
216+
runner,
217+
snapshotter,
177218
)
178219
.await
179220
}
@@ -347,10 +388,21 @@ async fn cmd_deploy(
347388
allowlist: Option<&str>,
348389
dry_run: bool,
349390
json: bool,
391+
runner: ComposeRunner,
392+
snapshotter: Option<Snapshotter>,
350393
) -> Result<()> {
394+
if matches!(runner, ComposeRunner::DockerCompose) && snapshotter.is_some() {
395+
bail!("--snapshotter is only supported with --runner nerdctl-compose");
396+
}
351397
let yaml = std::fs::read_to_string(compose_path)
352398
.with_context(|| format!("reading compose file '{compose_path}'"))?;
353-
let app_compose = compose::build_app_compose(name, &yaml, !no_kms);
399+
let app_compose = compose::build_app_compose_with_runtime(
400+
name,
401+
&yaml,
402+
!no_kms,
403+
runner.as_str(),
404+
snapshotter.map(Snapshotter::as_str),
405+
);
354406

355407
let mut port_maps = Vec::new();
356408
for spec in port_specs {

dstack/dstack-types/src/lib.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ pub struct AppCompose {
8080
#[serde(default)]
8181
pub features: Vec<String>,
8282
pub runner: String,
83+
/// containerd snapshotter used by the `nerdctl-compose` runner.
84+
/// The field is invalid for other runners.
85+
#[serde(default, skip_serializing_if = "Option::is_none")]
86+
pub snapshotter: Option<ContainerSnapshotter>,
8387
#[serde(default)]
8488
pub docker_compose_file: Option<String>,
8589
#[serde(default)]
@@ -121,6 +125,13 @@ pub struct AppCompose {
121125
pub requirements: Option<Requirements>,
122126
}
123127

128+
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
129+
#[serde(rename_all = "kebab-case")]
130+
pub enum ContainerSnapshotter {
131+
Overlayfs,
132+
Stargz,
133+
}
134+
124135
/// Canonical source for the policy used when `requirements.gpu_policy` is
125136
/// absent. Both typed defaults and measurement are derived from this JSON.
126137
pub const DEFAULT_GPU_POLICY: &str = "{}";
@@ -445,6 +456,26 @@ mod app_compose_tests {
445456
assert_eq!(compose.manifest_version_u32(), Some(3));
446457
}
447458

459+
#[test]
460+
fn parses_supported_container_snapshotters() {
461+
let compose: AppCompose = serde_json::from_value(serde_json::json!({
462+
"manifest_version": "3",
463+
"name": "test",
464+
"runner": "nerdctl-compose",
465+
"snapshotter": "stargz"
466+
}))
467+
.unwrap();
468+
assert_eq!(compose.snapshotter, Some(ContainerSnapshotter::Stargz));
469+
470+
let invalid = serde_json::from_value::<AppCompose>(serde_json::json!({
471+
"manifest_version": "3",
472+
"name": "test",
473+
"runner": "nerdctl-compose",
474+
"snapshotter": "unknown"
475+
}));
476+
assert!(invalid.is_err());
477+
}
478+
448479
#[test]
449480
fn manifest_version_accepts_legacy_numeric_1_and_2() {
450481
assert_eq!(

dstack/dstack-util/src/system_setup.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,14 @@ fn verify_manifest_feature_requirements(app_compose: &AppCompose) -> Result<()>
839839
"requirements requires manifest_version >= {MANIFEST_VERSION_3}; use string manifest_version \"{MANIFEST_VERSION_3}\" so older guests fail closed"
840840
);
841841
}
842+
if app_compose.runner == "nerdctl-compose" && manifest_version < MANIFEST_VERSION_3 {
843+
bail!(
844+
"nerdctl-compose requires manifest_version >= {MANIFEST_VERSION_3}; use string manifest_version \"{MANIFEST_VERSION_3}\" so older guests fail closed"
845+
);
846+
}
847+
if app_compose.runner != "nerdctl-compose" && app_compose.snapshotter.is_some() {
848+
bail!("snapshotter is only supported by the nerdctl-compose runner");
849+
}
842850
Ok(())
843851
}
844852

@@ -3162,6 +3170,27 @@ fn test_os_version_requirement_requires_v3_manifest() {
31623170
assert!(err.to_string().contains("requires manifest_version"));
31633171
}
31643172

3173+
#[test]
3174+
fn test_nerdctl_compose_requires_v3_manifest() {
3175+
let mut app_compose = test_app_compose(serde_json::json!(2), None, None);
3176+
app_compose.runner = "nerdctl-compose".to_string();
3177+
let err = verify_manifest_feature_requirements(&app_compose).unwrap_err();
3178+
assert!(err.to_string().contains("nerdctl-compose requires"));
3179+
3180+
app_compose.manifest_version = "3".to_string();
3181+
verify_manifest_feature_requirements(&app_compose).unwrap();
3182+
}
3183+
3184+
#[test]
3185+
fn test_snapshotter_is_rejected_for_other_runners() {
3186+
let mut app_compose = test_app_compose(serde_json::json!("3"), None, None);
3187+
app_compose.snapshotter = Some(dstack_types::ContainerSnapshotter::Stargz);
3188+
let err = verify_manifest_feature_requirements(&app_compose).unwrap_err();
3189+
assert!(err
3190+
.to_string()
3191+
.contains("snapshotter is only supported by the nerdctl-compose runner"));
3192+
}
3193+
31653194
#[test]
31663195
fn test_os_version_requirement_rejects_too_old_os() {
31673196
let app_compose = test_app_compose(serde_json::json!("3"), Some(">=0.6.1"), None);

dstack/guest-agent/src/rpc_service.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,7 @@ mod tests {
735735
name: String::new(),
736736
features: Vec::new(),
737737
runner: String::new(),
738+
snapshotter: None,
738739
docker_compose_file: None,
739740
public_logs: false,
740741
public_sysinfo: false,

os/common/rootfs/app-compose.service

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
[Unit]
22
Description=App Compose Service
3-
Wants=docker.service
4-
After=docker.service dstack-prepare.service dstack-guest-agent.service
3+
Wants=docker.service containerd.service containerd-stargz-grpc.service
4+
After=docker.service containerd.service containerd-stargz-grpc.service dstack-prepare.service dstack-guest-agent.service
55

66
[Service]
77
Type=oneshot
88
RemainAfterExit=true
99
EnvironmentFile=-/dstack/.host-shared/.decrypted-env
1010
WorkingDirectory=/dstack
1111
ExecStart=/bin/app-compose.sh
12-
ExecStop=/bin/docker compose stop
12+
ExecStop=/bin/app-compose.sh stop
1313
StandardOutput=journal+console
1414
StandardError=journal+console
1515

0 commit comments

Comments
 (0)