How to create cron jobs in K8S.
Serhii Korol
Posted on October 29, 2023
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
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>
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
}
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);
}
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);
}
}
`
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";
}
}
`
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();
}
}
}
`
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; }
}
`
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;
}
}
`
`
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.
`
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.
When everything is ready, we can check this out. You'll see something like that.
##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
`
If you run the image, you should see the same result as was earlier.
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
`
You must log in to Docker. Without it, you can't continue.
`
docker login
`
If everything is good, paste this command it needs for setting the tag:
`
docker tag cronsample localhost:5001/cronsample
`
Next, we need to push and pull:
`
docker push localhost:5001/cronsample
docker pull localhost:5001/cronsample
`
In Docker, you'll see the new image we will use.
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.
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:
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: {}
`
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"
`
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 }}
`
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.
`
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.
`
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:
`
helm install sample --name-template sample-service
`
If you made everything right in Docker, you'll see something like that.
If you come into the second image, you should see this 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.
Posted on October 29, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.