Runtime arguments in MirageOS
TL;DR: Passing runtime arguments around is tricky, and prone to change every other month.
Motivation
Sometimes, as an unikernel developer and also as operator, it's nice to have some runtime arguments passed to an unikernel. Now, if you're into OCaml, command-line parsing - together with error messages, man page generation, ... - can be done by the amazing cmdliner package from Daniel Bünzli.
MirageOS uses cmdliner for command line argument passing. This also enabled
us from the early days to have nice man pages for unikernels (see
my-unikernel-binary --help
). There are two kinds
of arguments: those at configuration time (mirage configure
), such as the
target to compile for, and those at runtime - when the unikernel is executed.
In Mirage 4.8.1 and 4.8.0 (released October 2024) there have been some changes to command-line arguments, which were motivated by 4.5.0 (released April 2024) and user feedback.
First of all, our current way to pass a custom runtime argument to a unikernel
(unikernel.ml
):
open Lwt.Infix
open Cmdliner
let hello =
let doc = Arg.info ~doc:"How to say hello." [ "hello" ] in
let term = Arg.(value & opt string "Hello World!" doc) in
Mirage_runtime.register_arg term
module Hello (Time : Mirage_time.S) = struct
let start _time =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
Logs.info (fun f -> f "%s" (hello ()));
Time.sleep_ns (Duration.of_sec 1) >>= fun () -> loop (n - 1)
in
loop 4
end
We define the Cmdliner.Term.t
in line 6 (let term = ..
) - which provides documentation ("How to say hello."), the option to
use (["hello"]
- which is then translated to --hello=
), that it is optional,
of type string
(cmdliner allows you to convert the incoming strings to more
complex (or more narrow) data types, with decent error handling).
The defined argument is directly passed to Mirage_runtime.register_arg
,
(in line 7) so our binding hello
is of type unit -> string
.
In line 14, the value of the runtime argument is used (hello ()
) for printing
a log message.
The nice property is that it is all local in unikernel.ml
, there are no other
parts involved. It is just a bunch of API calls. The downside is that hello ()
should only be evaluated after the function start
was called - since the
Mirage_runtime
needs to parse and fill in the command line arguments. If you
call hello ()
earlier, you'll get an exception "Called too early. Please delay
this call to after the start function of the unikernel.". Also, since
Mirage_runtime needs to collect and evaluate the command line arguments, the
Mirage_runtime.register_arg
may only be called at top-level, otherwise you'll
get another exception "The function register_arg was called to late. Please call
register_arg before the start function is executed (e.g. in a top-level binding).".
Another advantage is, having it all in unikernel.ml means adding and removing
arguments doesn't need another execution of mirage configure
. Also, any
type can be used that the unikernel depends on - the config.ml is compiled only
with a small set of dependencies (mirage itself) - and we don't want to impose a
large dependency cone for mirage just because someone may like to use
X509.Key_type.t as argument type.
Earlier, before mirage 4.5.0, we had runtime and configure arguments mixed
together. And code was generated when mirage configure
was executed to
deal with these arguments. The downsides included: we needed serialization for
all command-line arguments (at configure time you could fill the argument, which
was then serialized, and deserialized at runtime and used unless the argument
was provided explicitly), they had to appear in config.ml
(which also means
changing any would need an execution of mirage configure
), since they generated code
potential errors were in code that the developer didn't write (though we had
some __POS__
arguments to provide error locations in the developer code).
Related recent changes are:
- in mirage 4.8.1, the runtime arguments to configure the OCaml runtime system (such as GC settings, randomization of hashtables, recording of backtraces) are now provided using the cmdliner-stdlib package.
- in mirage 4.8.0, for git, dns-client, and happy-eyeballs devices the optional arguments are generated by default - so they are always available and don't need to be manually done by the unikernel developer.
Let's dive a bit deeper into the history.
History
In MirageOS, since the early stages (I'll go back to 2.7.0 (February 2016) where
functoria was introduced) used an embedded fork of cmdliner
to handle command
line arguments.
February 2016 (Mirage 2.7.0)
When looking into the MirageOS 2.x series, here's the code for our hello world unikernel:
config.ml
open Mirage
let hello =
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
Key.(create "hello" Arg.(opt string "Hello World!" doc))
let main =
foreign
~keys:[Key.abstract hello]
"Unikernel.Hello" (console @-> job)
let () = register "hello-key" [main $ default_console]
and unikernel.ml
open Lwt.Infix
module Hello (C: V1_LWT.CONSOLE) = struct
let start c =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
C.log c (Key_gen.hello ());
OS.Time.sleep 1.0 >>= fun () ->
loop (n-1)
in
loop 4
end
As you can see, the cmdliner term was provided in config.ml
, and in
unikernel.ml
the expression Key_gen.hello ()
was used - Key_gen
was
a module generated by the mirage configure
invocation.
You can as well see that the term was wrapped in Key.create "hello"
- where
this string was used as the identifier for the code generation.
As mentioned above, a change needed to be done in config.ml
and a
mirage configure
to take effect.
July 2016 (Mirage 2.9.1)
The OS.Time
was functorized with a Time
functor:
config.ml
open Mirage
let hello =
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
Key.(create "hello" Arg.(opt string "Hello World!" doc))
let main =
foreign
~keys:[Key.abstract hello]
"Unikernel.Hello" (console @-> time @-> job)
let () = register "hello-key" [main $ default_console $ default_time]
and unikernel.ml
open Lwt.Infix
module Hello (C: V1_LWT.CONSOLE) (Time : V1_LWT.TIME) = struct
let start c _time =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
C.log c (Key_gen.hello ());
Time.sleep 1.0 >>= fun () ->
loop (n-1)
in
loop 4
end
February 2017 (Mirage pre3)
The Time
signature changed, now the sleep_ns
function sleeps in nanoseconds.
This avoids floating point numbers at the core of MirageOS. The helper package
duration
is used to avoid manual conversions.
Also, the console signature changed - and log
is now inside the Lwt monad.
config.ml
open Mirage
let hello =
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
Key.(create "hello" Arg.(opt string "Hello World!" doc))
let main =
foreign
~keys:[Key.abstract hello]
~packages:[package "duration"]
"Unikernel.Hello" (console @-> time @-> job)
let () = register "hello-key" [main $ default_console $ default_time]
and unikernel.ml
open Lwt.Infix
module Hello (C: V1_LWT.CONSOLE) (Time : V1_LWT.TIME) = struct
let start c _time =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
C.log c (Key_gen.hello ()) >>= fun () ->
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
loop (n-1)
in
loop 4
end
February 2017 (Mirage 3)
Another big change is that now console is not used anymore, but logs.
config.ml
open Mirage
let hello =
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
Key.(create "hello" Arg.(opt string "Hello World!" doc))
let main =
foreign
~keys:[Key.abstract hello]
~packages:[package "duration"]
"Unikernel.Hello" (time @-> job)
let () = register "hello-key" [main $ default_time]
and unikernel.ml
open Lwt.Infix
module Hello (Time : Mirage_time_lwt.S) = struct
let start _time =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
Logs.info (fun f -> f "%s" (Key_gen.hello ()));
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
loop (n-1)
in
loop 4
end
January 2020 (Mirage 3.7.0)
The _lwt
is dropped from the interfaces (we used to have Mirage_time and
Mirage_time_lwt - where the latter was instantiating the former with concrete
types: type 'a io = Lwt.t
and type buffer = Cstruct.t
-- in a cleanup
session we dropped the _lwt
interfaces and opam packages. The reasoning was
that when we'll get around to move to another IO system, we'll move everything
at once anyways. No need to have lwt
and something else (async
, or nowadays
miou
or eio
) in a single unikernel.
config.ml
open Mirage
let hello =
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
Key.(create "hello" Arg.(opt string "Hello World!" doc))
let main =
foreign
~keys:[Key.abstract hello]
~packages:[package "duration"]
"Unikernel.Hello" (time @-> job)
let () = register "hello-key" [main $ default_time]
and unikernel.ml
open Lwt.Infix
module Hello (Time : Mirage_time.S) = struct
let start _time =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
Logs.info (fun f -> f "%s" (Key_gen.hello ()));
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
loop (n-1)
in
loop 4
end
October 2021 (Mirage 3.10)
Some renamings to fix warnings. Only config.ml
changed.
config.ml
open Mirage
let hello =
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
Key.(create "hello" Arg.(opt string "Hello World!" doc))
let main =
main
~keys:[key hello]
~packages:[package "duration"]
"Unikernel.Hello" (time @-> job)
let () = register "hello-key" [main $ default_time]
and unikernel.ml
open Lwt.Infix
module Hello (Time : Mirage_time.S) = struct
let start _time =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
Logs.info (fun f -> f "%s" (Key_gen.hello ()));
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
loop (n-1)
in
loop 4
end
June 2023 (Mirage 4.4)
The argument was moved to runtime.
config.ml
open Mirage
let hello =
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
Key.(create "hello" Arg.(opt ~stage:`Run string "Hello World!" doc))
let main =
main
~keys:[key hello]
~packages:[package "duration"]
"Unikernel.Hello" (time @-> job)
let () = register "hello-key" [main $ default_time]
and unikernel.ml
open Lwt.Infix
module Hello (Time : Mirage_time.S) = struct
let start _time =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
Logs.info (fun f -> f "%s" (Key_gen.hello ());
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
loop (n-1)
in
loop 4
end
March 2024 (Mirage 4.5)
The runtime argument is in config.ml
refering to the argument as string
("Unikernel.hello"), and being passed to the start
function as argument.
config.ml
open Mirage
let runtime_args = [ runtime_arg ~pos:__POS__ "Unikernel.hello" ]
let main =
main
~runtime_args
~packages:[package "duration"]
"Unikernel.Hello" (time @-> job)
let () = register "hello-key" [main $ default_time]
and unikernel.ml
open Lwt.Infix
open Cmdliner
let hello =
let doc = Arg.info ~doc:"How to say hello." [ "hello" ] in
Arg.(value & opt string "Hello World!" doc)
module Hello (Time : Mirage_time.S) = struct
let start _time hello =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
Logs.info (fun f -> f "%s" hello);
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
loop (n-1)
in
loop 4
end
October 2024 (Mirage 4.8)
Again, moved out of config.ml
.
config.ml
open Mirage
let main =
main
~packages:[package "duration"]
"Unikernel.Hello" (time @-> job)
let () = register "hello-key" [main $ default_time]
and unikernel.ml
open Lwt.Infix
open Cmdliner
let hello =
let doc = Arg.info ~doc:"How to say hello." [ "hello" ] in
Mirage_runtime.register_arg Arg.(value & opt string "Hello World!" doc)
module Hello (Time : Mirage_time.S) = struct
let start _time =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
Logs.info (fun f -> f "%s" (hello ()));
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
loop (n-1)
in
loop 4
end
2024 (Not yet released)
This is the future with time defunctorized. Read more in the discussion.
To delay the start function, a dep
of noop
is introduced.
config.ml
open Mirage
let main =
main
~packages:[package "duration"]
~dep:[dep noop]
"Unikernel" job
let () = register "hello-key" [main]
and unikernel.ml
open Lwt.Infix
open Cmdliner
let hello =
let doc = Arg.info ~doc:"How to say hello." [ "hello" ] in
Mirage_runtime.register_arg Arg.(value & opt string "Hello World!" doc)
let start () =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
Logs.info (fun f -> f "%s" (hello ()));
Mirage_timer.sleep_ns (Duration.of_sec 1) >>= fun () ->
loop (n-1)
in
loop 4
Conclusion
The history of hello world shows that over time we slowly improve the developer experience, and removing the boilerplate needed to get MirageOS unikernels up and running. This is work over a decade including lots of other (here invisible) improvements to the mirage utility.
Our current goal is to minimize the code generated by mirage, since code generation has lots of issues (e.g. error locations, naming, binary size). It is a long journey. At the same time, we are working on improving the performance of MirageOS unikernels, developing unikernels that are useful in the real world (VPN endpoint, DNSmasq replacement, ...), and also simplifying the deployment of MirageOS unikernels.
If you're interested in MirageOS and using it in your domain, don't hesitate to reach out to us (via eMail: team@robur.coop) - we're keen to deploy MirageOS and find more domains where it is useful. If you can spare a dime, we're a registered non-profit in Germany - and can provide tax-deductable receipts for donations (more information).