In this tutorial, you’ll use dotnet framework features and F# language techniques when building a template to create a fully functional Minecraft Server running on an Azure Container Instance with its world data stored in an Azure Storage Account. Because Farmer is a domain specific language embedded within F#, you are able to utilize the rich dotnet ecosystem and a static type system to “craft” an advanced deployment.
We will need a few dependencies here, so first let’s reference the packages and open the namespaces:
#r "nuget: Farmer"
#r "nuget: MinecraftConfig"
#r "nuget: FSharp.Data"
open System
open Farmer
open Farmer.Builders
open FSharp.Data
open MinecraftConfig
Deploying infrastructure often also means building configuration files, and Minecraft is no different. There are four critical files for a server:
First we build a list of users that we will allow on our server. We’ll use this list to build both the ops.json (operators) and whitelist.json (allowed gamers), so we will indicate which ones are operators.
let operator = true
/// Our list of Minecraft users - their username, uuid, and whether or not they are an operator.
let minecrafters = [
"McUser1", "a6a66bfb-6ff7-46e3-981e-518e6a3f0e71", operator
"McUser2", "d3f2e456-d6a4-47ac-a7f0-41a4dc8ed156", not operator
"McUser3", "ceb50330-681a-4d9d-8e84-f76133d0fd28", not operator
]
Now we build the whitelist.json and ops.json files - the MinecraftConfig application handles formatting the configuration file, we just need to map from our list of minecrafters
to the lists of the records for the whitelist and the operator. whitelist
holds the contents of our whitelist.json file, and ops
hosts the contents of the ops.json file. We will write use those later.
/// Let's allow our list of minecrafters on the whitelist.
let whitelist =
minecrafters
|> List.map (fun (name, uuid, _) -> { Name=name; Uuid=uuid })
|> Whitelist.format
/// Filter the minecrafters that aren't operators.
let ops =
minecrafters
|> List.filter (fun (_, _, op) -> op) // Filter anyone that isn't an operator
|> List.map (fun (name, uuid, _) -> { Name=name; Level=OperatorLevel.Level4; Uuid=uuid })
|> Ops.format
And we can generate an accepted EULA, storing this content in eula
.
/// And accept the EULA.
let eula = Eula.format true
Now we need a few properties that are used both for the server.properties and for the resulting infrastructure. The worldName
tells Minecraft where to store the world data. Since this will be mounted to an Azure Storage File share, we create a binding for it to make sure the name we use in the server.properties file matches what we use in the storage account.
The same is true for the serverPort
, which is both used in the server.properties file and must be exposed publicly on the Azure Container Group.
The name of the storage account is used in three places: the storage account itself, in the deployment script that will upload files to the storage account, and in the container group that will mount a volume from it. The storageAccountName
can be referenced in all three uses.
/// Add bindings for fields that are referenced in a few places
/// Name of the share for the world.
let worldName = "world1"
/// Port for this world
let serverPort = 25565
/// Storage account name
let storageAccountName = "mcworlddata"
And now we create the server.properties file, storing it in serverProperties
. With that, we completed generating all of the configuration files for the server and can move on to defining and deploying the infrastructure.
/// Write the customized server properties.
let serverProperties =
[
ServerPort serverPort
RconPort (serverPort + 10)
EnforceWhitelist true
WhiteList true
Motd "Azure Minecraft Server"
LevelName worldName
Gamemode "survival"
]
|> ServerProperties.format
A Minecraft server stores some data for the world that is generated, and people play in. That data, along with the configuration files, is stored in a directory that must be accessible to the server. Azure Container Groups are able to attach an Azure Storage Account File share as a volume, so we will create a storage account with a file share.
/// A storage account, with a file share for the server config and world data.
let serverStorage = storageAccount {
name storageAccountName
sku Storage.Sku.Standard_LRS
add_file_share_with_quota worldName 5<Gb>
}
There are some deployment orchestration tasks that cannot be fully represented by Azure resources, but we need ARM to carry them out for us. We can use deploymentScripts
as an Azure resource to represent script execution. This allows us to specify orchestration properties, such as that ARM should execute this deployment script after the storage account is deployed.
The script itself runs in a temporary container that has the Azure CLI ready and authenticated with a user that has the “Contributor” role over everything in this deployment. This is helpful because it means our script runs as a user that can access the storage account to upload content.
We need this deployment script to do three things:
First, we will tackle the configuration files. We are going to use F# to generate the CLI script, so we can actually embed these in the deployment script itself. To avoid any trouble with escaping characters for our script, we will encode all of the configuration files as base64 strings when we build the script, and then the script will decode the base64 data and write files out to the container file system where the Azure CLI can upload them.
az storage file upload
to transfer them to the storage account./// A deployment script to create the config in the file share.
let deployConfig =
// Helper function to base64 encode the files for embedding them in the
// deployment script.
let b64 (s:string) =
s |> System.Text.Encoding.UTF8.GetBytes |> Convert.ToBase64String
// Build a script that embeds the content of these files, writes to the
// deploymentScript instance and then copies
// to the storageAccount file share. We will include the contents of these
// files as base64 encoded strings so there is no need to worry about
// special characters in the embedded script.
let uploadConfig =
[
whitelist, Whitelist.Filename
ops, Ops.Filename
eula, Eula.Filename
serverProperties, ServerProperties.Filename
]
|> List.map (fun (content, filename) ->
$"echo {b64 content} | base64 -d > {filename} && az storage file upload --account-name {storageAccountName} --share-name {worldName} --source {filename}")
That seemed a bit complicated, but using the best of both F# and the Azure CLI, the actual code to do this is minimal. The b64
function converts any string you give it to bytes, and then base64 encodes those bytes into a string we can embed in the script.
Next, we have a list that contains the contents of each configuration file paired with the filename we need to write. We map each of those items to an interpolated string, which is where F# can execute little bits of code when building the string. Within the interpolated string, we call the b64
function to encode the contents of each file, which is what $"echo {b64 content}"
does. When the script executes, it will pass that string into base64 -d
which decides the base64 back into bytes that are written to a file. After each file is written, it’s uploaded with az storage file upload
which again uses interpolated string values to get the storageAccountName
, worldName
, and filename
values.
Having embedded the configuration files, now we need to add a line to the script to download the Minecraft server.jar and upload it as well. Whenever a new Minecraft Server is released, they update this page with a link that is named for the server version.
Without F#, we would probably stop here and just use the link for whatever version is out today. But F# has nice toys for reading and exploring data, like FSharp.Data which can parse HTML files, so we’re only a few lines away from scraping the download page for the link to the current version.
When this F# code is executed to build the ARM template, it will load the Download page, find the link starting with minecraft_server
, and copy the URL from the href
on that link. We will embed that URL into our deployment script as a parameter to a curl
call, which will download the file before calling az storage file upload
to copy the file to the storage account.
/// The script will also need to download the server.jar and upload it.
let uploadServerJar =
let results = HtmlDocument.Load "https://www.minecraft.net/en-us/download/server"
// Scrape for anchor tags from this download page.
results.Descendants ["a"]
// where the inner text contains "minecraft_server" since that's what is
// displayed on that link
|> Seq.filter (fun (x:HtmlNode) -> x.InnerText().StartsWith "minecraft_server")
// And choose the "href" attribute if present
|> Seq.choose(fun (x:HtmlNode) -> x.TryGetAttribute("href") |> Option.map(fun (a:HtmlAttribute) -> a.Value()))
|> Seq.head // If it wasn't found, we'll get an error here.
|> (fun url -> $"curl -O {url} && az storage file upload --account-name {storageAccountName} --share-name {worldName} --source server.jar")
Now we have two lists:
uploadConfig
is a list of the four lines of bash
that will decode and then upload the configuration files to the storage account.uploadServerJar
is a line of bash
to download the server software and upload it to the storage account.We concat those lines together with a semicolon ;
to break up our commands, and we have a full script we can run. The deploymentScript
resource itself is fairly simple, and we use depends_on serverStorage
to make sure this only runs after our storage account is deployed.
let scriptSource =
uploadServerJar :: uploadConfig
|> List.rev // do the server upload last so it won't start until the configs are in place.
|> String.concat "; "
deploymentScript {
name "deployMinecraftConfig"
// Depend on the storage account so this won't run until it's there.
depends_on serverStorage
script_content scriptSource
force_update
}
The container instance runs a Java Runtime Environmennt, giving it enough CPU and memory for a small server with a few players. It has a volume mounted to the Azure Storage Account File share where the configuration files and server.jar are uploaded.
The containerGroup
has a dependency on the storageAccount
so it won’t be deployed until the storageAccount is deployed. There is a bit of a race condition since the container group could be deployed and start before the deploymentScript
uploads the server.jar and configuration files. To prevent this issue the container runs a while
loop in bash
until the server starts successfully.
let serverContainer = containerGroup {
name "minecraft-server"
public_dns "azmcworld1" [ TCP, uint16 serverPort ]
add_instances [
containerInstance {
name "minecraftserver"
image "mcr.microsoft.com/java/jre-headless:8-zulu-alpine"
// The command line needs to change to the directory for the file share
// and then start the server.
// It needs a little more memory than the defaults, -Xmx3G gives it 3 GiB
// of memory.
command_line [
"/bin/sh"
"-c"
// We will need to do a retry loop since we can't have a depends_on
// for the deploymentScript to finish.
$"cd /data/{worldName}; while true; do java -Djava.net.preferIPv4Stack=true -Xms1G -Xmx3G -jar server.jar nogui && break; sleep 30; done"
]
// If we chose a custom port in the settings, it should go here.
add_public_ports [ uint16 serverPort ]
// It needs a couple cores or the world may lag with a few players
cpu_cores 2
// Give it enough memory for the JVM
memory 3.5<Gb>
// Mount the path to the Azure Storage File share in the container
add_volume_mount worldName $"/data/{worldName}"
}
]
// Add the file share for the world data and server configuration.
add_volumes [
volume_mount.azureFile worldName worldName serverStorage.Name.ResourceName.Value
]
}
Here we will build the template. The deployConfig
deployment script is especially interesting as it contains the embedded configuration files and the curl
command with the link to the current server.jar
from scraping the download page.
/// Build the deployment with storage, deployment script, and container group.
let deployment = arm {
location Location.EastUS
add_resources [
serverStorage
deployConfig
serverContainer
]
}
deployment |> Writer.quickWrite "minecraft-server"
After running this deployment, view the container group in the Azure Portal or with az container logs
to watch the server start up and generate a world. Once the world is generated, it’s ready to connect from your Minecraft Java Edition client by entering the DNS name for the container group!
If you need to change the configuration you could connect to the terminal of the container instance. But in the spirit of mature configuration management and immutable infrastructure, you should rebuild the config, stop the container group, and redeploy. The existing state - the Minecraft world data - is left intact in the storage account and the configuration is replaced with your updates. Once the update is deployed, you can restart the container group.