.NET 5 - cross platform development walk-thru

This is a step-by-step walk-thru' to create a .NET cross platform application for Windows and Linux. We'll target 3 different Linux: Ubuntu on Windows (WSL), Synology DSM and Raspberry Pi (that choice of targets ... simply because that's what I have).

.NET 5 continues the evolution of .NET Core to bring together Windows and Linux development for some application types (and some other OSes are available). And to be clear, cross platform here does not include Windows Forms or GUI apps ...for now we are just talking about console/command line apps but that can of course include web servers and other services running in the background.

In this walk-thru we will be starting on Windows (because its just easier and more familiar (you may disagree). A summary of the steps:

  1. we'll create a simple app
  2. build / publish that for Linux
  3. run it locally but now on Linux using WSL (Windows Subsystem for Linux)
  4. copy it somewhere else and run it there too - for my setup I have a Synology NAS and Raspberry Pi available both of which are custom Linux's and (hardware dependent) should just work. The Raspberry Pi is different hardware (ARM) and that needs addressing.

After that, the next stage is to go back to 1 and make the app a little bit more interesting...we'll could create a simple web server app based on this foundation for example.

For full disclosure, my Synology NAS is a 918+ running up-to-date DSM 6 ...which is based on Intel hardware, as is my Windows 10 Professional used for development - that will make things easier, however the Raspberry Pi of course is not Intel but ARM based.

1. Creating the app

Assuming you have .NET 5 already installed. If not, install the SDK from here. If you are a developer on Windows click on the SDK-Windows installers-x64 link.

Create the simplest .NET command line app:

PS C:\Users\username> dotnet new "Console Application" --name helloworld
PS C:\Users\username> cd helloworld
PS C:\Users\username\helloworld>

Look in the file Program.cs to for the source code from that simplest of templates:

using System;

namespace helloworld
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
        }
    }
}

Then build it:

PS C:\Users\username\helloworld> dotnet build

And run it

PS C:\Users\username\helloworld> .\bin\Debug\net5.0\helloworld.exe
Hello World!

Rather dull! but it gives us a starting point. Of course, if you prefer all of that is just as easy in Visual Studio or Visual Code.

2. Publish for Linux

Next we want to build this for Linux. A choice to be made is for self-contained - where the published package includes all of .NET and has no dependencies and will just run - or framework dependent where the published package does not include .NET libraries and thus requires them to be installed on the target machine when we come to runtime.

For this I prefer self-contained. The package is MUCH bigger, much slower to create, much slower to copy and deploy and from a security point of view the runtime will not get updated if you update the whole server with some future .NET patch.

BUT the lack of dependency beats that IMHO for my servers I am pushing to and my application model. Of course any situation is different and choose what works for you.

A command line for publishing like this:

dotnet publish --output bin\publish\linux-x64 -c Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true

Breaking that command line down as follows:

publish - command for the dotnet exe

--output - choose a directory for the publish output to be built in

-c - choose the configuration for the build...by default it would be Debug but for publishing I'm more likely to want release as I would have done all my debugging on Windows before I got here.

--runtime - choose the runtime to be included and of course this will be hardware dependent depending where you will copy to and execute

--self-contained - chooses the self contained publish model over framework dependent

-p:PublishSingleFile - chooses the option to make it a single file ... all the various .NET libraries get bundled inside the single exe. Again there are downsides to this but I prefer it to keep things simple when it comes to copying around.

In fact, the publish processing is quite quick. For this example I end up with 64Mb file and a PDB...

PS C:\Users\username\helloworld> dir .\bin\publish\linux-x64\

    Directory: C:\Users\username\helloworld\bin\publish\linux-x64

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---          18/01/2021    12:01       62579646 helloworld
-a---          18/01/2021    12:01           9348 helloworld.pdb

...so that's quite big for a trivial application - but of course the libraries stay the same size as your application evolves and likely gets bigger itself and this runtime size wont change so much.

There is also an option to trim unused libraries but this is marked as evaluation so use it at your peril and test on the target server of course -- if their logic failed it would be to remove something that you actually needed and that of course would be bad at runtime. Adding this option will increase the build time for publish, presumably as it has to cross create references to work out what is not used.

As always you can do all the above from Visual Studio with friendly dialogs to help you - right click on your project in solution explorer and select publish - all the same options are there and you can save various profiles with different names (for example for different destinations and different target runtimes). Also it has options for publishing to cloudy places, not just the local file store.

3. Run the Linux build

Next we'll want to run our application under Linux.

We'll start with WSL - Windows Subsystem for Linux - because it is just there as we have Windows 10 Professional, and it just works.

We wont cover setting up WSL here -- plenty of resource help you do that. If its installed, then running a command like wsl uname -a will give you some version output just to prove its working:

wsl uname -a
Linux DESKTOP-1234567 4.19.128-microsoft-standard #1 SMP Tue Jun 23 12:58:10 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

Next start WSL interactively to a bash prompt (just execute "wsl") and navigate to the folder we created in the publish -- your local C: drive in WSL appears under the /mnt/c folder so the full path would be /mnt/c/Users/username/helloworld/bin/publish/linux-x64

PS C:\Users\username\helloworld> wsl
Welcome to Ubuntu 20.04 LTS (GNU/Linux 4.19.128-microsoft-standard x86_64)
...
username@DESKTOP-1234567:/mnt/c/Users/username/helloworld$ cd bin/publish/linux-x64/
username@DESKTOP-1234567:/mnt/c/Users/username/helloworld/bin/publish/linux-x64$ ./helloworld
Hello World!

Still rather dull of course but its the same app now running on Windows and Linux (all be it Linux on Windows, which feels a bit like cheating).

4. Push to another Linux somewhere else.

So next we'll likely want to push this binary somewhere else to run it for real.

I have Synology NAS 918+ running DSM 6 -- which is Synology's own flavour of Linux. It has Intel hardware so the same build should work. Because the NAS system has its own packaging, installation and upgrade regime thats why I prefer the single file and no-dependency approach to this publish... its just easier to see what is where and redo if in the future some Synology upgrade were to change it of install their own .NET for some reason for example. All that is debatable if course.

From Windows I use scp to copy to a Linux box. It just works better if you have ssh authorization setup already for this to just work without too much prompting. Also, scp wont create the target directory so do that first if it doesn't exist. Other tools like rsync can also do this (and maybe better) but just use what you've got. We also need to mark the executable so it can be run -- scp wont do this either.

ssh username@server "mkdir /tmp/helloworld"
scp -rp .\bin\publish\linux-x64\* username@server :/tmp/helloworld
helloworld                                                100%   60MB  48.0MB/s   00:01
helloworld.pdb                                            100% 9348     2.2MB/s   00:00
ssh username@server "chmod +x /tmp/helloworld/helloworld"

In the above I am using username for the Windows and Linux user names, server is the remote Linux server. Change as appropriate.

Now we want to run it. Start a terminal on your remote server or just execute from Windows, using ssh to the Linux box:

ssh username@server:/tmp/helloworld/helloworld
Hello World!

Note specific to Synology DSM 6, a rather annoying issue is that their version of /lib/libstdc is 6.0.20 and the .net runtime seems to need 22 ... and without it you very some noise warnings when the program runs:

/tmp/helloworld/helloworld: /lib/libstdc++.so.6: no version information available (required by /tmp/helloworld/helloworld)

Its nicely described in here. Its just noise (or so it seems) and is fixded by updating the library (which, IMHO, Synology ought to do).

4.1 Push to Raspberry Pi

The Raspberry Pi is different hardware so we cant use a x64 build.

But its a trivial change to republish for ARM specify runtime of linux-arm

dotnet publish --output bin\publish\arm-x64 -c Release --runtime arm-x64 --self-contained true -p:PublishSingleFile=true

Copy to your Pi, change file to executable and run it all the same as above (raspi is the network name for the Raspberry Pi in this case) ...

ssh pi@raspi "mkdir ~/helloworld"
scp -rp .\bin\publish\linux-arm\* pi@raspi2:~/helloworld
ssh pi@raspi "~/helloworld/helloworld"
Hello World!

Still rather dull of course but its the same app now running on Windows and now 3 flavours of Linux including alternative hardware than Intel.

Next we'll improve it to a web hosting application with a simple website, that's for the next post.