Mkernel, our scheduler for our unikernels
A derivation of Miou for unikernels
Following this brief digression on the scheduler, it may be worth using Miou
and mkernel to develop our unikernels. In this case, mkernel extends Miou
to handle all I/O-related tasks with Solo5.
As mentioned, Solo5 is very minimal and offers only two devices: what is known as a network interface (which can easily be thought of as an Ethernet socket) and what is known as a block device (which can easily be thought of as a hard drive).
It is through these two types of devices that we obtain four hypercalls: that is to say, functions that interact with these devices:
- read an Ethernet frame
- write an Ethernet frame
- read a page from a block device
- write a page to a block device
This is how mkernel essentially offers two things:
- integration of these hypercalls with the Miou scheduler
- the ability to specify the devices required for your unikernel
Your first unikernel with mkernel
If we go back to our example with dune, we simply need to add mkernel as a
dependency for our unikernel. This library, just as we explained with regard to
digestif, contains C stubs, so it must be marked as a vendor library:
let () = Mkernel.(run []) @@ fun () ->
print_endline "Hello World!"
$ unic infer -r . -x _build -x vendors > _mfetch
$ mfetch --with-dune-file
mkernel ok
$ dune build
$ solo5-hvt -- _build/solo5/main.exe --solo5:quiet
Hello World!
It is worth noting that the startup.c file we used to have to
write is no longer needed. This is because mkernel implements it for us.
Next, unlike Miou{,_unix}.run, our Mkernel.run function takes a rather
special list: the one that specifies our devices. Let’s give it a go with a
block device:
let () = Mkernel.(run [ block "disk" ]) @@ fun blk () ->
let bstr = Bstr.create (Mkernel.Block.pagesize blk) in
Mkernel.Block.atomic_read blk ~src_off:0 bstr;
Fmt.pr "@[<hov>%a@]" (Hxd_string.pp Hxd.default) (Bstr.to_string bstr)
When extending our code, there are several things to do:
- We need to modify the
dunefile to include the new librariesbstr,hxd.coreandhxd.string(for HexDump) - we must double-check whether adding these libraries means (or not) that some
of them need to be marked as vendor libraries (we must therefore run
unic inferagain) - if we need to mark some of them as vendor libraries, we can re-run
mfetch - Adding a device to our unikernel also requires a change to our manifest.json. There's a handy trick for this last task!
$ unic infer -r . -x _build -x vendors > _mfetchHere, we can see that we need to use bstr; it’s a small library for
manipulating bigarrays.
$ mfetch
bstr ok
mkernel skipped (already vendored)Here's a tip for generating the manifest.json. When we compile our
unikernel, we compile it twice: once for Solo5 and once using the default
context. As for the default context, it's a simple executable produced from the
same code (i.e. the same main.ml).
From this code, we can infer which devices have been added to Mkernel.run and
generate our manifest.json accordingly. We could even turn this into a
dune rule!
$ dune exec ./main.exe > manifest.json
$ dune buildLet's create a block device file.txt. It must be aligned to our default page
(512 bytes).
$ echo "Hello World!" > file.txt
$ truncate -s 512 file.txt
$ solo5-hvt --block:disk=file.txt -- _build/solo5/main.exe --solo5:quiet
00000000: 4865 6c6c 6f20 576f 726c 6421 0a00 0000 Hello World!....
...We've manipulated our first block device in our unikernel! As we can see, we assign a name to a device. This is the name that will be used when we want to run our unikernel (in this case, "disk").
Next, we associate a file called file.txt with it, which contains our "Hello
World", and we display it using fmt and hxd. We’ve made a hypercall:
specifically, reading a page (512 bytes) from the given block device.
We also have the complete workflow for developing a unikernel, specifically
this loop involving adding dependencies, determining what needs to be
vendorised using unic, and downloading the source code.
Our bootagotchi
At this stage, we're going to implement a proper unikernel using a block device. The aim is to create our own little Tamagotchi, but in the form of a unikernel: this draws a parallel with real life, as we try, at robur to look after our deployed unikernels on a daily basis. The principle is simple: to be able to save the state of our Tamagotchi to a block device.
type state =
{ boots : int32
; health : int32 }Bin is a module that allows you to define a binary format which can then be
encoded and decoded.
let state =
let open Bin in
record (fun boots health -> { boots; health })
|+ field beint32 (fun t -> t.boots)
|+ field beint32 (fun t -> t.health)
|> sealr
let encode blk value =
let bstr = Bstr.create (Mkernel.Block.pagesize blk) inOne of the advantages of Bin is that it may be able to infer the size of a
value as it serialises it. Here, we check that what we want to encode is
smaller than a page.
let size = Bin.size_of_value state value in
let fn size =
if size > Mkernel.Block.pagesize blk
then Fmt.failwith "Impossible to encode our state into the given block \
device (its page is too small)" in
Option.iter fn size;
Mkernel.Block.atomic_read blk ~src_off:0 bstr;
Bin.encode_bstr state value bstr (ref 0);
Mkernel.Block.atomic_write blk ~dst_off:0 bstr
let decode blk =
let bstr = Bstr.create (Mkernel.Block.pagesize blk) in
Mkernel.Block.atomic_read blk ~src_off:0 bstr;
match Bin.decode_bstr state bstr (ref 0) with
| value -> value
| exception _ -> Fmt.failwith "Invalid block device"We can therefore create the core of our unikernel, which involves retrieving the state of our bootagotchi and displaying it:
let show state = match state.health with
| n when n <= 0l ->
Fmt.pr
{text|
/\_/\
( x.x ) [Boot #%ld]
> ^ <
|text} state.boots
| n ->
Fmt.pr
{text|
/\_/\
( o.o ) [Boot #%ld]
> ^ < [Health: %ld/100]
|text} state.boots (Int32.min state.health 100l)
let run feed = Mkernel.(run [ block "bootagotchi" ]) @@ fun blk () ->
let state = decode blk in
let state = { state with health= Int32.add state.health feed } in
show state;
let next = { boots= Int32.succ state.boots
; health= Int32.pred state.health } in
encode blk next
open Cmdliner
let feed =
let doc = "Give some food to your bootagotchi." in
Arg.(value & opt int32 0l & info [ "feed" ] ~doc)
let term =
let open Term in
const run $ feed
let cmd = Cmd.v (Cmd.info "bootagotchi") term
let () = Cmd.(exit @@ eval' cmd)
We're going to add our dune file to our project. This will use a trick to
generate the manifest.json on the fly.
(executable
(name main)
(modules main)
(link_flags :standard -cclib "-z solo5-abi=hvt")
(libraries cmdliner mkernel fmt bin bstr)
(foreign_stubs
(language c)
(names manifest)))
(rule
(targets manifest.c)
(deps manifest.json)
(enabled_if
(= %{context_name} "solo5"))
(action
(run solo5-elftool gen-manifest manifest.json manifest.c)))If we want to compile our unikernel in the default context (as a simple executable), the manifest.c file can be left blank.
(rule
(targets manifest.c)
(enabled_if
(= %{context_name} "default"))
(action (write-file manifest.c "")))However (and this is the trick), as soon as we compile our unikernel within the
Solo5 environment, we’ll run that same unikernel as a simple executable in
order to generate our manifest.json based on the arguments we pass to
Mkernel.run.
(rule
(target manifest.json)
(enabled_if
(= %{context_name} "solo5"))
(action
(with-stdout-to manifest.json (run ./main.exe))))So we can now follow our workflow and run our unikernel!
$ unic -r . -x _build -x vendors > _mfetch
$ mfetch
bin ok (bin, bstr)
mkernel ok
$ dune build
$ touch state.img
$ truncate -s 512 state.img
$ solo5-hvt --block:bootagotchi=state.img -- \
_build/solo5/main.exe --solo5:quiet --feed 3
/\_/\
( o.o ) [Boot #0]
> ^ < [Health: 3/100]
$ solo5-hvt --block:bootagotchi=state.img -- \
_build/solo5/main.exe --solo5:quiet
/\_/\
( o.o ) [Boot #1]
> ^ < [Health: 2/100]
$ solo5-hvt --block:bootagotchi=state.img -- \
_build/solo5/main.exe --solo5:quiet
/\_/\
( o.o ) [Boot #2]
> ^ < [Health: 1/100]
$ solo5-hvt --block:bootagotchi=state.img -- \
_build/solo5/main.exe --solo5:quiet
/\_/\
( x.x ) [Boot #3]
> ^ <
And here's our first bootagotchi! Mkernel provides the most basic
hypercalls for interacting with block devices. As such, we've also been able
to develop a library such as cachet that allows us to cache page
reads (as the hypercall can be quite costly) whilst also enabling us to write
values of arbitrary size without being constrained by page boundaries.
A filesystem for our unikernel
Finally, a block device can be extended to support more complex operations,
such as treating it as a FAT32 file system. That is why we have developed
mfat, an OCaml implementation of FAT32, and here is how we could use
it with mkernel:
module Blk = struct
type t = Mkernel.Block.t
let pagesize = Mkernel.Block.pagesize
let read = Mkernel.Block.atomic_read
let write = Mkernel.Block.atomic_write
end
module FAT32 = Mfat_bos.Make (Blk)
let fat32 name =
let fn blk () =
let v = FAT32.create blk in
let v = Result.map_error (fun (`Msg msg) -> msg) v in
Result.error_to_failure v in
Mkernel.map fn [ Mkernel.block name ]
let run _ =
Mkernel.(run [ fat32 "disk" ]) @@ fun fs () ->
...
Time to stop!
We're going to look at one final aspect of mkernel that comes directly from
Miou: tasks. Thanks to Miou, it's possible to launch tasks and let the
scheduler run them. So here we're going to have a bit of fun with time and
tasks to make the unikernel a little more dynamic.
let or_raise = function Ok v -> v | Error exn -> raise exn
let rec loop time str =
Mkernel.sleep time;
Fmt.pr "%s%!" str;
loop time str
let _500ms = 500_000_000
let _1s = 1_000_000_000
let _5s = 5_000_000_000
let () = Mkernel.(run []) @@ fun () ->The aim here is to have two tasks that will run simultaneously and will be cancelled after 5 seconds.
let prm0 = Miou.async @@ fun () -> loop _500ms "." in
let prm1 = Miou.async @@ fun () -> loop _1s "o" in
let prm3 = Miou.asycn @@ fun () ->
Mkernel.sleep _5s;
print_endline "Bing!" in
Miou.await_first [ prm0; prm1; prm3 ] |> or_raiseYou can compile it and get a little animation!

Conclusion
We're already starting to have a bit of fun with mkernel. We now have a
scheduler that can, amongst other things, handle block devices and run
asynchronous tasks simultaneously. With that in mind, our brief introduction to
Miou might help you take things further. The documentation for
mkernel is also worth a look.
However, handling block devices is certainly not enough, which is why, in the next section, we’ll introduce you to using mnet and networking!