Skip to content

Memory validation in BytesToStringMB fails to catch sub-viable values above the truncation threshold #819

Description

@Nachiket-Roy

Summary

BytesToStringMB (pkg/unikontainers/hypervisors/utils.go) is intended to guard against invalid or excessively small memory requests by falling back to DefaultMemory (256MB). In practice, the fallback only occurs when the converted value truncates to 0 MB (i.e. inputs smaller than 1MB). Any value of 1MB or greater is passed directly to the hypervisor without any lower-bound validation, even if it is insufficient for the guest to boot.

This behavior is shared across all hypervisor backends that use BytesToStringMB (qemu, hvt, spt, and cloud-hypervisor). firecracker does not call this helper, but contains the same pattern locally (fcMem == 0).

Root cause

In pkg/unikontainers/hypervisors/utils.go:

func bytesToMB(bytes uint64) uint64 {
	const bytesInMB = 1000 * 1000
	return bytes / bytesInMB
}

func BytesToStringMB(argMem uint64) string {
	stringMem := strconv.FormatUint(DefaultMemory, 10)

	if argMem != 0 {
		userMem := bytesToMB(argMem)

		// Check for too low memory
		if userMem == 0 {
			userMem = DefaultMemory
		}

		stringMem = strconv.FormatUint(userMem, 10)
	}

	return stringMem
}

The comment (// Check for too low memory) suggests this was intended as a minimum-memory guard. However, the implementation only catches the integer-truncation case rather than enforcing an actual lower bound.

Reachability

unikontainers.go forwards the OCI memory limit directly:

vmmArgs.MemSizeB = uint64(*u.Spec.Linux.Resources.Memory.Limit)

Neither Docker, containerd, nor Kubernetes enforces a runtime-specific minimum memory limit before this value reaches urunc. As a result, values such as 2Mi are accepted and eventually reach BytesToStringMB.

Reproduction

Parser behavior

Input Output Behavior
500,000 bytes 256 Falls back
999,000 bytes 256 Falls back
1,000,000 bytes 1 Passed through
2,000,000 bytes 2 Passed through
4,000,000 bytes 4 Passed through

Empirical boot floor

Using the redis-qemu-unikraft-initrd image, I performed a binary search over guest memory values while checking for "Ready to accept connections" within a 3-second serial output window.

Memory (MB) Result
130 Success
98 Success
82 Success
78 Success
76 Success
75 Failed
74 Failed
66 Failed

The measured boot floor for this image was 76MB.

At 75MB and below, the guest panics during boot:

Cannot handle palloc request of order 6: Out of memory
main returned 11, halting system

At 76MB and above, Redis starts successfully.

Note: This measurement was taken before fixing the unit-conversion issue described in #818 . Since QEMU currently interprets -m 76M as 76 MiB, the exact threshold should be revalidated once that issue is resolved.

Existing test coverage

utils_test.go contains tests for bytesToMB and bytesToMiB, but there are currently no tests covering BytesToStringMB's fallback behavior or minimum-memory validation.

Impact

Memory values between 1MB and the actual minimum boot requirement are accepted without validation and eventually cause guest boot failures. This includes realistic configuration mistakes such as specifying 2Mi or 4Mi, resulting in a guest panic instead of an earlier fallback or validation error.

Suggested fix

Short term

Replace the truncation-based check with an explicit minimum:

const MinMemoryMB uint64 = 128

if userMem < MinMemoryMB {
	userMem = DefaultMemory
	// or return an error
}

The measured Redis boot floor is approximately 76MB, but a larger default provides headroom for real workloads.

Longer term

Instead of a runtime-wide constant, consider extending UnikernelConfig (for example via an image annotation such as com.urunc.unikernel.minMemory) so each unikernel image can declare its own minimum supported memory.

Related issues

Environment

  • urunc commit: 2785749875a1b13f543b6ad4137c68903a9ec87e
  • QEMU: 8.2.2 (1:8.2.2+ds-0ubuntu1.17)
  • Test image: harbor.nbfc.io/nubificus/urunc/redis-qemu-unikraft-initrd:latest
Binary search script
import subprocess
import time

def test_boot(mem_mb):
    cmd = [
        "qemu-system-x86_64", "-cpu", "host", "-enable-kvm",
        "-m", str(mem_mb),
        "-kernel", "dist/unikernel/redis_unikraft_kvm_x86_64.qmu",
        "-initrd", "dist/unikernel/initrd",
        "-display", "none", "-vga", "none",
        "-serial", "stdio", "-monitor", "null"
    ]

    p = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
    )

    start = time.time()

    while time.time() - start < 3:
        if p.poll() is not None:
            break
        time.sleep(0.1)

    try:
        p.terminate()
        out, _ = p.communicate(timeout=1)
    except Exception:
        p.kill()
        out, _ = p.communicate()

    return "Ready to accept connections" in out

low, high, best = 4, 256, None

while low <= high:
    mid = (low + high) // 2
    if test_boot(mid):
        best = mid
        high = mid - 1
    else:
        low = mid + 1

print(best)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions