Running .NET Apps on OpenShift
This article will guide you on running a .NET application on OpenShift using the Source-to-Image (S2I) tool. While .NET is not my primary area of expertise, I have been working with it quite extensively lately. In this article, we will examine more complex application cases, which may initially present some challenges.
If you are interested in developing applications for OpenShift, you may also want to read my article on deploying Java applications using the odo tool.
Why Source-to-Image?
That’s probably the first question that comes to mind. Let’s start with a brief definition. Source-to-Image (S2I) is a framework and tool that enables you to write images using the application’s source code as input, producing a new image. In other words, it provides a clean, repeatable, and developer-friendly way to build container images directly from source code – especially in OpenShift, where it’s a core built-in mechanism. With S2I, there is no need to create Dockerfiles, and you can trust that the images will be built to run seamlessly on OpenShift without any issues.
Source Code
Feel free to use my source code if you’d like to try it out yourself. To do that, you must clone my sample GitHub repository. Then you should only follow my instructions.
Prerequisite – OpenShift cluster
There are several ways in which you can run an OpenShift cluster. I’m using a cluster that runs in AWS. But you can run it locally using OpenShift Local. This article describes how to install it on your laptop. You can also take advantage of the 30-day free Developer Sandbox service. However, it is worth mentioning that its use requires creating an account with Red Hat. To provision an OpenShift cluster in the developer sandbox, go here. You can also download and install Podman Desktop, which will help you set up both OpenShift Local and connect to the Developer Sandbox. Generally speaking, there are many possibilities. I assume you simply have an OpenShift cluster at your disposal.
Create a .NET application
I have created a slightly more complex application in terms of its modules. It consists of two main projects and two projects with unit tests. The WebApi.Library project is simply a module to be included in the main application, which is WebApi.App. Below is the directory structure of our sample repository.
.
├── README.md
├── WebApi.sln
├── src
│  ├── WebApi.App
│  │  ├── Controllers
│  │  │  └── VersionController.cs
│  │  ├── Program.cs
│  │  ├── Startup.cs
│  │  ├── WebApi.App.csproj
│  │  └── appsettings.json
│  └── WebApi.Library
│  ├── VersionService.cs
│  └── WebApi.Library.csproj
└── tests
├── WebApi.App.Tests
│  ├── VersionControllerTests.cs
│  └── WebApi.App.Tests.csproj
└── WebApi.Library.Tests
├── VersionServiceTests.cs
└── WebApi.Library.Tests.csprojPlaintextBoth the library and the application are elementary in nature. The library provides a single method in the VersionService class to return its version read from the .csproj file.
using System.Reflection;
namespace WebApi.Library;
public class VersionService
{
private readonly Assembly _assembly;
public VersionService(Assembly? assembly = null)
{
_assembly = assembly ?? Assembly.GetExecutingAssembly();
}
public string? GetVersion()
{
var informationalVersion = _assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
return informationalVersion ?? _assembly.GetName().Version?.ToString();
}
}C#The application includes the library and uses its VersionService class to read and return the library version in the GET /api/version endpoint. There is no story behind it.
using Microsoft.AspNetCore.Mvc;
using WebApi.Library;
namespace WebApi.App.Controllers
{
[ApiController]
[Route("api/version")]
public class VersionController : ControllerBase
{
private readonly VersionService _versionService;
private readonly ILogger<VersionController> _logger;
public VersionController(ILogger<VersionController> logger)
{
_versionService = new VersionService();
_logger = logger;
}
[HttpGet]
public IActionResult GetVersion()
{
_logger.LogInformation("GetVersion");
var version = _versionService.GetVersion();
return Ok(new { version });
}
}
}C#The application itself utilizes several other libraries, including those for generating Swagger API documentation, Prometheus metrics, and Kubernetes health checks.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Prometheus;
using System;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace WebApi.App
{
public class Startup
{
private readonly IConfiguration _configuration;
public Startup(IConfiguration configuration)
{
_configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
// Enhanced Health Checks
services.AddHealthChecks()
.AddCheck("memory", () =>
HealthCheckResult.Healthy("Memory usage is normal"),
tags: new[] { "live" });
services.AddControllers();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo {Title = "WebApi.App", Version = "v1"});
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// Enable prometheus metrics
app.UseMetricServer();
app.UseHttpMetrics();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "person-service v1"));
// Kubernetes probes
app.UseHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = reg => reg.Tags.Contains("live"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.UseHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = reg => reg.Tags.Contains("ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapMetrics();
});
using var scope = app.ApplicationServices.CreateScope();
}
}
}C#As you can see, the WebApi.Library project is included as an internal module, while other dependencies are simply added from the external NuGet repository.
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\WebApi.Library\WebApi.Library.csproj" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.8" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.3</Version>
<IsPackable>true</IsPackable>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>WebApi.App</PackageId>
<Authors>piomin</Authors>
<Description>WebApi</Description>
</PropertyGroup>
</Project>XMLUsing OpenShift Source-to-Image for .NET
S2I Locally with CLI
Before testing a mechanism on OpenShift, you try Source-to-Image locally. On macOS, you can install s2i CLI using Homebrew:
brew install source-to-imageShellSessionAfter installation, check its version:
$ s2i version
s2i v1.5.1ShellSessionThen, go to the repository root directory. At this point, we need to parameterize our build because the repository contains several projects. Fortunately, S2I provides a parameter that allows us to set the main project in a multi-module structure easily. It must be set as an environment variable for the s2i command. The following command sets the DOTNET_STARTUP_PROJECT environment variable and uses the registry.access.redhat.com/ubi8/dotnet-90:latest as a builder image.
s2i build . registry.access.redhat.com/ubi8/dotnet-90:latest webapi-app \
-e DOTNET_STARTUP_PROJECT=src/WebApi.AppShellSessionOf course, you must have Docker or Podman running on your laptop to use s2i. So, before using a builder image, pull it to your host.
podman pull registry.access.redhat.com/ubi8/dotnet-90:latestShellSessionLet’s take a look at the s2i build command output. As you can see, s2i restored and built two projects, but then created a runnable output for the WebApi.App project.

What about our unit tests? To execute tests during the build, we must also set the DOTNET_TEST_PROJECTS environment variable.
s2i build . registry.access.redhat.com/ubi8/dotnet-90:latest webapi-app \
-e DOTNET_STARTUP_PROJECT=src/WebApi.App \
-e DOTNET_TEST_PROJECTS=tests/WebApi.App.TestsShellSessionHere’s the command output:

The webapi-app image is ready.
$ podman images webapi-app
REPOSITORY TAG IMAGE ID CREATED SIZE
docker.io/library/webapi-app latest e9d94f983ac1 5 seconds ago 732 MBShellSessionWe can run it locally with Podman (or Docker):

S2I for .NET on OpenShift
Then, let’s switch to the OpenShift cluster. You need to log in to your cluster using the oc login command. After that, create a new project for testing purposes:
oc new-project dotnetShellSessionIn OpenShift, a single command can handle everything necessary to build and deploy an application. We need to provide the address of the Git repository containing the source code, specify the branch name, and indicate the name of the builder image located within the cluster’s namespace. Additionally, we should include the same environment variables as we did previously. Since the version of source code we tested before is located in the dev branch, we must pass it together with the repository URL after #.
oc new-app openshift/dotnet:latest~https://github.com/piomin/web-api-2.git#dev --name webapi-app \
--build-env DOTNET_STARTUP_PROJECT=src/WebApi.App \
--build-env DOTNET_TEST_PROJECTS=tests/WebApi.App.TestsShellSessionHere’s the oc new-app command output:

Then, let’s expose the application outside the cluster using OpenShift Route.
oc expose service/webapi-appShellSessionFinally, we can verify the build and deployment status:

There is also a really nice command you can use here. Try yourself 🙂
oc get allShellSessionOpenShift Builds with Source-to-Image
Verify Build Status
Let’s verify what has happened after taking the steps from the previous section. Here’s the panel that summarizes the status of our application on the cluster. OpenShift automatically built the image from the .NET source code repository and then deployed it in the target namespace.

Here are the logs from the Pod with our application:

The build was entirely performed on the cluster. You can verify the logs from the build by accessing the Build object. After building, the image was pushed to the internal image registry in OpenShift.

Under the hood, the BuildConfig was created. This will be the starting point for the next example we will consider.
apiVersion: build.openshift.io/v1
kind: BuildConfig
metadata:
annotations:
openshift.io/generated-by: OpenShiftNewApp
labels:
app: webapi-app
app.kubernetes.io/component: webapi-app
app.kubernetes.io/instance: webapi-app
name: webapi-app
namespace: dotnet
spec:
output:
to:
kind: ImageStreamTag
name: webapi-app:latest
source:
git:
ref: dev
uri: https://github.com/piomin/web-api-2.git
type: Git
strategy:
sourceStrategy:
env:
- name: DOTNET_STARTUP_PROJECT
value: src/WebApi.App
- name: DOTNET_TEST_PROJECTS
value: tests/WebApi.App.Tests
from:
kind: ImageStreamTag
name: dotnet:latest
namespace: openshift
type: SourceYAMLOpenShift with .NET and Azure Artifacts Proxy
Now let’s switch to the master branch in our Git repository. In this branch, the WebApi.Library library is no longer included as a path in the project, but as a separate dependency from an external repository. However, this library has not been published in the public NuGet repository, but in the internal Azure Artifacts repository. Therefore, the build process must take place via a proxy pointing to the address of our repository, or rather, a feed in Azure Artifacts.
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="WebApi.Library" Version="1.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.8" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.3</Version>
<IsPackable>true</IsPackable>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>WebApi.App</PackageId>
<Authors>piomin</Authors>
<Description>WebApi</Description>
</PropertyGroup>
</Project>XMLThis is how it looks in Azure Artifacts. The name of my feed is pminkows. To access the feed, I must be authenticated against Azure DevOps using a personal token. The full address of the NuGet registry exposed via my instance of Azure Artifacts is https://pkgs.dev.azure.com/pminkows/_packaging/pminkows/nuget/v3/index.json.

If you would like to build such an application locally using Azure Artifacts, you should create a nuget.config file with the configuration below. Then place it in the $HOME/.nuget/NuGet directory.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="pminkows" value="https://pkgs.dev.azure.com/pminkows/_packaging/pminkows/nuget/v3/index.json" />
</packageSources>
<packageSourceCredentials>
<pminkows>
<add key="Username" value="pminkows" />
<add key="ClearTextPassword" value="<MY_PERSONAL_TOKEN>" />
</pminkows>
</packageSourceCredentials>
</configuration>nuget.configOur goal is to run this type of build on OpenShift instead of locally. To achieve this, we need to create a Kubernetes Secret containing the nuget.config file.
oc create secret generic nuget-config --from-file=nuget.configShellSessionThen, we must update the contents of the BuildConfig object. The changed lines in the object have been highlighted. The most important element is spec.source.secrets. The Kubernetes Secret containing the nuget.config file must be mounted in the HOME directory of the base image with .NET. We also change the branch in the repository to master and increase the logging level for the builder to detailed.
apiVersion: build.openshift.io/v1
kind: BuildConfig
metadata:
annotations:
openshift.io/generated-by: OpenShiftNewApp
labels:
app: webapi-app
app.kubernetes.io/component: webapi-app
app.kubernetes.io/instance: webapi-app
name: webapi-app
namespace: dotnet
spec:
output:
to:
kind: ImageStreamTag
name: webapi-app:latest
source:
git:
ref: master
uri: https://github.com/piomin/web-api-2.git
type: Git
secrets:
- secret:
name: nuget-config
destinationDir: /opt/app-root/src/
strategy:
sourceStrategy:
env:
- name: DOTNET_STARTUP_PROJECT
value: src/WebApi.App
- name: DOTNET_TEST_PROJECTS
value: tests/WebApi.App.Tests
- name: DOTNET_VERBOSITY
value: d
from:
kind: ImageStreamTag
name: dotnet:latest
namespace: openshift
type: SourceYAMLNext, we can run the build again, but this time with new parameters using the command below. With increased logging level, you can confirm that all dependencies are being retrieved via the Azure Artifacts instance.
oc start-build webapi-app --followShellSessionConclusion
This article covers different scenarios about building and deploying .NET applications in developer mode on OpenShift. It demonstrates how to use various parameters to customize image building according to the application’s needs. My goal was to demonstrate that deploying .NET applications on OpenShift is straightforward with the help of Source-to-Image.

Leave a Reply