WebAssembly at the IoT Edge: A Motivating Example

In a previous article, I shared my excitement for WebAssembly in the context of the Internet of Things (IoT). I am particularly excited about the potential for sharing code, flexibly and securely, between the cloud and the edge. In this article, I provide a motivating example.

A Traditional Approach

Consider a point-of-sale terminal that runs software to calculate and display the price to the customer before accepting payment. For the product launch, the price is calculated as the item count multiplied by the cost and is included as part of the firmware. The firmware is written in Rust.[1]

use anyhow::anyhow;

fn display_price() -> anyhow::Result<()> {
    let count = 2;
    let unit_price = 10;

    let price = price_func(count, unit_price);

    // Protect against nonsensical prices
    if price < 0 || price > 100 {
        return Err(anyhow!("Invalid price : {}", price));
    }

    println!("Please pay: ${}", price);

    Ok(())
}

fn price_func(count: i32, unit_price: i32) -> i32 {
    count * unit_price
}

After a successful launch, we want to add a new feature that will discount the price during off-peak hours, from 11:00 p.m. to 11:00 a.m. The code change is straightforward:

use anyhow::anyhow;
use chrono::{Timelike, Local};

fn display_price() -> anyhow::Result<()> {
    let count = 2;
    let unit_price = 10;
    let hour = Local::now().hour() as i32;

    let price = price_func(count, unit_price, hour);

    // Protect against nonsensical prices
    if price < 0 || price > 100 {
        return Err(anyhow!("Invalid price : {}", price));
    }

    println!("Please pay: ${}", price);

    Ok(())
}

fn price_func(count: i32, unit_price: i32, hour: i32) -> i32 {
    let price = count * unit_price;

    if hour > 11 && hour < 24 {
        price
    } else {
        (price as f32 * 0.5) as i32
    }
}

While the code change is straightforward, delivering it to customers is not. There is a monthly code freeze where all changes enter a testing cycle that includes extensive hardware-in-the-loop testing. After the new release has been tested, there is a staged rollout process to ensure there are no issues with firmware updates, customer complaints, or support escalations. It may take weeks or months for this very small code change to reach the majority of customers.

WebAssembly

Now consider how things might be different with WebAssembly. The price_func function can be extracted into its own library. The addition of no_mangle and extern "C" allow the library code to be called from languages other than Rust.[2]

#[no_mangle]
pub extern "C" fn price_func(count: i32, unit_price: i32, hour: i32) -> i32 {
    let price = count * unit_price;

    if hour > 11 && hour < 24 {
        price
    } else {
        (price as f32 * 0.5) as i32
    }
}

It can then be compiled to WebAssembly:

$ cargo build --target wasm32-unknown-unknown --release 
    Finished release [optimized] target(s) in 0.01s

The binary is small, just over 200 bytes:

$ ls -lh target/wasm32-unknown-unknown/release/price_func.wasm 
-rwxr-xr-x  1 cbreck  staff   223B Jul 23 08:09 target/wasm32-unknown-unknown/release/price_func.wasm

The firmware loads the WebAssembly code, compiles it, and exports the price_func function. The code only needs be to be JIT compiled once, when loading the WebAssembly module, and the extracted function can be used repeatedly. For this example, the host program is using the Wasmer WebAssembly run-time via the Rust API:

fn get_wasm() -> anyhow::Result<NativeFunc<(i32, i32, i32), i32>> {
    let wasm_bytes = std::fs::read("price_func.wasm")?;

    // Sandbox
    let store = Store::default();

    // JIT
    let module = Module::new(&store, &wasm_bytes)?;

    // The module doesn't import anything, so we create an empty import object
    let import_object = imports! {};
    let instance = Instance::new(&module, &import_object)?;

    instance
        .exports
        .get_native_function::<(i32, i32, i32), i32>("price_func")
        .context("Getting price_func")
}

The pricing function can then be invoked like a regular function and the program looks largely the same as before:

fn display_price() -> anyhow::Result<()> {
    // In a real program, this only needs to happen once and can be reused
    let price_func = get_wasm()?;

    let count = 2;
    let unit_price = 10;
    let hour = Local::now().hour() as i32;

    let price = price_func
        .call(count, unit_price, hour)
        .context("Calling price_func")?;
        
    // Protect against nonsensical prices
    if price < 0 || price > 100 {
        return Err(anyhow!("Invalid price : {}", price));
    }

    println!("Please pay: ${}", price);

    Ok(())
}

To emphasise the portability of WebAssembly, the same WebAssembly code could be used in the cloud to evaluate the value in making a pricing change, or used across different point-of-sale terminals with various processor architectures, or used on a mobile phone to display the same final price. All environments would be using exactly the same code without the overhead or discrepancies that might arise from reimplementing the same business logic for different use-cases.

Updating the WebAssembly

Now that we are using WebAssembly, contrast what the next product change looks like. In addition to the off-peak pricing, we want to introduce happy-hour pricing between 2 p.m. and 5 p.m. The code change in the WebAssembly module is straightforward:

#[no_mangle]
pub extern "C" fn price_func(count: i32, unit_price: i32, hour: i32) -> i32 {
    let price = count * unit_price;

    if hour > 11 && hour < 14 || hour > 16 && hour < 24 {
        price
    } else {
        (price as f32 * 0.5) as i32
    }
}

However, and this is the key point, the firmware on the point-of-sale terminal does not need to change. The new WebAssembly code must be distributed to the point-of-sale terminal and reloaded, but a new firmware release is not required, avoiding the lengthy code freeze, testing, and deployment cycle. Now, pricing changes can be delivered to customers in a matter of minutes, rather than months, and we can be much more responsive to customer needs, a huge competitive advantage.[3]

Security Considerations

For IoT devices providing critical functions, security and reliability are paramount. Downloading code from the cloud, even if from a trusted source, and changing the run-time behaviour of the device needs to be done with great care. First, the WebAssembly should be signed and the firmware should verify the signature to attest it is from a trusted source. I did not sign the WebAssembly to keep the examples concise. Second, in this example, the WebAssembly code is a pure function that is sandboxed and cannot access the file system, open ports, or read from, or write to, shared memory. However, the host program must continue to provide protections against unsafe or invalid input or output, like a negative price, as included in the example.[4] Finally, the host program itself will run as a binary or a container on the edge computing platform. Sandboxing the host program through systemd, Minijail, AppArmor, the container run-time, or other mechanisms, remains as important as ever, and is complimentary to the WebAssembly sandbox.

Code as Configuration

IoT platforms have mechanisms for assigning a desired configuration for the IoT device from the cloud, often as a JSON document of configuration settings. For the point-of-sale terminal, these settings might include the status, language, tax rate, and various sales promotions. Since the WebAssembly code is just over 300 bytes when Base64 encoded, the JSON document can be used to deliver the code to the device:

{
  "status": "enabled",
  "language": "EN",
  "tax": 0.10,
  "price-func": "AGFzbQEAAAABCAFgA39/fwF/AwIBAAUDAQAQBhkDfwFBgIDAAAt/AEGAgMAAC38AQYCAwAALBzIEBm1lbW9yeQIACnByaWNlX2Z1bmMAAApfX2RhdGFfZW5kAwELX19oZWFwX2Jhc2UDAgp5AXcCAX0BfyABIABsIgGyQwAAAD+UIgNDAAAAz2AhAAJAAkAgA4tDAAAAT11FDQAgA6ghBAwBC0GAgICAeCEECyABIAFBAEH/////ByAEQYCAgIB4IAAbIAND////Tl4bIAMgA1wbIAJBb2pBB0kbIAJBfnFBDEYbCw=="
}

Concerns

People will raise concerns about the combinatorial testing challenges when device code can be modified dynamically. However, I do not see this as any different than the combinatorial challenges that already exist for testing the typically large number of device configurations which also change run-time behaviour. Similarly, people will be concerned about tracking the version of the WebAssembly module the device is running for troubleshooting, feature flagging, and customer support. But, again, the same issues already exist with tracking device configurations over time. In the end, these concerns must be addressed by a platform for desired-state management, which is critically important for provisioning and operating IoT devices safely, securely, and reliably.

An Unmotivating Example

To temper my excitement, consider a change that modifies the return type of the price_func function from an integer to a float:

#[no_mangle]
pub extern "C" fn price_func(count: i32, unit_price: i32, hour: i32) -> f32 {
    let price = count * unit_price;

    if hour > 11 && hour < 14 || hour > 16 && hour < 24 {
        price as f32
    } else {
        price as f32 * 0.5
    }
}

When the point-of-sale terminal attempts to load this new WebAssembly module, it results in an error due to the now incompatible function signature:

$ ./target/release/point_of_sale_terminal
Error: Getting price_func

Caused by:
    Incompatible Export Type

The same error would occur in the first example in this article where the function signature was modified to include the hour of the day. A firmware update is unavoidable in these cases.

This is a classic challenge when evolving library code. Unfortunately, instead of a compiler error, it is a run-time error and the host must handle the error appropriately.[5] Relaxing compile-time safety should not be taken lightly and is a trade-off with this approach. That said, and as just discussed, IoT devices generally have many configuration parameters. Some parameters can be incompatible or invalid at run-time. For example, assigning a non-existent language on the point-of-sale terminal. In IoT, the impact from an invalid configuration could be as severe as unsafe operation, human injury, hardware damage, or bricking the device. Therefore, dynamic configuration parameters already require careful attention to defensive coding, error handling, testing, and configuration management. Viewed in this light, the challenges from WebAssembly run-time errors are arguably no different. Of note, if there are problems with the WebAssembly code itself, it can be modified and updated quickly through the desired-state configuration platform.

Summary

When might it be appropriate to use WebAssembly on the IoT edge?

  • When the function is a pure function that takes input and returns output and has no side effects.
  • When the function signature is well-defined and reasonably stable.
  • When the business logic may change, like a billing calculation, a machine-learning model, or a streaming calculation, and there is value in iterating flexibly that outweighs the trade-offs of maintaining code as configuration and handling run-time errors.[6]

This article motivated the need to update code on IoT devices outside of protracted firmware releases. WebAssembly is a promising technology to flexibly and securely update code on the IoT edge and create a harmonious relationship between the edge and the cloud.


  1. In reality, the item count and the unit price would be inputs, and the price would not just be written to the console. However, for simplicity, the examples in this article are self-contained. ↩︎

  2. For more information on Foriegn Function Interfaces (FFI), refer to the The Embedded Rust Book. For discussion of a Wasm Application Binary Interface (ABI) for Rust, see the Rust wasm ABI design meeting minutes and the wasm ABI pull request. ↩︎

  3. If the point-of-sale terminal has reliable connectivity to the cloud, an alternative approach is to call a web service that returns the price. The advantage of the WebAssembly approach is that this code runs locally and works when off-line. Many IoT devices must continue to perform their critical functions even when temporarily disconnected from the cloud. In addition, running locally can be more responsive, and in some cases more cost effective, than relying on cloud services. ↩︎

  4. Refinement types could be used to reduce the state space further, for example, restricting the price type to only positive integers, but is left as an exercise for the reader. ↩︎

  5. As I mentioned in the previous article, WebAssembly can be hosted in many languages, but my preference is to use Rust. In addition to Rust’s compile-time memory-safety and thread-safety, Rust also supports functional programming. Functional programming techniques, like using option types, make for disciplined error handling in situations like this. The compiler can help ensure all error cases are handled. ↩︎

  6. Two plus two will always equal four, so there is no point in making it a WebAssembly module. ↩︎