How to create cron jobs in K8S.
Tags: dotnet, docker, kubernetes, csharp
Published: 2023-10-29 20:04:32

Hi folks. Now, I want to show you how to create your custom cron job service. And run it with scheduling in K8S. Sure, there are many packages, such as Quarz and HangFire. However, I aim to show you base things, how it works, and how to set K8S. It can be helpful in further work. For K8S, I'll be using Helm, and in a simple example, I'll show how to create charts. Let's begin.

##Preparations

Let's create a simple console application:

```bash
dotnet new console -n CronSample
```

Next step, add the required NuGet packages:

```xaml
<ItemGroup>
      <PackageReference Include="RestSharp" Version="110.2.1-alpha.0.16" />
      <PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0-dev-10359" />
      <PackageReference Include="Serilog.Sinks.Console" Version="5.0.0-dev-00923" />
    </ItemGroup>
```


##Implementation
When everything is ready, let's write code. First of all, let's add the job. It'll be determining the job's name.

```C#
public enum Job
{
    HealthJob
}
```

Now, I ask you to create `Jobs` folder and to add the `JobProcessorBase` class inherited from the `IJobProcessor`.


```C#
public abstract class JobProcessorBase : IJobProcessor
{
    protected readonly ILogger Logger;

    protected JobProcessorBase(ILogger logger)
    {
        Logger = logger;
    }

    protected abstract Task Process();

    public abstract Job JobToProcess { get; }

    public async Task Execute()
    {
        var stopwatch = Stopwatch.StartNew();
        Logger.LogInformation("{JobToProcess} started", JobToProcess.ToString());

        await Process();

        stopwatch.Stop();
        Logger.LogInformation("{JobToProcess} ended, elapsed time: {ElapsedMilliseconds} ms", JobToProcess, stopwatch.ElapsedMilliseconds);
    }
```
There are only two methods: job and execution. The logger is needed for output results, which will show the start and stop of the job. Let's move on. Add the `JobProcessor ' inherited from the base class.


````C#
public class JobProcessor : JobProcessorBase
{
    private readonly IRestSharpService _restSharpService;

    public JobProcessor(IRestSharpService restSharpService, ILogger<JobProcessor> logger) : base(logger)
    {
        _restSharpService = restSharpService;
    }

    public override Job JobToProcess => Job.HealthJob;

    protected override async Task Process()
    {
        bool isHealthy = await _restSharpService.IsHealthy();
        Logger.LogInformation("Test endpoint is it Healthy : {IsHealthy}", isHealthy);
    }
}
````

This class calls service and logs results. Now, you should create a `Contracts` folder and add the `IRestSharpService` interface. And also, you need to create a `Services` folder and add `RestSharpService`.

````C#
public class RestSharpService : RestSharpClient, IRestSharpService
{
    public RestSharpService(AppSettings appSettings) : base(appSettings.TestEndpointConfiguration.BaseUrl)
    {
    }

    public async Task<bool> IsHealthy()
    {
        RestRequest request = new RestRequest("/");
        return await ExecuteAsyncThrowingEvenForNotFound(request, Method.Get) == "Healthy";
    }
}
````
This service executes the request to the external URL. And let's add the base class where implementing the RestSharp client.

````C#
public abstract class RestSharpClient : IDisposable
{
    private readonly RestClient _client;

    protected RestSharpClient(string baseUrl)
    {
        _client = new RestClient(baseUrl);
    }

    protected async Task<string> ExecuteAsyncThrowingEvenForNotFound(RestRequest request, Method method)
    {
        var resp = await _client.ExecuteAsync(request, method);

        if (!resp.IsSuccessStatusCode || resp.ErrorException is not null)
        {
            throw resp.ErrorException ?? new InvalidOperationException($"Not a success HTTP StatusCode: {resp.StatusCode}");
        }

        return "Healthy";
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    private void Dispose(bool disposing)
    {
        if (disposing)
        {
            _client.Dispose();
        }
    }
}
````

Also, add configurations and place them in the `Configuration` folder.

````C#
public class AppSettings
{
    public AppSettings(IDictionary<string, object> environmentVariables)
    {
        JobToProcess = (Job)Enum.Parse(typeof(Job), (string)environmentVariables["JOB"], true);

        TestEndpointConfiguration = new EndpointConfiguration
        {
            BaseUrl = (string)environmentVariables["TEST_ENDPOINT_BASE_URL"]
        };
    }

    public Job JobToProcess { get; set; }
    public EndpointConfiguration TestEndpointConfiguration { get; set; }
}

public class EndpointConfiguration
{
    public string? BaseUrl { get; set; }
}
````

We have already ended with the central part, and now we must register services. Let's create a `DI` folder  and add these classes:

````C#
public static class ProcessorConfiguration
{
    public static IServiceCollection AddJobProcessor(this IServiceCollection services)
    {
        services.AddTransient<IJobProcessor, JobProcessor>();

        return services;
    }
}
````

````C#
public static class RestSharpConfiguration
{
    public static IServiceCollection AddRestSharp(this IServiceCollection services)
    {
        services.AddSingleton<IRestSharpService, RestSharpService>();

        return services;
    }
}
````

Now, let's implement the application's execution.

````C#
public abstract class Program
{
    private Program() { }

    public static async Task<int> Main()
    {
        var appSettings = BuildAppSettingsFromEnvironmentVariables();

        Log.Logger = new LoggerConfiguration()
            .WriteTo.Console()
            .MinimumLevel.Information()
            .Enrich.FromLogContext()
            .CreateLogger();

        // Setup the dependency injection
        var serviceProvider = new ServiceCollection()
            .AddSingleton(appSettings)
            .AddJobProcessor()
            .AddRestSharp()
            .AddLogging(cfg => cfg.AddSerilog())
            .BuildServiceProvider();

        try
        {
            await serviceProvider.GetServices<IJobProcessor>().ToDictionary(job => job.JobToProcess)[appSettings.JobToProcess].Execute();

            return 0;
        }
        catch (Exception exc)
        {
            serviceProvider.GetRequiredService<ILogger<Program>>().LogCritical(exc, "An error has occured during {Job} process", appSettings.JobToProcess);

            return 1;
        }
    }

    private static AppSettings BuildAppSettingsFromEnvironmentVariables()
    {
        IDictionary<string, object> environmentVariables = Environment.GetEnvironmentVariables()
            .Cast<DictionaryEntry>()
            .Where(entry => entry.Key is string)
            .GroupBy(entry => ((string)entry.Key).ToUpper())
            .ToDictionary(g => g.Key, g => g.Single().Value!);

        return new AppSettings(environmentVariables);
    }
}
````

Here, set configurations and run the job's executing. And I almost forgot, we need to add `Dockerfile`.

````
FROM mcr.microsoft.com/dotnet/runtime:7.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src
COPY ["CronSample/CronSample.csproj", "CronSample/"]
RUN dotnet restore "CronSample/CronSample.csproj"
COPY . .
WORKDIR "/src/CronSample"
RUN dotnet build "CronSample.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "CronSample.csproj" -c Release -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "CronSample.dll"]
````

The cherry on the cake is the variables in the project's settings.


![vars](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rpf5yqb2lewyaoit7mel.png)

When everything is ready, we can check this out. You'll see something like that.


![result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1g34ajp6j1bdfl35ooxw.png)

 ##Docker
Now, we are beginning work with Docker. I hope you have installed Docker. You can build your image with Dockerfile, but you can also add the `docker-compose` file and run it.

````yaml
version: '3.7'

services:
  runner:
    image: cronsample:latest
    environment:
      - JOB=HealthJob
      - TEST_ENDPOINT_BASE_URL=https://dev.to
    build:
      context: .
      dockerfile: CronSample/Dockerfile
````
If you run the image, you should see the same result as was earlier.


![docker](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9qjn1q0gweidhkzggo5y.png)


And now, we need to configure Docker. First, let's create a self-hosted Docker registry if you don't have one yet.

````bash
docker run -d -p 5001:5000 --restart=always --name registry registry:2
````
You must log in to Docker. Without it, you can't continue.

````bash
docker login
````

If everything is good, paste this command it needs for setting the tag:

````bash
docker tag cronsample localhost:5001/cronsample 
````
Next, we need to push and pull:

````bash
docker push localhost:5001/cronsample 
docker pull localhost:5001/cronsample 
````
In Docker, you'll see the new image we will use.


![registry](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8ke3gefev8n7iogokmrz.png)

##K8S and Helm
We reached the final part of this article. We'll add Helm chart and test this out. Let's create a helm folder. It's optional. And add a new Chart. If you use Rider, you can simply add it to the project.


![rider](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h6zhdlfcb91hb579e02x.png)

Please name the chart `cronjob`. The naming is crucial in Helm. If you add a chart from Rider, you'll see many different files. You don't need all the files. Leave only this:


![helm](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nvfwbl1hh55r07522bba.png)

Let's explain what these files are.  The `values.yaml` file sets variables.

````yaml
imagePullSecrets: []
nameOverride: "scheduled-job-test"
fullnameOverride: ""
schedule: "*/1 * * * *"
concurrencyPolicy: "Forbid"
failedJobsHistoryLimit: 1
successfulJobsHistoryLimit: 1
backoffLimit: 0
restartPolicy: "Never"

image:
  repository: "localhost:5001/cronsample"
  pullPolicy: IfNotPresent
  tag: "latest"

environment:
  JOB : "HealthJob"
  TEST_ENDPOINT_BASE_URL : "https://dev.to"

service:
  port: 8080

serviceAccount:
  create: true
  annotations: {}
  name: ""

podAnnotations: {}

podSecurityContext: {}

securityContext: {}

resources:
  limits:
    cpu: 200m
    memory: 0.3Gi
  requests:
    cpu: 100m
    memory: 0.15Gi

nodeSelector:
  ms: all

tolerations: []

affinity: {}
````

The `Chart.yaml` keeps the name and version.

````yaml
apiVersion: v2
name: cronjob
description: A Helm chart for Kubernetes

version: 0.1.0

appVersion: "1.16.0"
````

The `serviceaccount.yaml` is needed for creating a service in k8s.

````yaml
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ include "cronjob.serviceAccountName" . }}
  labels:
    {{- include "cronjob.labels" . | nindent 4 }}
  {{- with .Values.serviceAccount.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
{{- end }}
````

The NOTES.txt is optional. It shows information to the console.

````
{{ .Values.environment.JOB }} CRON JOB INSTALLED !
  
A CronJob will run with schedule {{ .Values.schedule }}, denoted in UTC

It will keep {{ .Values.failedJobsHistoryLimit }} failed Job(s) and {{ .Values.successfulJobsHistoryLimit }} successful Job(s).
See the logs of the Pod associated with each Job to see the result.
````

The `cronjob.yaml` is responsible for starting jobs separated by pods.

````yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: {{ .Values.nameOverride }}
  labels:
    {{- include "cronjob.labels" . | nindent 4 }}
spec:
  schedule: "{{ .Values.schedule }}"
  concurrencyPolicy: {{ .Values.concurrencyPolicy }}
  failedJobsHistoryLimit: {{ .Values.failedJobsHistoryLimit }}
  successfulJobsHistoryLimit: {{ .Values.successfulJobsHistoryLimit }}
  jobTemplate:
    metadata:
      {{- with .Values.podAnnotations }}
      annotations:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      labels:
        {{- include "cronjob.selectorLabels" . | nindent 8 }}
    spec:
      template:
        spec:
          {{- with .Values.imagePullSecrets }}
          imagePullSecrets:
            {{- toYaml . | nindent 8 }}
          {{- end }}
          serviceAccountName: {{ include "cronjob.serviceAccountName" . }}
          containers:
            - name: {{ .Chart.Name }}
              image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
              imagePullPolicy: {{ .Values.image.pullPolicy }}
              env:
                {{- range $key, $value := .Values.environment }}
                - name: {{ $key }}
                  value: {{ $value | quote }}
                {{- end }}
              resources:
                limits: 
                  memory: {{ .Values.resources.limits.memory }}
                  cpu: {{ .Values.resources.limits.cpu }}
                requests:
                  memory: {{ .Values.resources.requests.memory }}
                  cpu: {{ .Values.resources.requests.cpu }}
          restartPolicy: {{ .Values.restartPolicy }}
          {{- with .Values.affinity }}
          affinity:
            {{- toYaml . | nindent 8 }}
          {{- end }}
          {{- with .Values.tolerations }}
          tolerations:
            {{- toYaml . | nindent 8 }}
          {{- end }}      
      backoffLimit: {{ .Values.backoffLimit }}
````

The `_helper.tpl` is a template.

````
{{/*
Expand the name of the chart.
*/}}
{{- define "cronjob.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "cronjob.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "cronjob.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Common labels
*/}}
{{- define "cronjob.labels" -}}
helm.sh/chart: {{ include "cronjob.chart" . }}
{{ include "cronjob.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels
*/}}
{{- define "cronjob.selectorLabels" -}}
app.kubernetes.io/name: {{ include "cronjob.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/*
Create the name of the service account to use
*/}}
{{- define "cronjob.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "cronjob.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
````

The `test-connection.yaml` is optional.

````yaml
apiVersion: v1
kind: Pod
metadata:
  name: "{{ include "cronjob.fullname" . }}-test-connection"
  labels:
    {{- include "cronjob.labels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": test
spec:
  containers:
    - name: wget
      image: busybox
      command: ['wget']
      args: ['{{ include "cronjob.fullname" . }}:{{ .Values.service.port }}']
  restartPolicy: Never
````

After we added the chart, you need to install it. Go to the folder with charts and run this command:

````bash
helm install sample --name-template sample-service
````

If you made everything right in Docker, you'll see something like that.


![pods](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m262izv6e2u7p40xae89.png)


If you come into the second image, you should see this result:


![result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ow122w075n87y2t5g2a6.png)

The cron job every minute runs the application that checks the health of the site.

Thanks for reading, see you in the next article. Happy coding.

The source code is [HERE](https://github.com/SergKorol/CronSample).

<a href="https://www.buymeacoffee.com/b9z99kntvtQ" target="_blank"><img src="http://dartfordwaffler.co.uk/wp-content/uploads/2021/03/buy-me-a-beer.jpg" alt="Buy Me A Beer" style="height: 60px !important;width: 317px !important;" ></a>