Skip to content
7 changes: 7 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ veth_cidr = "10.12.0.0/16"
mem_size_mib = 1024
vcpu_count = 2

[machine.disk_rate_limit]
# enabled = true
# bandwidth_bytes_per_sec = 104857600 # 100 MB/s sustained
# bandwidth_burst_bytes = 10485760 # 10 MB one-time burst at VM start (not replenished)
# iops = 3000
# iops_burst = 500 # one-time IOPS burst at VM start (not replenished)
Comment on lines +116 to +118

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[documentation · low]
“At VM start” is misleading for snapshot restores. The resume path PATCHes a newly constructed rate limiter before every resume(), including one_time_burst, so Firecracker can grant this allowance again on each restore/resume rather than only at the original VM boot. Please document that the burst is one-time per limiter application (fresh boot or snapshot restore), or avoid sending it during resume if the intended contract is truly once per VM lifecycle.

Suggestion:

Suggested change
# bandwidth_burst_bytes = 10485760 # 10 MB one-time burst at VM start (not replenished)
# iops = 3000
# iops_burst = 500 # one-time IOPS burst at VM start (not replenished)
# bandwidth_burst_bytes = 10485760 # 10 MiB one-time burst per limiter application (fresh boot or snapshot restore)
# iops = 3000
# iops_burst = 500 # one-time IOPS burst per limiter application


[envd]
version = "0.5.15"
init_timeout_secs = 60
Expand Down
135 changes: 135 additions & 0 deletions src/cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,32 @@ pub struct MachineConfig {
pub vcpu_count: u32,
#[config(default = 1024u32)]
pub mem_size_mib: u32,
#[config(nested)]
pub disk_rate_limit: DiskRateLimitConfig,
}

#[derive(Debug, Config, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct DiskRateLimitConfig {
/// Enable per-sandbox disk I/O rate limiting via Firecracker's virtio-blk rate limiter.
#[config(default = false)]
pub enabled: bool,
Comment on lines +261 to +263

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
This describes an aggregate per-sandbox disk limit, but the implementation attaches the limiter only to USER_ROOTFS_DRIVE_ID; the boot drive and all extra drives receive None, and extra drives can be writable. A workload can therefore bypass this advertised quota through a writable extra drive. Also, simply assigning the full limit independently to every drive would multiply the aggregate allowance. Either define/document this configuration explicitly as a user-rootfs-only limit (including the field/table naming), or implement aggregate enforcement across every writable sandbox drive with a quota-sharing/splitting policy.

Suggestion:

Suggested change
/// Enable per-sandbox disk I/O rate limiting via Firecracker's virtio-blk rate limiter.
#[config(default = false)]
pub enabled: bool,
/// Enable disk I/O rate limiting for the sandbox's user-rootfs drive via
/// Firecracker's virtio-blk rate limiter. Other attached drives are not limited.
#[config(default = false)]
pub enabled: bool,

/// Sustained disk bandwidth limit in bytes per second (0 = unlimited).
#[config(default = 0u64)]
pub bandwidth_bytes_per_sec: u64,
Comment on lines +264 to +266

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This value is described as a per-second rate, but the Firecracker integration passes it directly as the token-bucket size. The effective rate is size / refill_time, so any configured refill_time_ms other than 1000 changes the requested bytes/second (for example, 500 ms doubles it). Either remove/configure a fixed 1000 ms refill period, or convert the per-second value to a bucket size using checked arithmetic before constructing the limiter.

/// One-time bandwidth burst in bytes, granted once when the VM starts (maps
/// to Firecracker's `one_time_burst`). It is a separate allowance consumed
/// before the sustained bucket and is not replenished after use, so it only
/// absorbs the initial I/O spike; it does not raise the steady-state rate.
#[config(default = 0u64)]
pub bandwidth_burst_bytes: u64,
/// Sustained IOPS limit (0 = unlimited).
#[config(default = 0u64)]
pub iops: u64,
/// One-time IOPS burst, granted once when the VM starts (maps to
/// Firecracker's `one_time_burst`). Consumed before the sustained bucket and
/// not replenished, so it only absorbs the initial spike, not steady state.
#[config(default = 0u64)]
pub iops_burst: u64,
Comment on lines +265 to +280

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
These u64 fields admit values above Firecracker's signed i64 token-bucket range. Such a configuration passes AppConfig::validate() and only fails later whenever a sandbox starts, even though it is statically invalid. Validate all four values against i64::MAX during config loading (and preferably reject a nonzero burst when its corresponding sustained limit is zero) so startup reports the bad configuration immediately.

Comment on lines +271 to +280

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
Validate that each burst is zero unless its corresponding sustained limit is nonzero. The application code only creates a bandwidth/IOPS token bucket when bandwidth_bytes_per_sec/iops is greater than zero, so configurations such as bandwidth_bytes_per_sec = 0 with bandwidth_burst_bytes = 1024 are accepted but silently ignore the burst. Reject these inconsistent combinations during AppConfig::validate() so operator mistakes fail at configuration load time.

}

#[derive(Debug, Config, Clone)]
Expand Down Expand Up @@ -778,6 +804,52 @@ impl AppConfig {
}
self.validate_memory_snapshot_background_download()?;
self.validate_overlaybd_global_config_paths()?;
self.validate_disk_rate_limit()?;
Ok(())
}

/// Reject internally inconsistent or out-of-range disk rate limit configs so
/// operator mistakes fail at load time. Disabled sections are skipped: both
/// the fresh-boot and snapshot-resume paths ignore all configured values when
/// disabled, so dormant/pre-staged values must not block startup.
///
/// When enabled: a one-time burst is meaningless without a nonzero sustained
/// limit (`build_disk_rate_limiter` only creates a bucket when the sustained
/// value is > 0, so a burst paired with a zero sustained limit is silently
/// ignored), and every value must fit Firecracker's signed `i64` token-bucket
/// fields (the consumer converts with `i64::try_from`, so an out-of-range
/// value would otherwise only fail later at sandbox start).
fn validate_disk_rate_limit(&self) -> Result<()> {
let cfg = &self.machine.disk_rate_limit;
if !cfg.enabled {
return Ok(());
}
if cfg.bandwidth_burst_bytes > 0 && cfg.bandwidth_bytes_per_sec == 0 {
Comment on lines +823 to +827

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
Validation rejects burst/sustained combinations even when enabled is false, although both fresh-boot and snapshot reconciliation paths explicitly ignore all configured limits while disabled. This means dormant/pre-staged values can prevent application startup despite having no runtime effect. Return early when the feature is disabled (or otherwise document that disabled sections must still be internally valid).

Suggestion:

Suggested change
let cfg = &self.machine.disk_rate_limit;
if cfg.bandwidth_burst_bytes > 0 && cfg.bandwidth_bytes_per_sec == 0 {
let cfg = &self.machine.disk_rate_limit;
if !cfg.enabled {
return Ok(());
}
if cfg.bandwidth_burst_bytes > 0 && cfg.bandwidth_bytes_per_sec == 0 {

bail!(
"machine.disk_rate_limit: bandwidth_burst_bytes is set but \
bandwidth_bytes_per_sec is 0; a burst requires a nonzero sustained limit"
);
}
if cfg.iops_burst > 0 && cfg.iops == 0 {
bail!(
"machine.disk_rate_limit: iops_burst is set but iops is 0; \
a burst requires a nonzero sustained limit"
);
}
for (name, value) in [
("bandwidth_bytes_per_sec", cfg.bandwidth_bytes_per_sec),
("bandwidth_burst_bytes", cfg.bandwidth_burst_bytes),
("iops", cfg.iops),
("iops_burst", cfg.iops_burst),
] {
if value > i64::MAX as u64 {
bail!(
"machine.disk_rate_limit.{name} ({value}) exceeds the maximum \
supported value {}",
i64::MAX
);
}
}
Ok(())
Comment on lines +833 to 853

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
The configuration fields accept values through u64::MAX, but Firecracker's token-bucket fields are i64. The consumer uses fallible conversions, so an enabled configuration above i64::MAX passes startup validation and then causes every affected sandbox creation/resume to fail. Validate each effective sustained/burst value against i64::MAX here so invalid operator input fails at config load time, consistently with this method's purpose.

Suggestion:

Suggested change
if cfg.iops_burst > 0 && cfg.iops == 0 {
bail!(
"machine.disk_rate_limit: iops_burst is set but iops is 0; \
a burst requires a nonzero sustained limit"
);
}
Ok(())
if cfg.iops_burst > 0 && cfg.iops == 0 {
bail!(
"machine.disk_rate_limit: iops_burst is set but iops is 0; \
a burst requires a nonzero sustained limit"
);
}
for (name, value) in [
("bandwidth_bytes_per_sec", cfg.bandwidth_bytes_per_sec),
("bandwidth_burst_bytes", cfg.bandwidth_burst_bytes),
("iops", cfg.iops),
("iops_burst", cfg.iops_burst),
] {
if value > i64::MAX as u64 {
bail!("machine.disk_rate_limit.{name} must be <= {}", i64::MAX);
}
}
Ok(())

}

Expand Down Expand Up @@ -1110,6 +1182,69 @@ mod tests {
);
}

#[test]
fn validate_rejects_disk_burst_without_sustained() {
let mut config = AppConfig::default();
config.machine.disk_rate_limit.enabled = true;
config.machine.disk_rate_limit.bandwidth_bytes_per_sec = 0;
config.machine.disk_rate_limit.bandwidth_burst_bytes = 1024;
let err = config.validate().unwrap_err();
assert!(
err.to_string().contains("bandwidth_burst_bytes is set but"),
"unexpected error: {err}"
);

let mut config = AppConfig::default();
config.machine.disk_rate_limit.enabled = true;
config.machine.disk_rate_limit.iops = 0;
config.machine.disk_rate_limit.iops_burst = 500;
let err = config.validate().unwrap_err();
assert!(
err.to_string().contains("iops_burst is set but"),
"unexpected error: {err}"
);
}

#[test]
fn validate_skips_disabled_disk_rate_limit() {
// A disabled section is ignored at runtime, so even internally
// inconsistent or out-of-range values must not block startup.
let mut config = AppConfig::default();
config.machine.disk_rate_limit.enabled = false;
config.machine.disk_rate_limit.bandwidth_bytes_per_sec = 0;
config.machine.disk_rate_limit.bandwidth_burst_bytes = 1024;
config.machine.disk_rate_limit.iops = u64::MAX;
config
.validate()
.expect("disabled disk rate limit config is not validated");
}

#[test]
fn validate_rejects_disk_rate_limit_above_i64_max() {
let mut config = AppConfig::default();
config.machine.disk_rate_limit.enabled = true;
config.machine.disk_rate_limit.bandwidth_bytes_per_sec = i64::MAX as u64 + 1;
let err = config.validate().unwrap_err();
assert!(
err.to_string()
.contains("machine.disk_rate_limit.bandwidth_bytes_per_sec"),
"unexpected error: {err}"
);
}

#[test]
fn validate_accepts_consistent_disk_rate_limit() {
let mut config = AppConfig::default();
config.machine.disk_rate_limit.enabled = true;
config.machine.disk_rate_limit.bandwidth_bytes_per_sec = 104_857_600;
config.machine.disk_rate_limit.bandwidth_burst_bytes = 10_485_760;
config.machine.disk_rate_limit.iops = 3000;
config.machine.disk_rate_limit.iops_burst = 500;
config
.validate()
.expect("consistent disk rate limit config passes");
}

#[test]
fn validate_rejects_shared_overlaybd_global_config_path() {
let mut config = AppConfig::default();
Expand Down
9 changes: 9 additions & 0 deletions src/sandbox/firecracker/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ pub struct FirecrackerCommonConfig {
/// start-fresh / start-resume hooks. Persisted with snapshot configs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_extension_params: Option<crate::sandbox::CustomExtensionParams>,
/// Effective disk I/O rate limit for the user rootfs drive. Carried in the
/// common config so fresh boot and snapshot resume apply the same config
/// and it persists across pause/resume, instead of each path reaching for
/// the process-global config.
#[serde(default)]
pub disk_rate_limit: crate::cfg::DiskRateLimitConfig,
}

impl FirecrackerCommonConfig {
Expand Down Expand Up @@ -181,6 +187,7 @@ impl FirecrackerCommonConfig {
cpu_config_json: None,
network_policy: None,
custom_extension_params: None,
disk_rate_limit: crate::cfg::DiskRateLimitConfig::default(),
}
}

Expand All @@ -191,6 +198,7 @@ impl FirecrackerCommonConfig {

let mut common = Self::new(firecracker_binary, tools_drive_version, runtime_policy);
common.envd_version = config.envd.version.clone();
common.disk_rate_limit = config.machine.disk_rate_limit.clone();
common.rootfs_allow_shrink = config.ublk.overlaybd.allow_shrink;
common.control_plane_port = config.tools.control_plane_port;
common.firecracker_work_base_dir = config.firecracker.work_dir.clone();
Expand Down Expand Up @@ -346,6 +354,7 @@ impl FirecrackerSandboxConfig {
read_only: false,
runtime_upper_mode: UpperMode::LogStructured,
});
common.disk_rate_limit = app_config.machine.disk_rate_limit;
Self {
common,
kernel_image,
Expand Down
24 changes: 22 additions & 2 deletions src/sandbox/firecracker/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use anyhow::{bail, Context, Result};
use firecracker_client::models::drive::IoEngine;
use firecracker_client::models::instance_action_info::ActionType;
use firecracker_client::models::vm::State as VmState;
use firecracker_client::models::{mmds_config::Version as MmdsVersion, MmdsConfig};
use firecracker_client::models::{mmds_config::Version as MmdsVersion, MmdsConfig, PartialDrive};
use firecracker_client::models::{
Balloon, BootSource, DirtyMemoryRanges, Drive, InstanceActionInfo, Logger,
MachineConfiguration, MemoryBackend, NetworkInterface, NetworkOverride, RateLimiter,
Expand Down Expand Up @@ -311,7 +311,10 @@ impl FirecrackerInstance {
///
/// `is_root_device` must be `true` for the boot rootfs drive and `false`
/// for any non-root extra drive. This API is only used during the
/// pre-boot configuration phase.
/// pre-boot configuration phase. `rate_limiter` attaches a pre-boot limiter
/// so throttling is in force from the guest's first I/O (a post-start PATCH
/// would leave a brief unthrottled window).
#[allow(clippy::too_many_arguments)]
pub async fn add_drive(
&self,
drive_id: &str,
Expand All @@ -320,19 +323,36 @@ impl FirecrackerInstance {
read_only: bool,
direct: bool,
io_engine: IoEngine,
rate_limiter: Option<Box<RateLimiter>>,
) -> Result<()> {
let mut drive = Drive::new(drive_id.to_string(), is_root_device);
drive.path_on_host = Some(path_on_host.to_string_lossy().into_owned());
drive.is_read_only = Some(read_only);
drive.direct = Some(direct);
drive.io_engine = Some(io_engine);
drive.rate_limiter = rate_limiter;
let path = format!("/drives/{}", drive_id);
self.client
.request_no_content(Method::PUT, &path, Some(&drive))
.await
.with_context(|| format!("Failed to add/update drive {}", drive_id))
}

/// Updates the rate limiter on a running VM's drive via PATCH.
pub async fn patch_drive_rate_limiter(
&self,
drive_id: &str,
rate_limiter: Box<RateLimiter>,
) -> Result<()> {
let mut partial = PartialDrive::new(drive_id.to_string());
partial.rate_limiter = Some(rate_limiter);
let path = format!("/drives/{}", drive_id);
self.client
.request_no_content(Method::PATCH, &path, Some(&partial))
.await
.with_context(|| format!("Failed to patch rate limiter on drive {}", drive_id))
}

/// Adds a network interface.
/// Pre-boot only.
pub async fn add_network_interface(
Expand Down
Loading
Loading