Leveraging ConfigMaps, we can inject configuration data into applications running in Kubernetes. In this article, we’ll look at how to use a Kubernetes ConfigMap to configure a .NET application from the outside and automatically reload changes applied to the ConfigMap.

Automatically reloading configuration data without redeploying the application and without doing a code change is super handy - not just for cloud-native applications. Being able to modify certain aspects of the application to hunt and pinpoint bugs is valuable and something you should consider adding to your applications too. For demonstration purposes, we will create a ConfigMap that allows us to modify the logging behavior of a simple HTTP API acting as the sample application.



The sample application

The sample application - a fairly basic .NET HTTP API - exposes just a single GET endpoint at /verify. You can either download the entire sample code from GitHub or use the container images already published and available at ghcr.io/thorstenhans/hotreloading-configuration-data-using-kubernetes-configmaps:3632688430 and ghcr.io/thorstenhans/hotreloading-configuration-data-using-kubernetes-configmaps:latest.

Because we will modify the configuration of the actual application throughout this article, you will notice that we will use both tags (3632688430 and latest).

As mentioned at the beginning of the article, we’re going to control which logs will be sent to STDOUT from outside of our application. The /verify endpoint splits out a bunch of log messages (each using a different log level):

[HttpGet("verify")]
public IActionResult Verify()
{
  _logger.LogTrace("Trace from {HostName}", Environment.MachineName);
  _logger.LogDebug("Debug from {HostName}", Environment.MachineName);
  _logger.LogInformation("Info from {HostName}", Environment.MachineName);
  _logger.LogWarning("Warning from {HostName}", Environment.MachineName);
  _logger.LogCritical("Critical message from {HostName}", Environment.MachineName);
  _logger.LogError("Error from {HostName}", Environment.MachineName);
  return Ok();
}

The sample application comes with a default configuration, appsettings.json (and the same values for appsettings.Development.json), that defines the following logging configuration:

{
 "Logging": {
  "LogLevel": {
   "Default": "Information",
   "ThorstenHans.SampleApi": "Warning",
   "Microsoft.AspNetCore": "Warning"
  }
 }
}

When we run the app with the default configuration and send a GET request to /verify, we will see the following output written to STDOUT:

warn: ThorstenHans.SampleApi.Controllers.VerificationController[0]
   Warning from d5c83f9c8f02
crit: ThorstenHans.SampleApi.Controllers.VerificationController[0]
   Critical message from d5c83f9c8f02
fail: ThorstenHans.SampleApi.Controllers.VerificationController[0]
   Error from d5c83f9c8f02

The entire source code of the sample application is available on GitHub.

Kubernetes ConfigMap refresher

Haven’t you used ConfigMaps in Kubernetes yet? No problem. Before implementing hot-reload, let’s do a quick refresher and dive into ConfigMaps.

In Kubernetes, we use ConfigMaps group and organize our non-sensitive configuration data. Containers (Pods) running in the same Kubernetes-Namespace can use that configuration data.

As part of the Pod specification, we can use configuration data from ConfigMaps for two everyday things:

  • Set Environment Variables
  • Mount configuration data into the container filesystem

Setting Environment Variables from ConfigMaps

Each container of a Pod can have multiple Environment Variables. Again, we have two options here. We can either link a single item of the ConfigMap to a particular Environment Variable or create Environment Variables from all items specified in a specific ConfigMap. Before we dive into linking Environment Variables, let’s create a simple ConfigMap for demonstration purposes:

apiVersion: v1
kind: ConfigMap
metadata:
 name: my-sample-config
data:
 SortOrder: "asc"
 PageSize: "50"

Let’s take a look at linking a single item (called SortOrder from the my-sample-config ConfigMap):

apiVersion: v1
kind: Pod
metadata:
 name: api
spec:
 containers:
 - name: main
  image: ghcr.io/thorstenhans/hotreloading-configuration-data-using-kubernetes-configmaps:3632688430
  env:
   - name: API_SortOrder
    valueFrom:
     configMapKeyRef:
      key: "SortOrder"
      name: "my-sample-config"
  resources:
   limits:
    cpu: 50m
    memory: 128Mi
  ports:
   - containerPort: 5000

You can also mark the configMapKeyRef as required by setting optional to false.

As an alternative approach, we can link all items from the ConfigMap using envFrom and configMapRef. In this case, we can use the prefix property to decorate all Environment Variables linked by the desired ConfigMap with an individual prefix. Again we use optional to control if the configuration data is required or not:

apiVersion: v1
kind: Pod
metadata:
 name: api
spec:
 containers:
 - name: main
  image: ghcr.io/thorstenhans/hotreloading-configuration-data-using-kubernetes-configmaps:3632688430
  envFrom:
   - prefix: API_
    configMapRef:
     name: my-sample-config
     optional: false
  resources:
   limits:
    cpu: 50m
    memory: 128Mi
  ports:
   - containerPort: 5000

Although both approaches are handy and commonly used in Kubernetes, you should remember that Environment Variables are only passed to processes upon startup! Environment Variables are not reloaded once the process has started. That said, we should use Environment Variables for configuration data that must not be reloaded while the application runs.

Although using ConfigMaps to set Environment Variables does not address the actual requirement in the first place, we’ll use this mechanism later in the article to connect the dots 😁.

Mount ConfigMaps into Container filesystem

In addition to setting Environment Variables from a ConfigMap, we can mount the items of a given ConfigMap into a folder of the container’s filesystem. When using this approach, Kubernetes will create a symlink for every item of your ConfigMap in the specified folder. The content of every symlinked ConfigMap-item consists of the actual configuration data.

To achieve this, we must first define a dedicated volume and update our Pod specification by adding a volumeMount block to link the volume to a particular folder in the container filesystem:

apiVersion: v1
kind: Pod
metadata:
 name: api
spec:
 containers:
 - name: main
  image: ghcr.io/thorstenhans/hotreloading-configuration-data-using-kubernetes-configmaps:3632688430
  volumeMounts:
   - name: sampleconfig
    mountPath: /etc/sampleapi
    readOnly: true
  resources:
   limits:
    cpu: 50m
    memory: 128Mi
  ports:
   - containerPort: 80
 volumes:
  - name: sampleconfig
   configMap:
    name: my-sample-config
    optional: false
    items:
     - key: SortOrder
      path: SortOrder
     - key: PageSize
      path: ItemsPerPage
    defaultMode: 0644

As you can see, we have some additional options when defining the volume. We can pick particular keys from the ConfigMap, control the filename they’ll get using the items array, and control the default file permission using defaultMode. In this example, all selected items will be linked into the /etc/myapp folder, which is explicitly marked as readOnly.

ConfigMap to control ILogger

Now that everybody knows what ConfigMaps in Kubernetes is and what we can do with them. It’s time to revisit our actual requirement: We want to control which logs are written to STDOUT without modifying or redeploying our application.

To do so, let’s now build the necessary ConfigMap and specify which logs should be written to STDOUT. In contrast to the default behavior, we want our application to include logs of level Debug and higher when logs are produced from within the C# namespace ThorstenHans.SampleApi:

apiVersion: v1
kind: ConfigMap
metadata:
 name: api-config
data:
 Logging__LogLevel__Default: "Information"
 Logging__LogLevel__ThorstenHans.SampleApi: "Debug"
 Logging__LogLevel__Microsoft.AspNetCore: "Warning"

Feeding .NET IConfiguration

In .NET, we use IConfiguration to pull configuration data from different sources. Luckily, there is an existing extension method provided by Microsoft which addresses our needs: We will now use AddKeyPerFile to load configuration data which is linked from the Kubernetes ConfigMap to the configuration folder:

builder.Configuration.AddKeyPerFile("/etc/sampleapi", false, true);

Several overloads are available for AddKeyPerFile. In this case, we use the second parameter to mark this configuration source as required, and the third parameter is used to make .NET reload configuration data upon changes.

Unfortunately, reloading on changes has yet to work!

Every file in /etc/sampleapi is as symlink, and .NET is not able to pick up changes applied to the original file by just knowing about the symlink. Fortunately, we can instruct .NET to use a polling mechanism instead. It will periodically read all files in /etc/sampleapi and make changes available to our application.

Let’s update our Pod specification and set the DOTNET_USE_POLLING_FILE_WATCHER Environment Variable:

apiVersion: v1
kind: Pod
metadata:
 name: api
spec:
 containers:
 - name: main
  image: ghcr.io/thorstenhans/hotreloading-configuration-data-using-kubernetes-configmaps:3632688430
  env:
   - name: DOTNET_USE_POLLING_FILE_WATCHER
    value: "true"
  volumeMounts:
   - name: apiconfig
    mountPath: /etc/sampleapi
    readOnly: true
  resources:
   limits:
    cpu: 50m
    memory: 128Mi
  ports:
   - containerPort: 5000
 volumes:
  - name: apiconfig
   configMap:
    name: api-config
    optional: false
    defaultMode: 0644

Testing Configuration Hot-Reload in Kubernetes

Now, we can deploy both (the ConfigMap and the Pod) to any Kubernetes cluster and see the configuration modifications being applied without having to restart the application:

kubectl apply -f ./kubernetes/config-map.yml
kubectl apply -f ./kubernetes/pod.yaml

kubectl port-forward pod/api 5000:5000

You can use cURL or any other HTTP client of your choice to interact with the API:

curl http://localhost:5000/verify

Consult the logs and see logs with level Debug being written to STDOUT as shown here:

Debug Logs sent to STDOUT

Go ahead, and modify the api-config ConfigMap by executing kubectl edit cm api-config and set Logging__LogLevel__ThorstenHans.SampleApi to Warning.

Keep in mind that it will take some time to update the actual configuration inside the container (about two minutes on my AKS cluster here). See the corresponding section of the Kubernetes documentation to understand why reloading the configuration may take some time. Once the value has reloaded, you will see only messages with level Warning and higher being logged to STDOUT.

Warning Logs sent to STDOUT

Set the configuration folder using an Environment Variable

Although our implementation already works as expected, we should add another improvement. We now access the configuration folder (/etc/sampleapi) by using a magic string inside our application. We should refactor this to specify the folder name from the outside. To do so, we’ll set the ConfigurationFolder Environment Variable on our container and use it in combination with the AddKeyPerFile method we used in the previous section.

First, let’s revisit the final Pod specification (also note that we moved from tag 3632688430 to latest:

apiVersion: v1
kind: Pod
metadata:
 name: api
spec:
 containers:
 - name: main
  image: ghcr.io/thorstenhans/hotreloading-configuration-data-using-kubernetes-configmaps:latest
  env:
   - name: DOTNET_USE_POLLING_FILE_WATCHER
    value: "true"
   - name: ConfigurationFolder
    value: /etc/sampleapi
  volumeMounts:
   - name: apiconfig
    mountPath: /etc/sampleapi
    readOnly: true
  resources:
   limits:
    cpu: 50m
    memory: 128Mi
  ports:
   - containerPort: 5000
 volumes:
  - name: apiconfig
   configMap:
    name: api-config
    optional: false
    defaultMode: 0644

Having the ConfigurationFolder Environment Variable specified, let’s review the corresponding part of the .NET application that deals with it:

const string varName = "DOTNET_RUNNING_IN_CONTAINER";
var runningInContainer = bool.TryParse(Environment.GetEnvironmentVariable(varName),
  out var isRunningInContainer)
  && isRunningInContainer;

var configFolder = builder.Configuration.GetValue<string>("ConfigurationFolder");

if (runningInContainer &&
  !string.IsNullOrWhiteSpace(configFolder) &&
  Directory.Exists(configFolder))
{
  builder.Configuration.AddKeyPerFile(configFolder, false, true);
}
else
{
  Console.WriteLine($"ConfigurationFolder set to: '{configFolder}'.");
  Console.WriteLine($"ConfigurationFolder exists: {Directory.Exists(configFolder)}");
  Console.WriteLine($"Running in Container: '{runningInContainer}'.");
   
  Console.WriteLine("Relying on default configuration");
}

With this modification, we can configure everything from the outside without restarting or redeploying the container.

Conclusion

Especially when running applications in the (private-)cloud, you should consider implementing hot-reload for your configuration data. Flipping some switches while the app remains running is priceless and a real timesaver.

Kubernetes comes with batteries that make consuming configuration data from different sources easy and straightforward.

No matter what you do, think twice before you hammer a vendor-specific SDK into your application! Try to stay as cloud- and cloud-vendor-agnostic as possible. It’ll payout in the long run.