Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@

## Unreleased

- **Workers no longer clean up after releases older than the move to UCloud.**
Every production worker was first set up on UCloud, and none carries the
leftovers, so these one-off cleanups are gone: per-sandbox
`ucloud_relay_<slot>` nftables tables and per-interface `FORWARD -i <if> -j
ACCEPT` rules (relay networking), the `ucloud-sandbox-heartbeat` timer and
service (vm_init), and the shared lock on the hibernation store's root-wide
`.store.lock`. Other cleanups that went with them:
- Only the pinned gVisor (`GVISOR_COMMIT`) with its build manifest and
companion binaries is accepted: deploy validation and the agent refuse any
other `sandbox.direct_runsc_commit`. Production runs the pin, so checkpoint
fingerprints do not change.
- Resident-memory sampling (agent and noded) reads only the cgroup a bundle's
`linux.cgroupsPath` names; a bundle without one is not sampled. Bundles
from 0.10.0 and this release always name one.
- `.ucloud-overlay.json` is always written and read as schema 2; Docker-store
bundles used to be written as schema 1. Production workers use host-EROFS
environments, which were already schema 2. On a Docker-store host, wake or
delete parked sandboxes before upgrading.
- Rollback to 0.10.0 needs nothing: 0.10.0 reads schema-2 bundles and still
runs the old cleanups, which find nothing to do.

- **Workers always run behind ucloud-noded, with the pause tier, node-local
model waits and split memory backing.** `sandbox.direct_node_front_door`,
`direct_node_rust_create`, `direct_node_rust_exec`, `direct_node_rust_pause`,
Expand Down
3 changes: 2 additions & 1 deletion runtime/noded/src/pause_policy/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,8 @@ impl Engine {
path
}
};
self.sampler.sample(key, u32::try_from(pid).ok()?, ticks, &sandbox.container_id, path.as_deref())
// Every bundle names its incarnation's cgroup; never guess one.
self.sampler.sample(key, u32::try_from(pid).ok()?, ticks, path.as_deref()?)
}

/// The sampling half of `refresh_resident_memory`: sample every owned,
Expand Down
20 changes: 8 additions & 12 deletions runtime/noded/src/pause_policy/resident.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,8 @@ impl ResidentSampler {

/// Sample one incarnation's cgroup; `None` (and the cache entry dropped)
/// on any doubt.
pub fn sample(&self, key: &Key, pid: u32, start_time_ticks: u64, container_id: &str, expected_path: Option<&str>) -> Option<ResidentSample> {
let result = self.measure(key, pid, start_time_ticks, container_id, expected_path);
pub fn sample(&self, key: &Key, pid: u32, start_time_ticks: u64, expected_path: &str) -> Option<ResidentSample> {
let result = self.measure(key, pid, start_time_ticks, expected_path);
let mut state = self.state();
match result {
Some((sample, proof)) => {
Expand All @@ -174,7 +174,7 @@ impl ResidentSampler {
}
}

fn measure(&self, key: &Key, pid: u32, ticks: u64, container_id: &str, expected_path: Option<&str>) -> Option<(ResidentSample, (String, u64, u64))> {
fn measure(&self, key: &Key, pid: u32, ticks: u64, expected_path: &str) -> Option<(ResidentSample, (String, u64, u64))> {
let same_sentry = || crate::runsc::start_time_ticks(&self.proc_root, pid).is_ok_and(|now| now == ticks);
if !same_sentry() {
return None;
Expand All @@ -184,14 +184,9 @@ impl ResidentSampler {
let [raw] = unified[..] else { return None };
let relative = raw.strip_prefix('/')?;
let parts: Vec<&str> = relative.split('/').filter(|part| !part.is_empty()).collect();
if parts.is_empty() || raw.split('/').skip(1).any(|part| part == "." || part == "..") {
if parts.is_empty() || raw.split('/').skip(1).any(|part| part == "." || part == "..") || raw != expected_path {
return None;
}
match expected_path {
Some(expected) if raw != expected => return None,
None if parts.last() != Some(&container_id) => return None, // Never a shared parent.
_ => {}
}
let path = self.cgroup_root.join(relative);
let mut identity = std::fs::metadata(&path).ok()?;
let proof = self.state().contained.get(key).cloned();
Expand Down Expand Up @@ -308,8 +303,7 @@ impl Reclaimer<'_> {
}
let relative = sample.cgroup_path.trim_start_matches('/');
let path = self.sampler.cgroup_root.join(relative);
let container_id = Path::new(relative).file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
let resample = || self.sampler.sample(key, sample.sentry_pid, sample.sentry_start_time_ticks, &container_id, Some(&sample.cgroup_path));
let resample = || self.sampler.sample(key, sample.sentry_pid, sample.sentry_start_time_ticks, &sample.cgroup_path);
let same_cgroup = |observed: &ResidentSample| (observed.cgroup_device, observed.cgroup_inode) == (sample.cgroup_device, sample.cgroup_inode);
let (mut requested, mut current, mut reason) = (0u64, sample.clone(), "target_reached");
let outcome = (|| -> std::io::Result<()> {
Expand Down Expand Up @@ -449,8 +443,10 @@ pub(crate) mod tests {
std::fs::write(self.proc.join("123/stat"), format!("123 (sentry) S {}{ticks} 0", "0 ".repeat(18))).unwrap();
}

/// Samples against `expected`, else the incarnation's own cgroup.
pub fn sample(&self, expected: Option<&str>) -> Option<ResidentSample> {
self.sampler.sample(&("s".into(), 1), 123, 456, &self.container, expected)
let own = format!("/ucloud-sandboxes/{}", self.container);
self.sampler.sample(&("s".into(), 1), 123, 456, expected.unwrap_or(&own))
}

/// `_anonymous(current)`: all of it tmpfs.
Expand Down
24 changes: 11 additions & 13 deletions runtime/noded/tests/resident_python_parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ before = time.monotonic()
sampled = {}
for case in request["cases"]:
sample = sampler.sample((case["name"], 1), pid=case["pid"], start_time_ticks=case["ticks"],
container_id=case["container_id"], expected_path=case["expected_path"])
expected_path=case["expected_path"])
sampled[case["name"]] = None if sample is None else dataclasses.asdict(sample)
published = PublishedResidentMemory(Path(request["resident_path"]), ResidentMemorySampler(
cgroup_root=Path(request["cgroup_root"]), proc_root=Path(request["proc_root"])))
Expand Down Expand Up @@ -81,8 +81,8 @@ struct Case {
name: &'static str,
/// `/proc/<pid>/cgroup`; `{c}` is the container's cgroup path.
membership: &'static str,
/// The OCI config's path (`{c}` as above), or none (legacy).
expected: Option<&'static str>,
/// The OCI config's path (`{c}` as above).
expected: &'static str,
/// The sentry's start ticks on disk (the caller expects 456).
ticks: u64,
files: &'static [(&'static str, &'static str)],
Expand All @@ -93,7 +93,7 @@ struct Case {
const STAT: &str = "shmem 0\nanon 100\nfile 800\nfile_dirty 50\nfile_writeback 20\nworkingset_refault_file 7\n";

fn cases() -> Vec<Case> {
let case = |name, files, accepted| Case { name, membership: "0::{c}", expected: Some("{c}"), ticks: 456, files, accepted };
let case = |name, files, accepted| Case { name, membership: "0::{c}", expected: "{c}", ticks: 456, files, accepted };
vec![
case("base", &[("memory.current", "1000"), ("memory.stat", STAT)], true),
case("peak-and-swap", &[("memory.current", "1000"), ("memory.peak", "900"), ("memory.swap.current", "400"), ("memory.stat", STAT)], true),
Expand Down Expand Up @@ -128,10 +128,9 @@ fn cases() -> Vec<Case> {
case("three-field-stat-line", &[("memory.current", "1000"), ("memory.stat", "shmem 0 1\nanon 100\nfile 800\nfile_dirty 50\nfile_writeback 20\nworkingset_refault_file 7\n")], false),
case("blank-stat-line", &[("memory.current", "1000"), ("memory.stat", "shmem 0\n\nanon 100\nfile 800\nfile_dirty 50\nfile_writeback 20\nworkingset_refault_file 7\n")], false),
Case { ticks: 999, ..case("pid-reused", &[("memory.current", "1000"), ("memory.stat", STAT)], false) },
Case { expected: Some("/another/runtime"), ..case("not-the-oci-cgroup", &[("memory.current", "1000"), ("memory.stat", STAT)], false) },
Case { expected: None, ..case("legacy-by-container-id", &[("memory.current", "1000"), ("memory.stat", STAT)], true) },
Case { expected: None, membership: "0::/ucloud-sandboxes", ..case("legacy-shared-parent", &[("memory.current", "1000"), ("memory.stat", STAT)], false) },
Case { membership: "0::/ucloud-sandboxes/../{c}", expected: None, ..case("dot-dot", &[("memory.current", "1000"), ("memory.stat", STAT)], false) },
Case { expected: "/another/runtime", ..case("not-the-oci-cgroup", &[("memory.current", "1000"), ("memory.stat", STAT)], false) },
Case { membership: "0::/ucloud-sandboxes", ..case("shared-parent", &[("memory.current", "1000"), ("memory.stat", STAT)], false) },
Case { membership: "0::/ucloud-sandboxes/../{c}", ..case("dot-dot", &[("memory.current", "1000"), ("memory.stat", STAT)], false) },
Case { membership: "0::/x{c}\n0::{c}\n", ..case("two-unified", &[("memory.current", "1000"), ("memory.stat", STAT)], false) },
Case { membership: "12:memory:/legacy\n0::{c}\n", ..case("v1-lines-beside-unified", &[("memory.current", "1000"), ("memory.stat", STAT)], true) },
]
Expand Down Expand Up @@ -161,8 +160,8 @@ fn the_daemon_samples_and_publishes_exactly_what_the_agent_would() {
std::fs::write(process.join("stat"), format!("{pid} (runsc-sandbox) S {}{} 0", "0 ".repeat(18), case.ticks)).unwrap();
std::fs::write(process.join("cgroup"), case.membership.replace("{c}", &path)).unwrap();
requests.push(json!({
"name": case.name, "pid": pid, "ticks": 456, "container_id": container,
"expected_path": case.expected.map(|expected| expected.replace("{c}", &path)),
"name": case.name, "pid": pid, "ticks": 456,
"expected_path": case.expected.replace("{c}", &path),
}));
}
// A runtime whose cgroup is a link out of the trusted root.
Expand All @@ -176,7 +175,7 @@ fn the_daemon_samples_and_publishes_exactly_what_the_agent_would() {
std::fs::create_dir_all(proc.join("999")).unwrap();
std::fs::write(proc.join("999/stat"), format!("999 (runsc-sandbox) S {}456 0", "0 ".repeat(18))).unwrap();
std::fs::write(proc.join("999/cgroup"), format!("0::/ucloud-sandboxes/{escaping}")).unwrap();
requests.push(json!({"name": "escaping", "pid": 999, "ticks": 456, "container_id": escaping, "expected_path": format!("/ucloud-sandboxes/{escaping}")}));
requests.push(json!({"name": "escaping", "pid": 999, "ticks": 456, "expected_path": format!("/ucloud-sandboxes/{escaping}")}));

// The daemon first, then the agent on the same trees.
let clock = Arc::new(SystemClock::new());
Expand All @@ -190,8 +189,7 @@ fn the_daemon_samples_and_publishes_exactly_what_the_agent_would() {
&(name.clone(), 1),
request["pid"].as_u64().unwrap() as u32,
456,
request["container_id"].as_str().unwrap(),
request["expected_path"].as_str(),
request["expected_path"].as_str().unwrap(),
);
if let Some(sample) = &sample {
published.push(((name, 1), sample.clone()));
Expand Down
11 changes: 5 additions & 6 deletions sandboxes/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2567,13 +2567,12 @@ def get_payload() -> dict[str, Any]:
companions = distribution_files(
plan.local_direct_runsc, plan.direct_runsc_commit
)
if companions:
companions.append(
(
plan.local_direct_runsc.parent / "build-manifest.json",
"build-manifest.json",
)
companions.append(
(
plan.local_direct_runsc.parent / "build-manifest.json",
"build-manifest.json",
)
)
for local_path, relative in companions:
remote_path = str(
PurePosixPath(plan.remote_direct_runsc_path).parent / relative
Expand Down
35 changes: 4 additions & 31 deletions sandboxes/direct_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
RELAY_FORWARD_MARK,
NetworkRelay,
apply_nft,
legacy_relay_policy_table,
parse_network_relays,
relay_bridge_rules,
relay_hosts,
Expand Down Expand Up @@ -430,11 +429,6 @@ def _remove_relay_policy(
}
if self._apply_relay_delta(relay_policy_removal(lease), remaining):
self._relay_applied.pop(lease.slot, None)
# An interface ACCEPT from an earlier release must never outlive the
# relay policy: a reused slot would bypass private-destination denies.
legacy = ("FORWARD", "-i", lease.host_interface, "-j", "ACCEPT")
if self._command_ok(("iptables", "-C", *legacy)):
self.runner(("iptables", "-D", *legacy))

def _apply_relay_delta(self, script: str, state: dict) -> bool:
"""Apply per-sandbox statements, else rebuild from durable state.
Expand Down Expand Up @@ -482,24 +476,18 @@ def _relay_fragments(
def _rebuild_relay_table(
self, fragments: Mapping[int, str], leases: Sequence[DirectNetworkLease]
) -> None:
# One transaction replaces the shared table and retires any per-sandbox
# tables from earlier releases, so there is no unguarded moment.
legacy = "".join(
f"add table inet {table}\ndelete table inet {table}\n"
for table in (legacy_relay_policy_table(lease.slot) for lease in leases)
)
# The bridge follows the latest resolution (each rebuild path resolves
# the relays just before it).
# One transaction replaces the shared table, so there is no unguarded
# moment. The bridge follows the latest resolution (each rebuild path
# resolves the relays just before it).
bridge = relay_bridge_rules(self.relays, {
name: self._relay_resolution.get(name, (0.0, ()))[1] for name in self.relays})
self._relay_table_ready = False
self.nft_runner(relay_table_rules() + bridge + "".join(fragments.values()) + legacy)
self.nft_runner(relay_table_rules() + bridge + "".join(fragments.values()))
self._relay_applied = dict(fragments)
self._relay_bridge_applied = bridge
self._relay_table_ready = True
if leases:
self._ensure_relay_forward_accept()
self._remove_legacy_relay_accepts(leases)

def _refresh_relay_policies(self, *, force: bool = False) -> None:
# Share the durable lease lock with create/delete so DNS refresh cannot
Expand Down Expand Up @@ -564,21 +552,6 @@ def _ensure_relay_forward_accept(
snapshot=snapshot,
)

def _remove_legacy_relay_accepts(
self, leases: Sequence[DirectNetworkLease]
) -> None:
# Earlier releases installed one FORWARD ACCEPT per relay interface.
snapshot = self._iptables_snapshot()
for lease in leases:
rule = ("FORWARD", "-i", lease.host_interface, "-j", "ACCEPT")
present = (
("filter", "-A", *rule) in snapshot
if snapshot is not None
else self._command_ok(("iptables", "-C", *rule))
)
if present:
self.runner(("iptables", "-D", *rule))

def _ensure_host_rules(self) -> None:
# Read a fresh kernel snapshot for this reconciliation, never a cached
# assertion that policy remains installed across requests.
Expand Down
8 changes: 3 additions & 5 deletions sandboxes/direct_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,9 @@ def boot_config_sha256(runsc: Path, runsc_commit: str, *, network: str, host_ero
boot_settings["application_memory_backend"] = "reflink-restore-v1"
elif ram_backing:
boot_settings["application_memory_backend"] = "ram-sparse-capture-v1"
companions = installed_sidecar_fingerprints(runsc, runsc_commit)
if companions:
# Preserve legacy fingerprints, but bind new checkpoints to every
# executable that can implement their kernel and restore operations.
boot_settings["gvisor_companions"] = companions
# Bind checkpoints to every executable that can implement their kernel
# and restore operations.
boot_settings["gvisor_companions"] = installed_sidecar_fingerprints(runsc, runsc_commit)
return _canonical_sha256(boot_settings)


Expand Down
6 changes: 3 additions & 3 deletions sandboxes/direct_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2293,16 +2293,16 @@ def _sample_resident(self, key, sandbox):
try:
config = json.loads((sandbox.bundle / "config.json").read_text())
path = config.get("linux", {}).get("cgroupsPath")
if path is not None and not isinstance(path, str):
return None
self._resident_cgroup_paths[key] = path
except (OSError, ValueError, TypeError):
return None
# Every bundle names its incarnation's cgroup; never guess one.
if not isinstance(path, str):
return None
return self._resident_memory.sample(
key,
pid=lifecycle.sentry_pid,
start_time_ticks=lifecycle.sentry_start_time_ticks,
container_id=sandbox.container_id,
expected_path=path,
)

Expand Down
21 changes: 12 additions & 9 deletions sandboxes/gvisor_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,24 @@
)


def _require_pinned(commit: str) -> None:
if commit != GVISOR_COMMIT:
raise ValueError(
f"gVisor runtime commit {commit} is not the pinned {GVISOR_COMMIT}"
)


def distribution_files(runsc: Path, commit: str) -> list[tuple[Path, str]]:
"""Return verified sidecars for the new pin; legacy deployments stay readable.
"""Return the verified sidecars of the pinned distribution.

The manifest is provenance inside the deployment's trusted input, not a
signature. The node bundle digest authenticates the resulting distribution.
"""
_require_pinned(commit)
manifest_path = runsc.parent / "build-manifest.json"
sidecar_dir = runsc.parent / "gvisor-bin"
if commit != GVISOR_COMMIT and not manifest_path.exists():
if sidecar_dir.exists():
raise ValueError("gVisor companion binaries require a build manifest")
return []
if not manifest_path.is_file():
raise ValueError("gVisor distribution requires a build manifest")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest.get("schema") != 2 or manifest.get("gvisor_commit") != commit:
raise ValueError("gVisor distribution manifest does not match the runtime pin")
Expand Down Expand Up @@ -70,11 +76,8 @@ def installed_sidecar_fingerprints(runsc: Path, commit: str) -> dict[str, str]:
Bootstrap has already authenticated the node bundle. Hash actual installed
bytes here so a different companion cannot reuse an old boot fingerprint.
"""
_require_pinned(commit)
directory = runsc.parent / "gvisor-bin"
if commit != GVISOR_COMMIT:
if directory.exists():
raise ValueError("legacy gVisor runtime must not share companion binaries")
return {}
if directory.is_symlink() or not directory.is_dir():
raise ValueError("installed gVisor companion directory is missing or linked")
if {path.name for path in directory.iterdir()} != set(GVISOR_SIDECARS):
Expand Down
Loading
Loading