How to create cron jobs in K8S.

Serhii Korol - Oct 29 '23 - - Dev Community

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:

dotnet new console -n CronSample
Enter fullscreen mode Exit fullscreen mode

Next step, add the required NuGet packages:

<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>
Enter fullscreen mode Exit fullscreen mode

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.

public enum Job
{
    HealthJob
}
Enter fullscreen mode Exit fullscreen mode

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

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);
    }
Enter fullscreen mode Exit fullscreen mode

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.

`

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);
    }
}
Enter fullscreen mode Exit fullscreen mode


`

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.

`

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";
    }
}
Enter fullscreen mode Exit fullscreen mode


`
This service executes the request to the external URL. And let's add the base class where implementing the RestSharp client.

`

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode


`

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

`

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; }
}
Enter fullscreen mode Exit fullscreen mode


`

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

`

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

        return services;
    }
}
Enter fullscreen mode Exit fullscreen mode


`

`

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

        return services;
    }
}
Enter fullscreen mode Exit fullscreen mode


`

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

`

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);
    }
}
Enter fullscreen mode Exit fullscreen mode


`

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"]
Enter fullscreen mode Exit fullscreen mode


`

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

vars

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

result

##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.

`

version: '3.7'

services:
  runner:
    image: cronsample:latest
    environment:
      - JOB=HealthJob
      - TEST_ENDPOINT_BASE_URL=https://dev.to
    build:
      context: .
      dockerfile: CronSample/Dockerfile
Enter fullscreen mode Exit fullscreen mode


`
If you run the image, you should see the same result as was earlier.

docker

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

`

docker run -d -p 5001:5000 --restart=always --name registry registry:2
Enter fullscreen mode Exit fullscreen mode


`
You must log in to Docker. Without it, you can't continue.

`

docker login
Enter fullscreen mode Exit fullscreen mode


`

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

`

docker tag cronsample localhost:5001/cronsample 
Enter fullscreen mode Exit fullscreen mode


`
Next, we need to push and pull:

`

docker push localhost:5001/cronsample 
docker pull localhost:5001/cronsample 
Enter fullscreen mode Exit fullscreen mode


`
In Docker, you'll see the new image we will use.

registry

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

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

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

`

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: {}
Enter fullscreen mode Exit fullscreen mode


`

The Chart.yaml keeps the name and version.

`

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

version: 0.1.0

appVersion: "1.16.0"
Enter fullscreen mode Exit fullscreen mode


`

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

`

{{- 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 }}
Enter fullscreen mode Exit fullscreen mode


`

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.
Enter fullscreen mode Exit fullscreen mode


`

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

`

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 }}
Enter fullscreen mode Exit fullscreen mode


`

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 }}
Enter fullscreen mode Exit fullscreen mode


`

The test-connection.yaml is optional.

`

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
Enter fullscreen mode Exit fullscreen mode


`

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

`

helm install sample --name-template sample-service
Enter fullscreen mode Exit fullscreen mode


`

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

pods

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

result

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.

Buy Me A Beer

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .