Experiments with Dart Microservices

Methods for Container Size Reduction

Going From Flutter to Dart

Like many, we were introduced to Dart by way of Flutter, and as our app complexity grew, we gradually found the need to divest the app-side of logic outside of the hot path that could be better addressed by dedicated microservices available through the backend.

Up until this point, all of our supporting backend microservices were implemented in a mix of Golang and Python. While we knew of Dart’s support for server-side application development, it wasn’t exactly something that was getting very much attention. A further challenge was that it wasn’t always immediately obvious where exactly the line between Flutter and Dart was drawn (as we found out, many plugins on pub advertise themselves as being Flutter plugins and add explicit Flutter dependencies — including mocking up application examples in Flutter, whilst the underlying plugin logic has no actual dependency on Flutter).

In the end, the most idiomatic approach seemed to be to:

separate out the relevant application logic into a library package that could be used directly by both the Flutter and Dart side applications; and develop a small service shim that would wrap a simple REST API around the library package and handle other basic service provisioning and instrumentation issues (service discovery and registration, logging, health checks, exposing metrics for scraping, etc.).

Dart Runtime and Containerization

Google provides a number of Docker base images to get started with containerization of server-side Dart applications. These include the google/dart-runtime and google/dart-runtime-base images.

Initial Containerization

The initial Dockerfile we used was simply an extension of the google/dart-runtime image to allow argument pass-through to the container:

Dockerfile for a server-side Dart app with JIT compilation and argument pass-through

The initial version of our VIN decoding microservice including the Dart runtime weighed in at a hefty 220MB container — a far cry from the ~10MB container images we’ve become accustomed to in the Golang world!

From JIT to AOT

Understandably, the resulting container size of a JIT compilation environment including the totality of the Dart runtime left something to be desired. Fortunately, AOT compilation, already used by Flutter, and supported in earlier versions of Dart, has been dusted off and re-introduced in the form of the dart2aot binary as of the Dart 2.3 SDK release earlier this year (noted in the following GitHub issue):

The first step, then, was in shifting from JIT to AOT compilation in order to discard as much of the runtime as possible through a multi-stage build. We were incidentally not the first to have this idea, and stumbled across a proof of concept in the following GitHub gist:

Multi-stage Dockerfile for a server-side Dart app with AOT compilation

With this approach, the resulting container image is down to a slightly more palatable 75MB —a good start, but still not great.

From AOT to Native

The remaining runtime artifacts are now limited to the server.aot binary, the dartaotruntime binary, and a minimal set of requisite shared libraries provided by the bitnami/minideb image:

$ ls -la server.aot

-rw-rw-r-- 1 pmundt pmundt 3838560 Oct 4 10:25 server.aot

$ ls -la `which dartaotruntime`

-rwxr-xr-x 1 root root 4781944 Sep 26 10:02 /usr/lib/dart/bin/dartaotruntime

Interest in a single combined binary has been expressed by a number of people, the progress of which is tracked in the following GitHub Issue:

This has resulted in the development of a dart2native mode of compilation capable of producing an integrated shared binary with no further dependencies on the Dart runtime environment:

$ dart2native bin/server.dart -o server

Generated: /home/pmundt/devel/git/vin-decoder-service/server

$ ls -la server

-rwxrwxr-x 1 pmundt pmundt 8622704 Oct 4 10:31 server

$ ldd server

linux-vdso.so.1 (0x00007ffcb0de2000)

libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ff826bb3000)

/lib64/ld-linux-x86-64.so.2 (0x00007ff8273ff000)

libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007ff826a65000)

libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007ff826a44000)

libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007ff826a3e000)

While single-binary compilation hasn’t saved us anything in image size, the loosening of the dependencies on Dart runtime components means that we can now more aggressively strip down the resulting container.

Further Container Reduction with Alpine

While the bitnami/minideb base image gives us a reasonable basis for starting out, the resulting container is still too large. As we now only have a single binary with dart2native compilation, and no additional dependencies, we can use a minimal glibc environment provided by Alpine to further bring this down:

Multi-stage Dockerfile for a server-side Dart app with native compilation, using Alpine

our end result is now a container that is only 20MB — greater than an order of magnitude in size reduction since we started out. Not bad!

Next Steps

The last remaining hurdles are now:

Generation of statically linked binaries by the dart2native builder, which will allow us to switch over to a scratch image and throw out the rest of the supporting libraries/binaries in the container — which should close the size gap with Golang containers; and

builder, which will allow us to switch over to a image and throw out the rest of the supporting libraries/binaries in the container — which should close the size gap with Golang containers; and Functional strip -ping of resulting native ELF binaries — current experimentation with stripping debug symbols, DWARF DWO objects, or .eh_frame breaks the generated binary. In theory, there should be nothing functionally dependent upon DWARF CFI for stack unwinding in the Dart VM, though this is something that will no doubt require further investigation by someone much more competent in Dart internals.

What about Performance?

While we have focused almost primarily on size, we have not yet mentioned anything about performance, or more specifically, the impact of different modes of compilation on application run-time performance. This is one of the things we are presently evaluating as part of our work in the SODALITE project, and plan to have a follow-up blog about this in the near future.

Conclusion

With consideration to container size, some careful crafting of the Dockerfile and stripping away of the runtime environment allows server-side Dart applications to come in at manageable sizes for realistic real-world deployment.

This is still a continuing area of development, and we fully expect further progress to be made, particularly as other use cases for server-side Dart begin to emerge (e.g. as a language runtime for FaaS functions — an area we are also actively working on).