Doing Nothing in Mirage

Page content

It’s Northern Hemisphere summer right now, and in Wisconsin we’re having one of the loveliest ones I can remember. Today the temperature is hovering right at pleasant, there are high clouds blowing across the sky, the breeze is soothing, and birds are singing all over the place. It is not, in short, programming weather. It is sit-outside, read-a-novel, do-nothing weather.

Sunbeams stream through the leaves of a large tree, beneath which is a bicycle.

Yes, this sort of thing.

We don’t often let our programs slack off, even when we let ourselves take a peaceful day. I got to wondering (staring off into space, watching the shadows cast by sun-dappled leaves) what the most trivial, do-nothing Mirage project would look like, and how it could be constructed with a minimum of activity and a maximum of understanding.

[] dothraki@iBook:~$ mkdir trivial
[] dothraki@iBook:~$ cd trivial/
[] dothraki@iBook:~/trivial$ ls -alh
total 16K
drwxrwxr-x   2 dothraki dothraki 4.0K Jul 23 13:17 .
drwxr-xr-x 161 dothraki dothraki  12K Jul 23 13:17 ..
[] dothraki@iBook:~/trivial$ mirage configure --xen
[ERROR]      No configuration file config.ml found.
You'll need to create one to let Mirage know what to do.

Okay, we’ll have to do at least one thing to make this work. Mirage uses config.ml to programmatically generate a Makefile and main.ml when you invoke mirage --configure. main.ml uses instructions from config.ml to satisfy module types representing driver requirements for your application, then begins running the threads you requested that it run. That all sounds an awful lot like work; maybe we can get away with not asking for anything.

[] dothraki@iBook:~/trivial$ touch config.ml
[] dothraki@iBook:~/trivial$ mirage configure --xen
Mirage      Using scanned config file: config.ml
Mirage      Processing: /home/dothraki/trivial/config.ml
Mirage      => rm -rf /home/dothraki/trivial/_build/config.*
Mirage      => cd /home/dothraki/trivial && ocamlbuild -use-ocamlfind -tags annot,bin_annot -pkg mirage config.cmxs
empty       Using configuration: /home/dothraki/trivial/config.ml
empty       0 jobs []
empty       => ocamlfind printconf path
empty       Generating: main.ml
empty       Now run 'make depend' to install the package dependencies for this unikernel.
[] dothraki@iBook:~/trivial$ ls
_build  config.ml  empty.xl  log  main.ml  Makefile

That seems like a great start! Maybe we can trivially achieve our dream of doing nothing.

[] dothraki@iBook:~/trivial$ make depend
opam install mirage-xen --verbose
[NOTE] Package mirage-xen is already installed (current version is 1.1.1).

Resting on our laurels. Excellent. (In keeping with the lazy theme of this post, I’ll elide the make depend step from future examples, but if you’re playing along at home you may discover that you need to run it when you introduce new complexity in pursuit of perfect non-action.)

[] dothraki@iBook:~/trivial$ make
ocamlbuild -classic-display -use-ocamlfind -pkgs lwt.syntax,mirage-types.lwt -tags "syntax(camlp4o),annot,bin_annot,strict_sequence,principal" -cflag -g -lflags -g,-linkpkg,-dontlink,unix main.native.o
ocamlfind ocamldep -package mirage-types.lwt -package lwt.syntax -syntax camlp4o -modules main.ml > main.ml.depends
ocamlfind ocamlc -c -g -annot -bin-annot -principal -strict-sequence -package mirage-types.lwt -package lwt.syntax -syntax camlp4o -o main.cmo main.ml
+ ocamlfind ocamlc -c -g -annot -bin-annot -principal -strict-sequence -package mirage-types.lwt -package lwt.syntax -syntax camlp4o -o main.cmo main.ml
File "main.ml", line 8, characters 2-13:
Error: Unbound module OS
Command exited with code 2.
make: *** [main.native.o] Error 10
[] dothraki@iBook:~/trivial$ 

Oh, bother.

Let’s have a look at the main.ml generated by mirage configure --xen with our empty config.ml.

(* Generated by Mirage (Wed, 23 Jul 2014 18:21:24 GMT). *)

open Lwt

let _ = Printexc.record_backtrace true

let () =
  OS.Main.run (join [])

This looks like the right general idea - OS.Main.run to invoke a thread, join [] (from Lwt) to operate on an empty list of work to do. We have no work to do. It’s a nice day.

An unrigged sailboat in a lake on a sunny day.

:)

Unfortunately, we can’t accomplish our goal of doing nothing if we can’t do anything (in other words, we can’t run our zero threads if we have no idea how to run anything), so we’ll have to do a little more work first.

If we can figure out how to get OS.Main.run included, we’ll be off to the races. Let’s have a look at the Makefile, which is also programmatically generated by mirage configure and shows which libraries the invocation of make will link against.

[] dothraki@iBook:~/trivial$ grep ^LIBS Makefile
LIBS   = -pkgs lwt.syntax,mirage-types.lwt

Mirage applications will always get mirage-types.lwt and lwt.syntax, and might get more libraries included if they’re requested in config.ml or required by a driver requested there. Unfortunately, neither of these gets us OS.

Some snooping on utop with strace reveals that an implementation for OS lives in the mirage-xen and mirage-unix libraries. If we add mirage-xen to LIBS manually in the Makefile (or mirage-unix, if we generated the Makefile with mirage configure --unix) and then make, we do get something runnable. But that’s not good enough! We don’t want to manually edit files that a computer could generate for us! That sounds like work, and that’s not what today is all about.

One possible solution is to do is write a few lines into our config.ml to request that an appropriate library be included in LIBS. We could ask for mirage-unix directly, but that wouldn’t work when we want to do nothing in Xen, and mirage-xen has the same problem when we want to do nothing in Unix. Every time we wanted to do nothing on a different platform, we’d have to change our config.ml! No way.

Instead, let’s request a driver that imports the OS interface. Numerous drivers are available, but the simplest one is CONSOLE, which provides basic text output on a screen. Let’s pick that one.

In order to use CONSOLE, we’ll have to add some code to our config.ml, disturbing its state of complete relaxation. Minimally, we need a call to register, which is defined in mirage.ml:

val register: string -> job impl list -> unit
(** [register name jobs] registers the application named by [name]
which will executes the given [jobs]. *)

It appears we’ll have to define at least one job impl, where we’ll do the hard work of defining doing nothing, then register it with some name. Say, out_to_lunch. Mirage expects us to define the job in another file, then tell config.ml where to find it with foreign (source):

val foreign: string -> ?libraries:string list -> ?packages:string list -> 'a typ -> 'a impl
(** [foreign name libs packs constr typ] states that the module named
by [name] has the module type [typ]. If [libs] is set, add the
given set of ocamlfind libraries to the ones loaded by default. If
[packages] is set, add the given set of OPAM packages to the ones
loaded by default. *)

So we’ll have to make another file, say maybe loaf.ml, with our top-secret and highly-valuable instructions for slacking off. (Before we get too high and mighty about all the instructions our project needs just to not do anything, remember that sometimes humans need help with this too.) loaf.ml needs to have at least one module specified that can take a CONSOLE module argument; traditionally unikernels call this module Main, but we can call it anything. Say, for example, Relax.

Modules in OCaml can’t be completely empty, and we’ll need a start function for the program to link, so we’ll just get all that hard work out of the way and define it right now. Since Mirage will want to pass information on how to use the specific instance of a console to the program, start will have to accept an argument of type console_impl. Luckily, there’s no requirement that we actually do anything of consequence in there, so we can finally define what it means to do nothing - Lwt.return (). A full explanation of Lwt is outside the scope of our laziness today, but there is great documentation both through the Mirage website and at the Ocsigen site for Lwt.

module Relax (C: V1_LWT.CONSOLE) = struct
  let start console =
      Lwt.return ()
  end

We define Relax as a module parameterized by a V1_LWT.CONSOLE module (which we have no plan to use, but config.ml won’t be able to make a sensible program for us unless we’re ready to accept it). start takes a console argument representing a particular implementation of a console. If we had any plans to output anything, we’d do it by calling a function with console as an argument, but we’re not going to do that. We’re just going to relax today.

Now that we know how to relax, we can point config.ml at this code with foreign, so that we’ll eventually be able to register it. The code we want to run is in the file loaf.ml, in a module called Relax, so our first argument to foreign will be "Loaf.Relax". (This works so simply because config.ml and loaf.ml are in the same directory - if you structure things differently, you’ll have to work harder, so I recommend against it.)

Our second argument to foreign, according to the type signature, should be a 'a typ, and if we do so, the function will return a 'a impl. In order to call register, we need at least one job impl to put in the list, so we need to somehow make 'a be job in our call to foreign. Mirage provides a job impl called, simply, job.

We can’t just pass the results of foreign "Loaf.Relax" job off to register, though, because if we do that, mirage configure won’t know that we wanted a console, and we’ll still have no libraries that know about OS.Main.run in our Makefile. What we really need is a (console -> job) typ, so we can get a (console -> job) impl, and then pass it a console impl to get a job impl out.

Mirage provides two combinators for making this work - @-> for composing a 'a typ and 'b typ into a ('a -> 'b) typ, and a combinator $ for applying a ('a -> 'b) impl to a 'a impl and getting a 'b impl back. We can use foreign "Loaf.Relax" (console @-> job) to get a (console -> job) typ, let-bind this value to a variable (say, relax), and use this along with a Mirage-provided console impl as an argument to register.

open Mirage

let () =                                                                         
  let relax = foreign "Loaf.Relax" (console @-> job) in                          
  register "out_to_lunch" [ relax $ default_console ] 

A mirage configure --xen and make get us a small, very lazy unikernel named mir-out_to_lunch.xen and an automatically generated configuration file for Xen named out_to_lunch.xl. (Both names are taken from the string we pass as the first argument to register.) If we start it up this unikernel with sudo xm create -c out_to_lunch.xl, we get the following (somewhat disappointing) output:

[] dothraki@iBook:~/trivial$ sudo xm create -c out_to_lunch.xl 
[sudo] password for dothraki: 
Using config file "./out_to_lunch.xl".
Started domain out_to_lunch (id=2)
Domain has already finished
Could not start console

Our unikernel finishes so quickly that Xen can’t attach a console to it. If we start it paused and then unpause it after our console attaches, we can watch it boot:

[] dothraki@iBook:~/trivial$ sudo xm create -c out_to_lunch.xl -p
Using config file "./out_to_lunch.xl".
Started domain out_to_lunch (id=3)

and, in another window, sudo xm unpause out_to_lunch, to let out_to_lunch start booting. We then see the output sent to the console by Mirage as it’s booting, before control is handed over to our user program - in this case, return (), which executes immediately, returns from the main program, and informs Xen that the virtual machine is shutting down.

Started domain out_to_lunch (id=3)
kernel.c: Mirage OS!
kernel.c:   start_info: 0x11c1000(VA)
kernel.c:     nr_pages: 0x10000
...
x86_mm.c: Demand map pfns at 10001000-2010001000.
Initialising timer interface
main returned 0
[] dothraki@iBook:~/trivial$ 

Doing Nothing with Different Drivers

We’ve succesfully done nothing with this simple config.ml:

open Mirage

let () =
  let relax = foreign "Loaf.Relax" (console @-> job) in
  register "out_to_lunch" [ relax $ default_console ]

What if we want to do nothing over the network? Or do nothing with a filesystem? Or do nothing with all of these things?

Including support for a driver requires us to have two things: a typ representation to include in the arguments to foreign, and an impl to include in the list of arguments to register. The available typs and impls are defined in mirage.mli; some typs have more than one corresponding impl, or have parameterized constructors for impls.

For example, there are two ways to get a network impl, the impl representing raw device-level access to a network card:

  • val tap0: network impl
  • val netif: string -> network impl

tap0 just returns the first available network interface; netif takes a string argument and attempts to find a matching network interface, then makes that interface available in the returned value. The same network typ applies for both:

open Mirage

let () =
  let relax = foreign "Loaf.Relax" (network @-> network @-> job) in
  register "out_to_lunch" [ relax $ tap0 $ netif "1" ]

This config.ml will build against a loaf.ml that has a Relax module parameterized by two V1_LWT.NETWORK modules, and a start function that expects two network impl arguments:

module Relax (IGNORED: V1_LWT.NETWORK) (ALSO_IGNORED: V1_LWT.NETWORK) = struct
  let start default string_parameterized = 
    Lwt.return ()
end

(To actually run this in Xen, we need to make a couple alterations to the autogenerated out_to_lunch.xl so that two network interfaces are actually provided, but this can be done once and saved off somewhere so as not to be overwritten by subsequent rebuilds.)

We can do nothing with multiple kinds of drivers, too:

open Mirage                                                                      
                                                                                 
let () =                                                                         
  let relax = foreign "Loaf.Relax" (network @-> console @-> job) in    
  register "out_to_lunch" [ relax $ tap0 $ default_console ]     

If we modify loaf.ml have a Relax module parameterized by a V1_LWT.NETWORK and V1_LWT.CONSOLE, and provide a start with network impl and console impl parameters, we’re off to the races.

For The Ambitious

The start function in Loaf.Relax can do more than just immediately return, of course. Programmers with ambition, gumption, and Tasks To Accomplish can define programs that Get Things Done, and launch them from start. Programs that plan to use, say, a network interface, can call functions provided by V1_LWT.NETWORK on the network impl provided to start to generate and receive network traffic; console programs can write to their provided console impl. The “Hello Mirage World” tutorial gives great examples and instructions for making things happen - perfect for a rainy day.

Today’s more of a return () type of day, though.