Some time ago, I had a conversation with my fellow GDG organizer Randy Gupta. He's a Google Developer Expert on cloud, and he blew my mind when he said that you can create Docker images containing a single, self-contained executable. Think about it: no libraries, no package manager - just a single program doing it's job. That's amazing!
So because I love Kotlin, I wondered how far we can take this using Kotlin Native. With that, we can compile Kotlin code as an executable. Then we can add this to a Docker image. Let's begin, and have some fun! π
Kotlin, Go Native
As a first step, we'll create a simple hello world program and compile that to native. There's a good tutorial to start. Our hello_world.kt
program:
fun main() {
println("Hello, World!")
}
Now we can compile and run it natively:
$ kotlinc-native hello_world.kt -o hello_world
$ ./hello_world.kexe
Hello, World!
$ file hello_world.kexe
hello_world.kexe: Mach-O 64-bit executable x86_64
Ok, this works! But as you can see, because I ran this on my Mac, this created a Mac executable. For Docker, we will need a Linux executable. We can pass -target linux
as a parameter to kotlinc-native
to do that:
$ kotlinc-native -target linux hello_world.kt -o hello_world_linux
$ ll *.kexe
-rwxr-xr-x 1 mreichelt mreichelt 607K 25 Dez 19:06 hello_world.kexe
-rwxr-xr-x 1 mreichelt mreichelt 375K 25 Dez 19:14 hello_world_linux.kexe
$ file hello_world_linux.kexe
hello_world_linux.kexe: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.16, BuildID[xxHash]=421d2b66bd8de945, not stripped
Ok, cool - we have a Linux binary! Docker will be so happyβ¦ π
Docker + Kotlin Native = β€οΈ
Let's pack this into a Docker image using a Dockerfile
:
FROM ubuntu
COPY hello_world_linux.kexe /hello_world
ENTRYPOINT ["/hello_world"]
Now, we can build the Docker image & run it:
$ docker build --tag hello_kotlin .
Sending build context to Docker daemon 5.179MB
Step 1/3 : FROM ubuntu
---> a2a15febcdf3
Step 2/3 : COPY hello_world_linux.kexe /hello_world
---> 9438118fba59
Step 3/3 : ENTRYPOINT ["/hello_world"]
---> Running in 2454425124e7
Removing intermediate container 2454425124e7
---> 47b7a9b05993
Successfully built 47b7a9b05993
Successfully tagged hello_kotlin:latest
$ docker run --rm hello_kotlin
Hello, World!
$ docker image list hello_kotlin
REPOSITORY TAG IMAGE ID CREATED SIZE
hello_kotlin latest 47b7a9b05993 2 minutes ago 64.6MB
Yay, it works! Think of what you can do now:
- Compile Kotlin code to a native binary
- you don't have to use the CLI compiler - this should work for your Gradle-builds from the IDE as well!
- no need for the JVM, so our Docker image already has 64.6MB in size (not more than 600MB)! π
And because Docker caches the lower layers (for us it's Ubuntu), there is no actual need to make this smaller. This will be the best result for most people, because if you need a shell or other Ubuntu tools, this image will give you that flexibility. But if you're courious how we could make this smaller, and you're willing to leave some comfort behind: read on!
Honey, I Shrunk the Docker Image
Docker offers a way to create images from scratch. So let's do that - we create a file shrinked.Dockerfile
, and we'll build a new Docker image hello_kotlin_shrinked
containing only our Kotlin Native executable!
FROM scratch
COPY hello_world_linux.kexe /hello_world
ENTRYPOINT ["/hello_world"]
So let's build and run that:
$ docker build --tag hello_kotlin_shrinked . --file shrinked.Dockerfile
[β¦]
Successfully tagged hello_kotlin_shrinked:latest
$ docker run --rm hello_kotlin_shrinked
standard_init_linux.go:211: exec user process caused "no such file or directory"
Hmm, that didn't work out. The reason is simple: the Kotlin Native compiler created a dynamically linked binary, meaning this file contains references to some shared libraries (.so
files on Linux, .dll
on Windows). Bummer!
I couldn't find any option for the kotlinc-native
compiler to build a static, self-contained executable. I played around a little, but I didn't find anything close to how Go creates self-contained binaries. I found this excellent blog post on how to do this from Go.
I also asked around in the #kotlin-native channel of the Kotlin Slack. I got a reply by Dominic Fischer that there's an -include-binary
option which could do what I want. I didn't come around to try that out yet, but if I do I'll update this post. Thanks, Dominic!
So, our binary doesn't run because it can't find those shared libraries. What happens if we add those to the Docker image directly? We can find which are needed by using the ldd
tool. Let's use our previously created, Ubuntu-based Docker image to debug this. We're overriding the entrypoint to be a bash
. Also we can mount the current working directory to /host
if we need this later.
$ docker run --rm --volume $(pwd):/foo --entrypoint /bin/bash -it hello_kotlin
root@a34eeb5c7bef:/# ldd /hello_world
linux-vdso.so.1 (0x00007ffc187bd000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f1a39a92000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f1a396f4000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f1a394d5000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f1a392bd000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1a38ecc000)
/lib64/ld-linux-x86-64.so.2 (0x00007f1a39c96000)
Ok, so now we know which shared libraries we need! linux-vdso.so.1
is a special library - it is directly provided by the Linux kernel. So we only need to care about the others.
I copied the entire Ubuntu content over to my host, and I created 2 empty directories lib
and lib64
and copied over only the mentioned files and symbolic links from the original Ubuntu image. Let's see how this looks:
Now we can modify our shrinked.Dockerfile
and add those libs:
FROM scratch
COPY lib /lib
COPY lib64 /lib64
COPY hello_world_linux.kexe /hello_world
ENTRYPOINT ["/hello_world"]
Let's see if we can now build and run this!
$ docker build --tag hello_kotlin_shrinked . --file shrinked.Dockerfile
[β¦]
Successfully tagged hello_kotlin_shrinked:latest
$ docker run --rm hello_kotlin_shrinked
Hello, World!
$ docker images 'hello_kotlin*'
REPOSITORY TAG IMAGE ID CREATED SIZE
hello_kotlin_shrinked latest a66abb1efa53 About a minute ago 4.54MB
hello_kotlin latest 47b7a9b05993 3 hours ago 64.6MB
Wow, that's cool - we now have a Docker image of 4.54MB in size that runs a Kotlin Native binary successfully. Party time! πππ
Conclusion
We looked at how to cross-compile a Kotlin program for Linux. Then we created a Docker image, using that program as an entrypoint. By leaving the JVM behind, we could already produce a Docker image of 64.6MB, most of which coming from the Ubuntu image itself.
Then, we entered the danger zone and looked at how we can strip most things of Ubuntu away. We included only the necessary libraries, and that way we got the image down to 4,54MB.
Some final note: If you compile your own (real) Kotlin Native program, you probably need more files than you might think. Using https connections? I bet you'll need the SSL root certificates provided in each Linux distro. Using some Kotlin Native libs? Maybe some of those add references to other native .so
libs, and your program won't start otherwise. So always test sensibly!
If you liked this post, please give it a β€οΈ and follow me on Twitter!
I would love to know more if you use this somewhere. Also, I'm pretty sure there must be some tools / tricks out there that would help in this matter. If you know something I don't: please write! π
Cover photo by Denise Johnson on Unsplash.
Top comments (0)