K3 begins with this module. In K2 you built the Kladde, packed it into an image and delivered it to Azure once. Before K3 turns to subscriptions, Bicep and pipelines, you look one level deeper, at what every cloud application stands on. A container is a Linux process. A request is a TCP connection to a port. An address is a DNS name. What lies between the browser and the Kladde is TLS, proxies and firewalls. If you understand that, you find errors in minutes instead of days.

Lesson What you get
3.0.1 Linux and the shell in a container the Kladde provably shuts down cleanly on SIGTERM
3.0.2 TCP/IP, ports and what a connection is an address plan for a later network of the Kladde in docs/network.md
3.0.3 DNS name resolution step by step, services by name instead of by address
3.0.4 TLS and the chain of trust a practice endpoint of your own that a client trusts only when you say so
3.0.5 Proxies and load balancers two instances behind a reverse proxy with a health check
3.0.6 Firewalls, NSGs and SSH the rules of a network security group as a table in docs/network.md
3.0.7 Mail: SMTP, SPF, DKIM and DMARC headers and records of a real domain, read

For this module you don’t need an Azure subscription yet. Everything runs in the Codespace, in Docker Desktop (or Podman) and against public addresses. The counterparts in Azure are named along the way and built from module 3.1 on. That leaves the rule “budget first, then the first resource” from 3.1.2 untouched.

Where the Kladde stands

At the end of K2, the Kladde is a web application with contacts, public appointment booking, tenders from the TED interface, sign-in with Entra ID and two languages. src/Kladde.Web has a multi-stage Dockerfile of its own (K2 2.9.7): it is built in the SDK image and run in the chiseled image aspnet:10.0-noble-chiseled-extra, as user app on port 8080. With the compose.yaml from K2 2.9.9 you start the Kladde along with SQL Server, without Aspire. A background service imports the tenders every night (K2 2.8.2).

The house rules from K1 and K2 still apply: names in code are English, texts for the users are English too, warnings are errors, the exit code counts, secrets never belong in code, logs or chats. This module adds one clarification: We write our scripts in PowerShell. You use bash where there is nothing else, namely inside a Linux container. PowerShell runs on Linux too (pwsh); it is already installed in the .NET SDK image.

Why Linux

Almost everything that surrounds your application in the cloud runs on Linux:

  • Containers: A Linux container is an ordinary process on a Linux kernel, just shielded. It sees a file system of its own, process numbers of its own and a network of its own. On Windows and macOS, Docker Desktop starts a small Linux machine in the background for this.
  • Azure Container Apps: Your Kladde runs there in a Linux container. The service ends it with a Linux signal (more on that in a moment).
  • Build agents: The hosted runners of GitHub Actions and Azure Pipelines mostly run Ubuntu. What goes wrong in the build, you see in a Linux log.
  • Codespaces: Your Codespace is a Linux container with a bash in the terminal.

You don’t have to become a Linux administrator. But you do have to find your way around a Linux shell, read permissions, find a process and search a log. That is what this lesson is about.

A shell in a container

Start an Ubuntu container with a bash:

Bash
docker run --rm -it --name exercise ubuntu:24.04 bash

The parts of the command:

  • --rm deletes the container as soon as you leave it. Practice containers clean up after themselves this way.
  • -it connects your terminal to the container (-i for input, -t for a terminal).
  • --name exercise gives it a name you can use to reach it from a second terminal.
  • ubuntu:24.04 is the image, bash the program that starts in it.

You see a prompt like root@3f2a9c1d7b6e:/#. That means: you are the user root, the machine is named after the ID of the container, and you are standing in the folder /. The # at the end indicates root; a normal user sees $. With exit or Ctrl+D you leave the shell, and the container is gone.

In the Codespace you don’t need Docker for this: the terminal there is already a bash in a Linux container. There you are not root, though, but a normal user (usually codespace).

The file system

Linux has no drive letters. Everything hangs under one root /. The folders you need most often:

Folder Contents
/etc configuration of the system: users (/etc/passwd), name resolution (/etc/hosts, /etc/resolv.conf)
/home the users’ folders, such as /home/codespace
/root the folder of the user root
/tmp temporary things, emptied on restart
/var/log log files of services (containers write their logs to the console instead)
/proc not a real folder: here the kernel shows every running process as a subfolder with its number
/app in the .NET images, the folder of your application

You know the commands for moving around in a similar form from PowerShell, which even has aliases with the same names:

Bash
pwd                      # where am I?
cd /etc                  # change folder
ls -la                   # show everything, hidden files too (name starts with .)
cat /etc/os-release      # print a file
mkdir -p /tmp/a/b        # create a folder, intermediate folders too
cp /etc/hosts /tmp/a/    # copy
mv /tmp/a/hosts /tmp/a/b/hosts-copy
rm -r /tmp/a             # delete, with -r including contents; there is no recycle bin

cat /etc/os-release shows, among other things, in the container:

PRETTY_NAME="Ubuntu 24.04.3 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"

The last digit of the version may be higher on your machine. Case matters: /etc/Hosts does not exist.

Users and permissions

Every process runs as a user, and every file belongs to a user and a group. id shows who you are:

Bash
id
uid=0(root) gid=0(root) groups=0(root)

root has the number 0 and may do anything. That is exactly why your application should not run as root: whoever finds a hole in the Kladde would otherwise have all rights in the container. Since .NET 8, the .NET images therefore ship with a user app with the number 1654, and the chiseled images are already set to it. Because ports below 1024 need special rights on Linux, ASP.NET Core in these images listens on port 8080 instead of 80. That is the reason for the 8080 you have met everywhere since K2.

What is allowed on a file is shown by ls -l:

drwxr-x--- 2 app  app  4096 Nov  9 10:12 data
-rw-r--r-- 1 root root  221 Nov  9 10:12 hosts

The first character says what it is: d a folder, - a file. Then come three groups of three characters each:

Position applies to Example rwxr-x---
2–4 the owner (app) rwx: read, write, execute
5–7 the group (app) r-x: read and execute, not write
8–10 everyone else ---: nothing

For a folder, x means you may change into it, and w means you may create and delete files in it. After that come the number of links, owner, group, size, modification time and name.

You change permissions with chmod and the owner with chown. chmod has two notations. The numbers are shorter: reading counts 4, writing 2, executing 1, and you add them up per group.

Number Characters means
7 rwx everything
5 r-x read and execute
4 r-- read only
0 --- nothing
Bash
chmod 750 /data            # owner everything, group read and enter, others nothing
chmod u=rwx,g=rx,o= /data  # the same in letters: u owner, g group, o others
chown app:app /data        # owner app, group app (only root may do this)
stat -c '%A %a %U:%G %n' /data
drwxr-x--- 750 app:app /data

stat with this format is nicer for checking than ls -l, because it shows exactly the fields you want to know, without a date.

Exercise 1: A folder only app writes to

Work in the container ubuntu:24.04 as root.

  1. Create a group and a user app with the number 1654, as in the .NET images, and a second user guest. The three commands for that are below this list.
  2. Create the folder /data. It should belong to app. app may read and write in it, the group app may only read, everyone else may do nothing.
  3. Check it with runuser, which runs a command as another user: app creates /data/a.txt, guest tries to create /data/b.txt and to list the folder.
Bash
groupadd --gid 1654 app
useradd --uid 1654 --gid app --no-create-home app
useradd --uid 2000 --no-create-home guest

How to check it: stat -c '%A %U:%G' /data shows drwxr-x--- app:app, app can write, and both attempts by guest end with Permission denied and an exit code other than 0 (echo $? right after).

Solution 1

Bash
groupadd --gid 1654 app
useradd --uid 1654 --gid app --no-create-home app
useradd --uid 2000 --no-create-home guest
mkdir /data
chown app:app /data
chmod 750 /data
stat -c '%A %U:%G' /data
runuser -u app -- touch /data/a.txt; echo "app: $?"
runuser -u guest -- touch /data/b.txt; echo "guest writes: $?"
runuser -u guest -- ls /data; echo "guest reads: $?"
drwxr-x--- app:app
app: 0
touch: cannot touch '/data/b.txt': Permission denied
guest writes: 1
ls: cannot open directory '/data': Permission denied
guest reads: 2

id app shows uid=1654(app) gid=1654(app) groups=1654(app). Two things are typical stumbling blocks:

  • chmod 750 without chown: the folder then belongs to root, and app may do nothing at all as one of the “others”. Permissions and owner belong together.
  • chmod 777, “so that it finally works”: then every process in the container may write there, even one that has no business there. When something doesn’t work, find out who needs it and give exactly that one the right.

In a Dockerfile you write the same as RUN mkdir /data && chown app:app /data && chmod 750 /data in the runtime stage, before USER app. A chiseled image has no shell for this; there COPY --chown=app:app does the job, or the folder is created in the build stage.

Packages in the image

The image ubuntu:24.04 is deliberately small. A lot of what you’re used to is missing:

Bash
ps
bash: ps: command not found

You install packages with apt-get. First update fetches the list of available packages, then install installs the ones you want:

Bash
apt-get update
apt-get install -y --no-install-recommends procps curl less ca-certificates

-y answers the confirmation prompt with yes, --no-install-recommends leaves out recommended extra packages. curl needs ca-certificates to be able to verify HTTPS peers (more on that in lesson 3.0.4). In a Dockerfile these steps go in one instruction, so that the package list does not stay behind as a layer of its own in the image:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*

In the Kladde’s runtime image there is neither apt-get nor a shell. That is deliberate: a chiseled image contains only what .NET needs to run. What isn’t there can’t have a security hole, and an attacker who finds a hole in the Kladde has no shell there to continue with. How you can look inside anyway comes further down.

Processes and signals

Every process has a number, the PID. In a container, the first process that starts is PID 1. In your practice container that is the bash, in the Kladde’s container it is dotnet. In the practice container, start a process in the background (the & at the end) and look at the processes:

Bash
sleep 600 &
ps -o pid,user,args
    PID USER     COMMAND
      1 root     bash
     23 root     sleep 600
     24 root     ps -o pid,user,args

Your numbers may be different. You end a process by sending it a signal. The most important ones:

Signal Number means can the process react to it?
SIGTERM 15 “Please end yourself.” yes: clean up, finish open work, then go
SIGINT 2 the same, triggered by Ctrl+C in the terminal yes
SIGKILL 9 immediate end by the kernel no, the process doesn’t notice anything

Without an argument, kill sends SIGTERM. After a process has ended, wait returns its exit code. If it was ended by a signal, that is 128 plus the number of the signal:

Bash
sleep 600 &
kill -TERM $!   # $! is the PID of the process last started in the background
wait $!; echo "Exit code: $?"
sleep 600 &
kill -KILL $!
wait $!; echo "Exit code: $?"
Exit code: 143
Exit code: 137

In between, the shell itself reports lines such as [1]+ Terminated sleep 600 or Killed; they are not part of the program’s output. These two numbers are worth remembering. 137 in a container log almost always means: the process was ended hard, either because it didn’t react to SIGTERM in time or because the container wanted more memory than it was allowed.

This is how container platforms end a container:

  1. They send PID 1 a SIGTERM.
  2. They wait out a grace period: docker stop 10 seconds, Azure Container Apps 30 seconds.
  3. If the process is still alive then, SIGKILL comes.

For the SIGTERM to reach your application, it has to be PID 1 itself. That is why the Kladde’s Dockerfile has the exec form ENTRYPOINT ["dotnet", "Kladde.Web.dll"] with square brackets. The shell form ENTRYPOINT dotnet Kladde.Web.dll would put a shell in front as PID 1, and it doesn’t pass the signal on; in the chiseled image that shell wouldn’t even exist.

What .NET does with SIGTERM

ASP.NET Core and every other .NET host understand SIGTERM on their own. The host stops accepting new requests, gives running requests time, tells all background services through their CancellationToken that it’s over, and waits for that for HostOptions.ShutdownTimeout at most (default: 30 seconds). This small program shows it.

You create this module’s practice programs in a folder of their own outside the Kladde repository, for example with mkdir ~/k3-exercises && cd ~/k3-exercises. That way the Kladde’s build rules don’t apply there, and the exercises don’t end up in a pull request by accident. In the Codespace, create a file shutdown.cs there:

C#
// shutdown.cs
#:sdk Microsoft.NET.Sdk.Web

var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
builder.Services.AddHostedService<NightlyImport>();
builder.Services.Configure<HostOptions>(options => options.ShutdownTimeout = TimeSpan.FromSeconds(25));

var app = builder.Build();
app.Lifetime.ApplicationStopping.Register(() => app.Logger.LogInformation("Stopping: no new requests, open ones may finish."));
app.Lifetime.ApplicationStopped.Register(() => app.Logger.LogInformation("Stopped cleanly."));
app.MapGet("/", () => $"Process {Environment.ProcessId} is running.");
app.Run();

sealed class NightlyImport(ILogger<NightlyImport> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        logger.LogInformation("Import is waiting for its next run.");
        try
        {
            await Task.Delay(Timeout.Infinite, stoppingToken);
        }
        catch (OperationCanceledException)
        {
            logger.LogInformation("Import stopped before the process ends.");
        }
    }
}

The #:sdk line turns the single file into a web application. The grace period here is deliberately 25 seconds, below the 30 seconds of Azure Container Apps: the host should be done before the platform reaches for SIGKILL. Start the program in the first terminal:

Bash
dotnet run shutdown.cs -- --urls http://localhost:5080

In the second terminal, ask the application for its PID and send it a SIGTERM. Don’t ask ps about dotnet: dotnet run starts your program as a child process of its own, and kill should hit exactly that one.

Bash
curl http://localhost:5080/
kill -TERM 4711   # use the number from your answer instead of 4711
Process 4711 is running.

In the first terminal you then see:

info: NightlyImport[0]
      Import is waiting for its next run.
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5080
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /home/codespace/k3-exercises
info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...
info: shutdown[0]
      Stopping: no new requests, open ones may finish.
info: NightlyImport[0]
      Import stopped before the process ends.
info: shutdown[0]
      Stopped cleanly.

PID and path will be different on your machine. The last four messages are the clean farewell: the host understood the signal, the background service saw its token and left before the process did. The exit code is 0.

With kill -KILL instead of kill -TERM, these four lines are missing entirely, and the exit code is 137. Whatever the import was just writing stays half written. That is why idempotency and the outbox from K2 2.8 matter so much: a process can end hard at any time, and the next instance still has to carry on correctly.

Exercise 2: Finding and ending processes

  1. In the practice container: install procps, start two sleep 600 in the background, find both with ps and end the first with SIGTERM and the second with SIGKILL. Note both exit codes.
  2. In the Codespace: start shutdown.cs as above, end it once with kill -TERM and once with kill -KILL. Compare the last lines of the log.
  3. Change NightlyImport so that it does not honor the token: await Task.Delay(Timeout.Infinite); without stoppingToken, and set ShutdownTimeout to 5 seconds. What happens now on SIGTERM?

How to check it: You have seen 143 and 137 as exit codes, the log after SIGTERM shows the four lines of the farewell, after SIGKILL none of them, and for step 3 you can say in one sentence what the host does after five seconds.

Solution 2

Step 1:

Bash
apt-get update && apt-get install -y --no-install-recommends procps
sleep 600 &
sleep 600 &
ps -o pid,args
kill -TERM %1; wait %1; echo "first: $?"
kill -KILL %2; wait %2; echo "second: $?"
    PID COMMAND
      1 bash
    310 sleep 600
    311 sleep 600
    312 ps -o pid,args
first: 143
second: 137

%1 and %2 are the shell’s background jobs in the order in which you started them; jobs lists them. A PID from the list of ps works just as well: kill -TERM 310.

Step 2 shows the four lines from “Application is shutting down...” on after SIGTERM; after SIGKILL the log ends with “Content root path”.

Step 3: The host reports “Application is shutting down...” and asks the import to stop. The import doesn’t listen. After five seconds the host stops waiting and ends anyway, and the import’s work breaks off in the middle. Look at the log: even now it ends with “Stopped cleanly.”, and the exit code is 0. Only the line “Import stopped before the process ends.” is missing. That is why the script in exercise 4 checks exactly this line and not just the exit code. In a container with docker stop, with a 30-second limit in the program, the SIGKILL would already have come after ten seconds. A background service that doesn’t pass on its token thus makes every grace period worthless. Restore the code afterwards.

Environment variables

Every process has environment variables, pairs of name and value that it inherits from the process that starts it. Containers are configured almost only through them. In the .NET images a few are already set, among them APP_UID=1654, ASPNETCORE_HTTP_PORTS=8080 and DOTNET_RUNNING_IN_CONTAINER=true.

Bash
printenv HOME                 # read a variable
echo "$HOME"                  # the same inside a text
export GREETING=hello         # set it and pass it on to child processes
bash -c 'echo "Child sees: $GREETING"'
unset GREETING
/root
/root
Child sees: hello

ASP.NET Core reads environment variables as configuration. A double underscore stands for the colon of the configuration keys: ConnectionStrings__Kladde becomes ConnectionStrings:Kladde. That is how the compose.yaml from K2 told the Kladde its database.

A rule that will come up often in K3: Never print a container’s whole environment, so no bare env, no printenv without a name and no docker inspect with all variables into a ticket, a chat or a log. Connection strings and keys are often in there. If you want to know which variables are set, have only the names shown:

Bash
env | cut -d= -f1 | sort

Pipes and redirections

Linux tools are small and each does one thing. They become powerful when you connect them:

Character means Example
\| the output of one is the input of the next cat app.log \| grep error
> output into a file, which is overwritten ls > list.txt
>> append output to a file date >> run.log
2> redirect the error output ls /does-not-exist 2> errors.txt
2>&1 error output to wherever the normal output goes dotnet test > test.log 2>&1
/dev/null the wastebasket that swallows everything curl -s -o /dev/null …

Every program has two outputs: the normal one (stdout) and the one for errors (stderr). If you write only >, you won’t find the error messages in the file, but still on the screen.

And the exit code? $? holds the one of the last command. For a pipe, that is the last command of the chain, not the first:

Bash
grep 'not-there' kladde.log | sort; echo "Exit code: $?"
set -o pipefail
grep 'not-there' kladde.log | sort; echo "Exit code: $?"
Exit code: 0
Exit code: 1

grep found nothing and ended with 1, sort sorted an empty input properly and ended with 0. Without pipefail, the whole chain looks successful. That is the same trap as in PowerShell when you pipe a command into Select-Object -First and then read $LASTEXITCODE: a script that decides on the exit code has to know whose exit code it is reading. bash scripts in a container therefore begin with set -euo pipefail.

grep, less and curl

For the examples in this section, create a file kladde.log with this content (in the Codespace: code kladde.log, paste the text, save). Each line is a request: time, method, path, status, duration.

08:14:02 GET /contacts 200 41ms
08:14:09 GET /appointments/book 200 63ms
08:14:11 POST /appointments/book 302 120ms
08:15:40 GET /tenders 200 1432ms
08:15:52 GET /contacts/17 404 12ms
08:16:03 GET /tenders 500 2051ms
08:16:30 GET /health 200 3ms
08:17:12 GET /tenders 500 2207ms
08:17:45 POST /appointments/book 500 88ms
08:18:01 GET /contacts 200 38ms
08:18:20 GET /health 200 2ms
08:19:05 GET /tenders 200 1187ms

grep finds lines that contain a pattern:

Bash
grep ' 500 ' kladde.log        # all lines with status 500
grep -c ' 500 ' kladde.log     # just count
grep -v ' 200 ' kladde.log     # all lines WITHOUT 200
grep -i 'POST' kladde.log      # ignoring case
08:16:03 GET /tenders 500 2051ms
08:17:12 GET /tenders 500 2207ms
08:17:45 POST /appointments/book 500 88ms
3
08:14:11 POST /appointments/book 302 120ms
08:15:52 GET /contacts/17 404 12ms
08:16:03 GET /tenders 500 2051ms
08:17:12 GET /tenders 500 2207ms
08:17:45 POST /appointments/book 500 88ms
08:14:11 POST /appointments/book 302 120ms
08:17:45 POST /appointments/book 500 88ms

The spaces in ' 500 ' matter: without them, grep would also find a duration of 1500 ms. With -E, grep understands extended regular expressions, such as ' 5[0-9]{2} ' for every status from 500 to 599.

With cut, sort and uniq this becomes a small analysis. cut -d' ' -f3 takes the third field, separated by spaces, the path. uniq -c counts identical lines that are directly one below the other; that is why you sort first:

Bash
cut -d' ' -f3 kladde.log | sort | uniq -c | sort -rn
      4 /tenders
      3 /appointments/book
      2 /health
      2 /contacts
      1 /contacts/17

less shows a long file page by page: less kladde.log. With the space bar you page, with /500 and Enter you search, n jumps to the next match, G to the end, q quits. tail -n 20 file shows the last 20 lines, tail -f file stays on it and shows new lines as soon as they come. For containers there is the same as docker logs --tail 20 -f kladde-web.

curl is the browser of the command line:

Bash
curl -i http://localhost:5080/                                   # answer with headers
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5080/  # just the status
curl -fsS http://localhost:5080/health                           # errors as exit code

-s turns off the progress display, -S shows errors anyway, -o /dev/null throws the content away, -w prints values of the response. Most important for scripts is -f: without -f, curl ends with exit code 0 even on a 500 response, because the connection did work, after all. With -f it ends with 22 on any status from 400 up. If curl gets no connection at all, the exit code is 7. In PowerShell the counterpart is Invoke-WebRequest, which throws an error on its own on a status from 400 up.

Exercise 3: Evaluating a log

Answer with grep, cut, sort, uniq, wc and pipes, without counting through the file by hand:

  1. How many requests ended with a status from 500 to 599?
  2. Which path has the most errors with status 500? One command, one line of output.
  3. Which requests took 1000 ms or longer?
  4. Build question 1 so that the exit code of the whole line is 1 if there is not a single error. Check it with a pattern that doesn’t occur.

How to check it: Your answers are 3, /tenders with 2 errors and four lines, all with /tenders.

Solution 3

Bash
grep -E ' 5[0-9]{2} ' kladde.log | wc -l
grep ' 500 ' kladde.log | cut -d' ' -f3 | sort | uniq -c | sort -rn | head -n 1
grep -E ' [0-9]{4,}ms$' kladde.log
3
      2 /tenders
08:15:40 GET /tenders 200 1432ms
08:16:03 GET /tenders 500 2051ms
08:17:12 GET /tenders 500 2207ms
08:19:05 GET /tenders 200 1187ms

On question 3: [0-9]{4,} means “at least four digits”, so 1000 or more, and ms$ requires the line to end after that. The finding fits together: the tenders are slow and fail when they get too slow. This is exactly how clearing up an incident begins, which you practice in module 3.4 with KQL instead of grep.

On question 4: grep -c counts by itself and ends with 1 when it finds nothing. That puts the exit code of grep at the end of the line:

Bash
grep -cE ' 5[0-9]{2} ' kladde.log; echo "Exit code: $?"
grep -cE ' 9[0-9]{2} ' kladde.log; echo "Exit code: $?"
3
Exit code: 0
0
Exit code: 1

If you want to stay with grep … | wc -l, you need set -o pipefail before it, otherwise wc always returns 0.

Looking into a chiseled image

When the Kladde runs in its container, you can’t simply attach to it:

Bash
docker exec -it kladde-web bash
OCI runtime exec failed: exec failed: unable to start container process: exec: "bash": executable file not found in $PATH: unknown

There is no bash, not even sh. You don’t need a shell in the image anyway. Start a debug container next to it instead, one that shares the processes and the network of the Kladde container:

Bash
docker run --rm -it --pid container:kladde-web --network container:kladde-web ubuntu:24.04 bash

In it you install what you need (procps, curl), and then with ps -o pid,user,args you see the Kladde’s dotnet process as PID 1 with the user 1654, and curl -i http://localhost:8080/health asks the Kladde from the inside, the way the platform does. The tools exist only in the debug container and disappear with it. The Kladde’s image stays small.

Azure Container Apps offers the same as a debug console: az containerapp debug opens a container of its own with tools such as ps, wget, openssl, dig and traceroute next to your app. az containerapp exec, on the other hand, requires a shell in the image and fails with the Kladde just like docker exec. You use both from module 3.2 on, when the Kladde runs in Azure again.

Exercise 4: The Kladde shuts down cleanly

Now you check the same thing on the real Kladde.

  1. Look at the Kladde’s background service that imports the tenders at night (K2 2.8.2): does it pass its stoppingToken on to every call that can wait (PeriodicTimer, HttpClient, EF Core)? Add it where it is missing. In the catch for OperationCanceledException, write a line Import stopped …, as in the example above.
  2. In Program.cs of Kladde.Web, set HostOptions.ShutdownTimeout to 25 seconds.
  3. Give the web application’s service in compose.yaml a grace period of 30 seconds (stop_grace_period: 30s), so that Docker doesn’t reach for SIGKILL after ten seconds.
  4. Put the following script into the repository as scripts/check-shutdown.ps1. It stops the container, reads the exit code and the log and checks three things.
  5. Start the Kladde with docker compose up -d, wait until it answers, and run the script. Use -Container to give the name of your container if it isn’t kladde-web (docker ps shows it).
  6. In docs/operations.md, in a section “Shutting down”, record which grace periods apply and how to check them.
PowerShell
# scripts/check-shutdown.ps1
# Stops the Kladde's container with SIGTERM and checks that it shut down cleanly: exit code 0 and the shutdown lines in its log.
[CmdletBinding(SupportsShouldProcess)]
param(
    [string] $Container = 'kladde-web',
    [int] $GraceSeconds = 30
)

if ($PSCmdlet.ShouldProcess($Container, "docker stop -t $GraceSeconds")) {
    docker stop -t $GraceSeconds $Container | Out-Null
    if ($LASTEXITCODE -ne 0) { throw "docker stop $Container failed." }
}

$exitCode = docker inspect $Container --format '{{.State.ExitCode}}'
if ($LASTEXITCODE -ne 0) { throw "docker inspect $Container failed. Does the container exist?" }
$log = (docker logs $Container 2>&1) -join "`n"

$checks = [ordered]@{
    'Exit code 0'                    = $exitCode -eq '0'
    'Host understood SIGTERM'        = $log.Contains('Application is shutting down...')
    'Import honored its token'       = $log.Contains('Import stopped')
}
foreach ($check in $checks.GetEnumerator()) {
    '{0,-32} {1}' -f $check.Key, ($check.Value ? 'ok' : 'MISSING')
}
if ($checks.Values -contains $false) { exit 1 }

How to check it: ./scripts/check-shutdown.ps1 prints ok three times, and $LASTEXITCODE is 0 afterwards. Do the counter-check: take the token out of the import, restart the Kladde and call the script with -GraceSeconds 2. Then the SIGKILL comes before the host is done: the script has to report MISSING for the exit code and for the import and end with 1. The grace period from compose.yaml applies to docker compose stop and docker compose down; docker stop -t in the script names its own. Afterwards, give the import its token back.

Solution 4

The decisive part in the background service looks roughly like this (your class and your import service may be named differently):

C#
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromHours(1), timeProvider);
    try
    {
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            if (IsImportHour(timeProvider.GetLocalNow()))
            {
                await using var scope = scopeFactory.CreateAsyncScope();
                var import = scope.ServiceProvider.GetRequiredService<TenderImport>();
                await import.RunAsync(stoppingToken);
            }
        }
    }
    catch (OperationCanceledException)
    {
        logger.LogInformation("Import stopped because the host is shutting down.");
    }
}

The token goes to the timer and to the import; the import passes it on to HttpClient and to SaveChangesAsync. In Program.cs:

C#
builder.Services.Configure<HostOptions>(options => options.ShutdownTimeout = TimeSpan.FromSeconds(25));

In compose.yaml, at the web application’s service:

YAML
services:
  web:
    stop_grace_period: 30s

A run of the script:

Exit code 0                      ok
Host understood SIGTERM          ok
Import honored its token         ok

And the section in docs/operations.md:

## Shutting down

- Platforms end the Kladde with SIGTERM, and after a grace period with SIGKILL:
  docker stop 10 s (compose: stop_grace_period 30s), Azure Container Apps 30 s.
- The Kladde waits 25 s at most (HostOptions.ShutdownTimeout), so below every grace period.
- The nightly import honors its CancellationToken and reports "Import stopped".
- To check: scripts/check-shutdown.ps1 (exit code 0, both log lines).
- Exit code 137 in the platform's log means: SIGKILL. Then check the import first.

Why two grace periods instead of one? The 30 seconds belong to the platform, the 25 seconds to the Kladde. If both were the same, the platform would win the race, and the farewell would fall into the last second.

The Kladde after this lesson

  • The nightly import passes its CancellationToken on everywhere and reports when it stops because of the shutdown.
  • Kladde.Web waits at most 25 seconds when shutting down, compose.yaml gives the container 30.
  • scripts/check-shutdown.ps1 checks the clean shutdown and lives in the repository.
  • docs/operations.md has a section “Shutting down”.

Commit that as a pull request, like every step of the Kladde.

What you can do now

  • You move around a Linux shell, read permissions and set them with chmod and chown as tightly as necessary.
  • You know why the .NET images run as user app with the number 1654 on port 8080.
  • You install packages in an image and know why the Kladde’s runtime image has no shell.
  • You find processes, end them with SIGTERM and tell from 143 and 137 how a process ended.
  • You know the grace periods of Docker, Azure Container Apps and the .NET host and tune them to each other.
  • You evaluate a log with grep, cut, sort and uniq and know whose exit code a pipe returns.
  • You look into a container without a shell using a debug container.

Further reading