Skip to content

Commit 5cacc0f

Browse files
authored
Merge pull request #16 from appsignal/fix/graceful-shutdown-and-mount-leak
Graceful shutdown on SIGTERM + fix rootfs mount leak that wedges slots
2 parents 78860a9 + 4e60e32 commit 5cacc0f

4 files changed

Lines changed: 149 additions & 27 deletions

File tree

manager/src/instance.rs

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,32 +188,43 @@ impl Instance {
188188
let rootfs_lv = format!("rootfs-{}-{}", self.role, self.idx);
189189
let base_lv = format!("base-{}", self.role);
190190

191+
// A mount leaked by a previous cycle (error between mount and unmount,
192+
// or the whole manager getting SIGKILLed) keeps the rootfs LV open, so
193+
// the lvremove/lvcreate below would fail on every retry — wedging this
194+
// slot permanently. Clean it up first.
195+
self.cleanup_stale_mount();
196+
191197
// Remove stale snapshot and create fresh one from base
192198
let _ = lvm::lvremove(&self.lvm.volume_group, &rootfs_lv);
193199
lvm::lvcreate_snapshot(&self.lvm.volume_group, &base_lv, &rootfs_lv)?;
194200

201+
// Fetch the registration token before mounting: it is a network call
202+
// and must not be able to fail while the rootfs is mounted.
203+
let token = self.github.registration_token()?;
204+
let runner_name = self.name();
205+
let labels = self.labels();
206+
195207
// Mount rootfs, inject config, unmount
196208
let mnt = Utf8PathBuf::from(self.work_dir.join("mnt").as_str());
197209
fs::create_dir_all(&mnt)?;
198210

199211
let rootfs_device = self.rootfs_device_path();
200212
mount::mount_image(&rootfs_device, &mnt)?;
201213

202-
let token = self.github.registration_token()?;
203-
let runner_name = self.name();
204-
let labels = self.labels();
205-
206-
inject::inject_config(
214+
let inject_result = inject::inject_config(
207215
&mnt,
208216
&self.github.org.clone(),
209217
&token,
210218
&runner_name,
211219
&labels,
212220
&self.network_allocation,
213-
)?;
221+
);
214222

215-
mount::unmount(&mnt)?;
223+
// Always unmount, even when injection failed — see cleanup_stale_mount.
224+
let unmount_result = mount::unmount(&mnt);
216225
let _ = fs::remove_dir(&mnt);
226+
inject_result?;
227+
unmount_result?;
217228

218229
// Write Firecracker config.json
219230
let config_json = serde_json::to_string(&self.firecracker_config()?)?;
@@ -253,6 +264,21 @@ impl Instance {
253264
// Nothing to reset in new model; slot loop handles retry
254265
}
255266

267+
/// Unmount and remove this slot's `mnt` directory if a previous cycle
268+
/// leaked it (error while mounted, or the manager was SIGKILLed). Mounts
269+
/// are kernel state and survive process death; a held mount makes every
270+
/// subsequent `lvremove`/`lvcreate_snapshot` for this slot fail with
271+
/// "contains a filesystem in use".
272+
pub fn cleanup_stale_mount(&self) {
273+
let mnt = self.work_dir.join("mnt");
274+
if mnt.as_std_path().exists() {
275+
if mount::unmount(&mnt).is_ok() {
276+
info!(role = %self.role, idx = self.idx, "unmounted stale rootfs mount");
277+
}
278+
let _ = fs::remove_dir(mnt.as_std_path());
279+
}
280+
}
281+
256282
#[instrument(skip(self), fields(role = %self.role, idx = self.idx))]
257283
pub fn try_clear_cache(&mut self) -> Result<()> {
258284
match self.cache.usage_pct() {

manager/src/lib.rs

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod inject;
99
pub mod instance;
1010
pub mod lvm;
1111
pub mod network;
12+
pub mod shutdown;
1213
pub mod slot;
1314

1415
use network::Forwarding;
@@ -38,6 +39,10 @@ impl Manager {
3839
lvm::reconcile(&self.config)?;
3940
}
4041

42+
// Install the SIGTERM/SIGINT handler and VM-killing watcher before any
43+
// slot exists, so an early stop is handled gracefully too.
44+
shutdown::install()?;
45+
4146
let config = Arc::new(self.config.clone());
4247
let mut handles = Vec::new();
4348

@@ -63,30 +68,15 @@ impl Manager {
6368

6469
info!(slots = handles.len(), "all slot threads started");
6570

66-
// Install SIGINT/SIGTERM handler
67-
setup_signal_handler()?;
68-
69-
// Join all slot threads (they run forever unless killed)
71+
// Join all slot threads. They run until a shutdown signal is received,
72+
// at which point each slot tears down (unmount, PID file) and exits.
7073
for handle in handles {
7174
if let Err(e) = handle.join() {
7275
warn!("slot thread panicked: {:?}", e);
7376
}
7477
}
7578

76-
info!("manager shutting down");
79+
info!("all slots stopped, manager shutting down");
7780
Ok(())
7881
}
7982
}
80-
81-
fn setup_signal_handler() -> Result<()> {
82-
use nix::sys::signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet, Signal};
83-
84-
// Install a no-op handler for SIGTERM and SIGINT so the process can shut down
85-
// gracefully. Slot processes receive the signal via their own process group.
86-
let action = SigAction::new(SigHandler::SigIgn, SaFlags::empty(), SigSet::empty());
87-
unsafe {
88-
sigaction(Signal::SIGTERM, &action)?;
89-
sigaction(Signal::SIGINT, &action)?;
90-
}
91-
Ok(())
92-
}

manager/src/shutdown.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//! Graceful shutdown coordination.
2+
//!
3+
//! SIGTERM/SIGINT set a global flag; a watcher thread then SIGKILLs every
4+
//! registered Firecracker process group so blocked `child.wait()` calls in the
5+
//! slot threads return. The slot loops observe the flag, run their teardown
6+
//! (unmount, PID file removal) and exit, letting the manager exit 0 instead of
7+
//! being SIGKILLed by systemd after `TimeoutStopSec`.
8+
9+
use std::collections::BTreeMap;
10+
use std::sync::atomic::{AtomicBool, Ordering};
11+
use std::sync::Mutex;
12+
use std::time::Duration;
13+
use tracing::{info, warn};
14+
15+
static SHUTDOWN: AtomicBool = AtomicBool::new(false);
16+
static CHILDREN: Mutex<BTreeMap<(String, usize), u32>> = Mutex::new(BTreeMap::new());
17+
18+
/// Whether a shutdown signal has been received.
19+
pub fn requested() -> bool {
20+
SHUTDOWN.load(Ordering::SeqCst)
21+
}
22+
23+
extern "C" fn handle_signal(_: libc::c_int) {
24+
// Only async-signal-safe work here: set the flag, nothing else.
25+
SHUTDOWN.store(true, Ordering::SeqCst);
26+
}
27+
28+
/// Install the SIGTERM/SIGINT handler and spawn the watcher thread that kills
29+
/// running Firecracker VMs once shutdown is requested. Call before spawning
30+
/// slot threads.
31+
pub fn install() -> anyhow::Result<()> {
32+
use nix::sys::signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet, Signal};
33+
34+
let action = SigAction::new(
35+
SigHandler::Handler(handle_signal),
36+
SaFlags::SA_RESTART,
37+
SigSet::empty(),
38+
);
39+
unsafe {
40+
sigaction(Signal::SIGTERM, &action)?;
41+
sigaction(Signal::SIGINT, &action)?;
42+
}
43+
44+
std::thread::Builder::new()
45+
.name("shutdown-watcher".to_string())
46+
.spawn(|| {
47+
while !requested() {
48+
std::thread::sleep(Duration::from_millis(250));
49+
}
50+
info!("shutdown requested, stopping running VMs");
51+
kill_children();
52+
})?;
53+
54+
Ok(())
55+
}
56+
57+
/// Record a running Firecracker child for a slot so the watcher can stop it.
58+
pub fn register_child(role: &str, idx: usize, pid: u32) {
59+
CHILDREN
60+
.lock()
61+
.unwrap()
62+
.insert((role.to_string(), idx), pid);
63+
// The watcher may have already swept before this child was registered.
64+
if requested() {
65+
kill_children();
66+
}
67+
}
68+
69+
/// Remove a slot's child after it has exited.
70+
pub fn unregister_child(role: &str, idx: usize) {
71+
CHILDREN.lock().unwrap().remove(&(role.to_string(), idx));
72+
}
73+
74+
fn kill_children() {
75+
let children = CHILDREN.lock().unwrap();
76+
for ((role, idx), pid) in children.iter() {
77+
info!(role = %role, idx = *idx, pid = *pid, "killing Firecracker process group");
78+
// Firecracker setsid()s into its own process group; kill the group.
79+
let ret = unsafe { libc::kill(-(*pid as libc::pid_t), libc::SIGKILL) };
80+
if ret != 0 {
81+
warn!(role = %role, idx = *idx, pid = *pid, "kill failed (process already gone?)");
82+
}
83+
}
84+
}

manager/src/slot.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::sync::Arc;
77
use std::time::{Duration, Instant};
88
use tracing::{error, info, instrument, warn};
99

10-
use crate::{instance::Instance, network::NetworkAllocation};
10+
use crate::{instance::Instance, network::NetworkAllocation, shutdown};
1111

1212
/// Run the per-slot loop for a given role and slot index.
1313
/// This function is intended to run in its own OS process or thread.
@@ -42,6 +42,11 @@ pub fn run_slot(config: Arc<ManagerConfig>, role: &Role, idx: usize) -> Result<(
4242
let mut cycle_n: u64 = 0;
4343

4444
loop {
45+
if shutdown::requested() {
46+
info!(role = %role.name, idx, "shutdown requested, exiting boot loop");
47+
break;
48+
}
49+
4550
let pid_file = instance.pid_file_path();
4651

4752
// Graceful restart: if Firecracker is already running for this slot, reattach
@@ -53,7 +58,10 @@ pub fn run_slot(config: Arc<ManagerConfig>, role: &Role, idx: usize) -> Result<(
5358
pid,
5459
"Firecracker already running, reattaching"
5560
);
61+
// Register so the shutdown watcher can stop the reattached VM too.
62+
shutdown::register_child(&role.slug(), idx, pid);
5663
wait_for_pid(pid);
64+
shutdown::unregister_child(&role.slug(), idx);
5765
remove_pid_file(&pid_file);
5866
// Fall through to next cycle after the job finishes
5967
continue;
@@ -71,6 +79,7 @@ pub fn run_slot(config: Arc<ManagerConfig>, role: &Role, idx: usize) -> Result<(
7179
match instance.start() {
7280
Ok(mut child) => {
7381
let pid = child.id();
82+
shutdown::register_child(&role.slug(), idx, pid);
7483
if let Err(e) = write_pid_file(&pid_file, pid) {
7584
error!(role = %role.name, idx, pid, error = %e, "failed to write PID file");
7685
}
@@ -154,11 +163,18 @@ pub fn run_slot(config: Arc<ManagerConfig>, role: &Role, idx: usize) -> Result<(
154163
}
155164
}
156165

166+
shutdown::unregister_child(&role.slug(), idx);
157167
remove_pid_file(&pid_file);
158168
}
159169
Err(e) => {
160170
error!(role = %role.name, idx, cycle = cycle_n, error = %e, "boot cycle failed");
161-
std::thread::sleep(Duration::from_secs(20));
171+
// Interruptible retry back-off so shutdown isn't delayed by up to 20s.
172+
for _ in 0..20 {
173+
if shutdown::requested() {
174+
break;
175+
}
176+
std::thread::sleep(Duration::from_secs(1));
177+
}
162178
instance.reset();
163179
continue;
164180
}
@@ -169,6 +185,12 @@ pub fn run_slot(config: Arc<ManagerConfig>, role: &Role, idx: usize) -> Result<(
169185
error!(role = %role.name, idx, error = %e, "cache clear failed");
170186
}
171187
}
188+
189+
// Shutdown path: make sure nothing from the last cycle is left behind that
190+
// would wedge the slot on the next manager start.
191+
instance.cleanup_stale_mount();
192+
info!(role = %role.name, idx, "slot shut down cleanly");
193+
Ok(())
172194
}
173195

174196
fn read_child_stderr(child: &mut std::process::Child) -> String {

0 commit comments

Comments
 (0)