Skip to content

Commit d50b75a

Browse files
authored
Merge pull request #948 from Dstack-TEE/codex/fix-guest-gateway-outage-boot
fix(guest): decouple Gateway outage from app boot
2 parents ac9b9fd + b5b111f commit d50b75a

12 files changed

Lines changed: 831 additions & 138 deletions

File tree

dstack/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dstack/dstack-util/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ serde.workspace = true
2525
serde-human-bytes.workspace = true
2626
semver.workspace = true
2727
serde_json.workspace = true
28+
sd-notify.workspace = true
2829
sha2.workspace = true
2930
tokio = { workspace = true, features = ["full"] }
3031
tracing.workspace = true

dstack/dstack-util/src/gateway_checker.rs

Lines changed: 676 additions & 0 deletions
Large diffs are not rendered by default.

dstack/dstack-util/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use clap::{Parser, Subcommand};
77
use dstack_attest::emit_runtime_event;
88
use dstack_types::{KeyProvider, KeyProviderKind};
99
use fs_err as fs;
10+
use gateway_checker::{cmd_gateway_checker, GatewayCheckerArgs};
1011
use getrandom::fill as getrandom;
1112
use host_api::HostApi;
1213
use k256::schnorr::SigningKey;
@@ -30,6 +31,7 @@ use utils::AppKeys;
3031

3132
mod crypto;
3233
mod docker_compose;
34+
mod gateway_checker;
3335
mod host_api;
3436
mod host_shared;
3537
mod parse_env_file;
@@ -72,6 +74,8 @@ enum Commands {
7274
HostShared(host_shared::HostSharedArgs),
7375
/// Refresh the dstack gateway configuration
7476
GatewayRefresh(GatewayRefreshArgs),
77+
/// Keep the dstack gateway registration fresh (long-running)
78+
GatewayChecker(GatewayCheckerArgs),
7579
/// Notify the host about the dstack app
7680
NotifyHost(HostNotifyArgs),
7781
/// Remove orphaned containers
@@ -1322,6 +1326,9 @@ async fn main() -> Result<()> {
13221326
cmd_sys_setup(args).await?;
13231327
}
13241328
Commands::HostShared(args) => host_shared::cmd_host_shared(args)?,
1329+
Commands::GatewayChecker(args) => {
1330+
cmd_gateway_checker(args).await?;
1331+
}
13251332
Commands::GatewayRefresh(args) => {
13261333
cmd_gateway_refresh(args).await?;
13271334
}

dstack/dstack-util/src/system_setup.rs

Lines changed: 85 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,9 @@ impl HostShared {
320320
}
321321

322322
const GATEWAY_CACHE_PATH: &str = "/run/dstack/gateway-cache.json";
323-
const WG_CONFIG_PATH: &str = "/etc/wireguard/dstack-wg0.conf";
323+
/// Name of the WireGuard interface linking this CVM to dstack-gateway.
324+
pub const WG_INTERFACE: &str = "dstack-wg0";
325+
pub const WG_CONFIG_PATH: &str = "/etc/wireguard/dstack-wg0.conf";
324326
/// Certificate validity period in seconds (10 days)
325327
const CERT_VALIDITY_SECS: u64 = 10 * 24 * 3600;
326328
const MAX_SUPPORTED_MANIFEST_VERSION: u32 = 3;
@@ -555,6 +557,14 @@ impl<'a> GatewayContext<'a> {
555557
// Get or generate key store (includes WireGuard keys and client certificate)
556558
let key_store = self.get_or_generate_key_store().await?;
557559

560+
// Persist the key store before attempting registration. Minting it costs a
561+
// KMS round-trip, two cert signing requests and a TDX quote, so a gateway
562+
// outage would otherwise make every retry pay that price again and turn a
563+
// gateway outage into a KMS load spike across the whole fleet.
564+
if let Err(e) = key_store.save() {
565+
warn!("failed to save gateway cache: {e:?}");
566+
}
567+
558568
if self.shared.sys_config.gateway_urls.is_empty() {
559569
bail!("Missing gateway urls");
560570
}
@@ -604,11 +614,6 @@ impl<'a> GatewayContext<'a> {
604614
));
605615
}
606616

607-
// Save cache
608-
if let Err(e) = key_store.save() {
609-
warn!("Failed to save gateway cache: {e:?}");
610-
}
611-
612617
// Check if config has changed (skip check if force is set)
613618
if !force {
614619
let current_config = fs::read_to_string(WG_CONFIG_PATH).ok();
@@ -1931,19 +1936,61 @@ impl Stage0<'_> {
19311936
}
19321937
}
19331938

1934-
pub async fn cmd_gateway_refresh(args: GatewayRefreshArgs) -> Result<()> {
1935-
let host_shared_dir = args.work_dir.join(HOST_SHARED_DIR_NAME);
1936-
let shared = HostShared::load(host_shared_dir.as_path()).with_context(|| {
1937-
format!(
1938-
"Failed to load host-shared dir: {}",
1939-
host_shared_dir.display()
1940-
)
1941-
})?;
1942-
let keys_path = shared.dir.join(APP_KEYS);
1943-
let keys: AppKeys = deserialize_json_file(&keys_path)
1944-
.with_context(|| format!("Failed to load app keys from {}", keys_path.display()))?;
1939+
/// Owns the inputs needed to (re)register this CVM with dstack-gateway.
1940+
///
1941+
/// Loading is separated from refreshing so a long-running caller (the gateway
1942+
/// checker) can pay the parsing cost once and then refresh repeatedly.
1943+
pub struct GatewayRefresher {
1944+
shared: HostShared,
1945+
keys: AppKeys,
1946+
}
1947+
1948+
impl GatewayRefresher {
1949+
/// Load the host-shared config and app keys from `work_dir`.
1950+
pub fn load(work_dir: &Path) -> Result<Self> {
1951+
let host_shared_dir = work_dir.join(HOST_SHARED_DIR_NAME);
1952+
let shared = HostShared::load(host_shared_dir.as_path()).with_context(|| {
1953+
format!(
1954+
"Failed to load host-shared dir: {}",
1955+
host_shared_dir.display()
1956+
)
1957+
})?;
1958+
let keys_path = shared.dir.join(APP_KEYS);
1959+
let keys: AppKeys = deserialize_json_file(&keys_path)
1960+
.with_context(|| format!("Failed to load app keys from {}", keys_path.display()))?;
1961+
Ok(Self { shared, keys })
1962+
}
1963+
1964+
/// Whether this app opted into dstack-gateway at all.
1965+
pub fn gateway_enabled(&self) -> bool {
1966+
self.shared.app_compose.gateway_enabled()
1967+
}
19451968

1946-
GatewayContext::new(&shared, &keys).setup(args.force).await
1969+
/// Validate the parts of the gateway config that can never become valid by
1970+
/// waiting. These are deployment mistakes, not outages, so callers that
1971+
/// retry should give up instead of looping forever.
1972+
pub fn check_config(&self) -> Result<()> {
1973+
if self.keys.gateway_app_id.is_empty() {
1974+
bail!("Missing allowed dstack-gateway app id");
1975+
}
1976+
if self.shared.sys_config.gateway_urls.is_empty() {
1977+
bail!("Missing gateway urls");
1978+
}
1979+
Ok(())
1980+
}
1981+
1982+
/// Register with dstack-gateway and apply the returned WireGuard config.
1983+
pub async fn refresh(&self, force: bool) -> Result<()> {
1984+
GatewayContext::new(&self.shared, &self.keys)
1985+
.setup(force)
1986+
.await
1987+
}
1988+
}
1989+
1990+
pub async fn cmd_gateway_refresh(args: GatewayRefreshArgs) -> Result<()> {
1991+
GatewayRefresher::load(&args.work_dir)?
1992+
.refresh(args.force)
1993+
.await
19471994
}
19481995

19491996
struct AppIdValidator {
@@ -2847,9 +2894,27 @@ impl Stage1<'_> {
28472894
self.vmm
28482895
.notify_q("boot.progress", "setting up dstack-gateway")
28492896
.await;
2850-
GatewayContext::new(&self.shared, &self.keys)
2897+
if let Err(error) = GatewayContext::new(&self.shared, &self.keys)
28512898
.setup(true)
2852-
.await?;
2899+
.await
2900+
{
2901+
warn!(
2902+
"dstack-gateway registration is unavailable during boot; continuing without a route: {error:#}"
2903+
);
2904+
// Boot no longer fails here, so a guest log line would be the only
2905+
// trace of it: the VM would report a clean boot while having no
2906+
// ingress at all. Report it to the host so the degraded state is
2907+
// visible from the VMM. The gateway checker clears this once it
2908+
// manages to register.
2909+
self.vmm
2910+
.notify_q(
2911+
"boot.error",
2912+
&format!(
2913+
"dstack-gateway registration failed, the app has no ingress route: {error:#}"
2914+
),
2915+
)
2916+
.await;
2917+
}
28532918
self.vmm
28542919
.notify_q("boot.progress", "setting up docker")
28552920
.await;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
[Unit]
2+
Description=dstack Gateway Registration Checker
3+
After=network-online.target dstack-prepare.service
4+
Wants=network-online.target
5+
6+
[Service]
7+
Type=notify
8+
ExecStart=/bin/dstack-util gateway-checker --work-dir /dstack
9+
# Every recovery path in the checker only runs while its loop runs, and a loop
10+
# that wedges leaves a healthy-looking process systemd would never restart. The
11+
# loop pings the watchdog itself, so a hang anywhere -- including the blocking
12+
# wg-quick/iptables shell-outs a refresh performs, which no in-process timeout
13+
# can cancel -- gets the service killed and restarted. The timeout is generous
14+
# because one refresh may legitimately spend minutes across KMS certificate
15+
# requests and every configured gateway URL.
16+
WatchdogSec=600
17+
# The checker exits 0 when the app never enabled dstack-gateway, because there
18+
# is then nothing to supervise. Restart=always would respawn that exit forever.
19+
Restart=on-failure
20+
RestartSec=10
21+
# Exit code 3 means the gateway config is broken in a way retrying cannot fix
22+
# (no gateway app id, no gateway URLs). Both are fixed for the lifetime of the
23+
# VM, so respawning every RestartSec would just be a slower spin. Stop
24+
# restarting but stay in `failed` state so the mistake is visible. Keep in sync
25+
# with EXIT_MISCONFIGURED in dstack-util's gateway_checker.
26+
RestartPreventExitStatus=3
27+
StandardOutput=journal
28+
StandardError=journal+console
29+
30+
[Install]
31+
WantedBy=multi-user.target

os/common/rootfs/wg-checker.service

Lines changed: 0 additions & 15 deletions
This file was deleted.

os/common/rootfs/wg-checker.sh

Lines changed: 0 additions & 97 deletions
This file was deleted.

os/mkosi/components/dstack-rust/dstack-rust-build.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ install -d "$DEST/usr/bin" "$DEST/usr/lib/systemd/system" \
99
"$DEST/etc/systemd/journald.conf.d" "$DEST/etc/systemd/resolved.conf.d" \
1010
"$DEST/etc/systemd/system/docker.service.d" \
1111
"$DEST/etc/systemd/system/containerd.service.d" "$DEST/etc/sysctl.d"
12-
for s in dstack-prepare ephemeral-docker wg-checker app-compose; do
12+
for s in dstack-prepare ephemeral-docker app-compose; do
1313
install -m0755 "$ROOT/os/common/rootfs/$s.sh" "$DEST/usr/bin/$s.sh"
1414
done
1515
install -m0644 "$ROOT/os/common/rootfs/"*.service \

os/mkosi/mkosi.skeleton/usr/lib/systemd/system-preset/80-dstack.preset

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ enable dstack-prepare.service
1010
enable dstack-guest-agent.socket
1111
enable dstack-guest-agent.service
1212
enable app-compose.service
13-
enable wg-checker.service
13+
enable dstack-gateway-checker.service
1414
enable nvidia-persistenced.service
1515
enable nvidia-fabricmanager.service
1616
enable containerd-stargz-grpc.service

0 commit comments

Comments
 (0)