Graceful Termination in K8S
In this article, we will cover UNIX
Process signals and in particular the `SIGTERM` signal. We’ll cover how to handle them with practical examples using Node. typescript, postal workerAnd kind local cluster.
UNIX Signals and SIGTERM
A Unix-based operating system (OS) consists of multiple processes. uses os software comes in between (aka signals) As a way of communicating with running processes, these signals are signals indicating that some sort of event has occurred and they can vary in their intent and purpose.
Here is the list of available signals on my machine:
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL
5) SIGTRAP 6) SIGABRT 7) SIGEMT 8) SIGFPE
9) SIGKILL 10) SIGBUS 11) SIGSEGV 12) SIGSYS
13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGURG
17) SIGSTOP 18) SIGTSTP 19) SIGCONT 20) SIGCHLD
21) SIGTTIN 22) SIGTTOU 23) SIGIO 24) SIGXCPU
25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH
29) SIGINFO 30) SIGUSR1 31) SIGUSR2
there are some, but we’re going to focus on those SIGTERM
,
SIGTERM
Signals are a way for the operating system to gracefully terminate a program. By graceful, we mean that the program is given time to perform a final cleanup before shutdown. Depending on the application, cleaning tasks may vary. Interestingly enough, Unix processes can block and ignore SIGTERM
, But if we want quality process/service then we have to handle these signals with respect, otherwise our process will be force-closed.
Unix process as HTTP server
For demonstration, we are going to create a sample HTTP server using typescriptAnd Hapi,
let’s make npm Project and follow the hint:
$ npm init
…
Install dependencies:
$ npm install @hapi/hapi typesript @types/node @types/node @types/hapi__hapi
We’re not going to bother with the separation of development and production dependencies here.
create a local file and call it index.ts
,
import Server from "@hapi/hapi";
function sleep(ms: number, value: string)
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
export async function startServer(host: string, port: number): Promise
const server = new Server( port, host );
server.route(
method: "GET",
path: "/work",
handler: async () => sleep(10 * 1000, `done something for 10 seconds\n`),
);
await server.start();
console.log(`Server running at: $server.info.uri, PID: $process.pid`);
return server;
startServer("0.0.0.0", 3000);
Here, we start the local server on host 0.0.0.0 and port 3000. We have also configured a single endpoint, GET /work
This simulates something that takes time to calculate – 10 seconds in our case. In real situation there may be time required to perform any kind of database query.
I love node, it’s so cool that in 27 lines of code you can define a server, you actually need less, because I added the sleep function etc., but yeah, it’s great.
Let’s run our server:
$ ./node_modules/.bin/ts-node ./index.ts
Server running at: http://0.0.0.0:3000, PID: 16544
All good We have a server running!
send now GET /work
HTTP request to our endpoint. Pick any networking tool you like, I’m going to use curl,
$ curl http://0.0.0.0:3000/work
done something for 10 seconds
So far, so good. But what happens to that HTTP request if the server has suddenly timed out, expired, or in other words, isn’t there anymore? What feedback will we get from the client? Will we get anything? What will happen if the request is served after the server is dead? Many questions! Let’s try it:
Send another request to the server:
$ curl http://0.0.0.0:3000/work
done something for 10 seconds
But this time, while the server is doing simulated work for 10 seconds — let’s KILL
This
$ kill -15 19346
Note: I am using Process ID (PID) which I got from index.ts output! Your local PID we’ll do something else for sure! If it’s not, it’s destiny and of course send me an email.
One may wonder, why not simply terminate the server’s shell process by pressing CTRL+C
keys? ok he will send one SIGINT
hint, but we want SIGTERM
,
OK, so what happened to our client connections when the server was down? This is what happens:
$ curl http://0.0.0.0:3000/work
curl: (52) Empty reply from server
It got an empty reply, meaning it didn’t get any information, nada. let’s add -V (stands for verbose) flag our CURL command to see more information.
$ curl -v http://0.0.0.0:3000/work
* Trying 0.0.0.0:3000…
* Connected to 0.0.0.0 (127.0.0.1) port 3000 (#0)
> GET /work HTTP/1.1
> Host: 0.0.0.0:3000
> User-Agent: curl/7.84.0
> Accept: */*
>
* Empty reply from server
* Closing connection 0
curl: (52) Empty reply from server
It looks like the server closed the connection abruptly 👀. This is not good. What if this happens to the server client in production? If you’re running your application in one of the cloud orchestration tools, then shutting down containers might not be too out of the ordinary.
i am using Kubernetes (K8S), so I’m going to talk about that. K8S is like an OS for the cloud. K8S can end running Pod (aka containers) Voluntarily, after all, the whole purpose of K8S is to orchestrate distributed systems. If one of the pods is requesting too many resources or if the application is being minimized, the container may receive a signal from the K8S that it is time to go.
So we have the notion of pointers to gracefully terminate our containers.
to beautiful ending
In the previous section, we talked about how our processes or containers can receive different signals.
When K8S needs to terminate a pod, it will send SIGTERM
, This way our service is not only getting cut off from resources, but it will have some time to do finalization tasks.
Let’s implement this logic in our node server:
import Server from "@hapi/hapi";
function sleep(ms: number, value: string)
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
export async function startServer(host: string, port: number): Promise
const server = new Server( port, host );
server.route(
method: "GET",
path: "/work",
handler: async () => sleep(10 * 1000, `done something for 10 seconds\n`),
);
process.on("SIGTERM", async function ()
console.log(`Received SIGTERM`);
await server.stop( timeout: 10 * 1000 );
console.log(`Server stopped.`);
process.exit(0);
);
await server.start();
console.log(`Server running at: $server.info.uri, PID: $process.pid`);
return server;
startServer("0.0.0.0", 3000);
we’ve added a listener to SIGTERM
Competition. When these events happen, we are stopping the server, but we are not just killing it. between that time SIGTERM
arrives and the specified `timeout` parameter, our server will refuse to accept any new requests and will finalize ongoing requests.
We can test that statement! Restart server:
$ ts-node ./index.ts
Server running at: http://0.0.0.0:3000, PID: 67116
run CURL
Make request and terminate server:
$ curl -v http://0.0.0.0:3000/work
We should see that the request is finished and a response is sent back to the client – no more “(52) Empty reply from server” errors.
If we try to access the server between the time limits SIGTERM
And the actual shutdown we’ll get:
$ curl -v http://0.0.0.0:3000/work
* Trying 0.0.0.0:3000…
* connect to 0.0.0.0 port 3000 failed: Connection refused
* Failed to connect to 0.0.0.0 port 3000 after 3 ms: Connection refused
* Closing connection 0
curl: (7) Failed to connect to 0.0.0.0 port 3000 after 3 ms: Connection refused
If there is no server we will get the same error!
But that’s all we need to do for graceful termination of node processes!
Sending SIGTERM to K8S pods
As we mentioned, whenever a K8S pod is terminated, it is sent SIGTERM
Signal.
the way we used to kill
command for local processes, we can end up using pod delete
command:
$ kubectl delete pod my-pod-qgldf
When K8S decides to terminate the pod for any reason, SIGTERM
The signal will be sent to it, then to the Docker container, and finally to the running process.
You don’t have to believe me, just give it a go.
K8S Sample Application
We’re going to reuse the server code, but containerize it and deploy it to a local K8S cluster.
Our Dockerfile:
FROM node:19-bullseye
WORKDIR /app
COPY index.ts package.json package-lock.json /app/
RUN npm install
EXPOSE 3000
ENTRYPOINT [“/app/node_modules/.bin/ts-node”, “index.ts”]
Create Docker container:
$ docker build -t poc -f ./Dockerfile .
First, let’s run it and see if we can get SIGTERM
Related log:
$ docker run -t poc:latest
Get Docker ID:
$ docker ps
CONTAINER ID IMAGE
86b0a46730ba poc:latest
and stop it:
$ docker stop 86b0a46730ba
We should see the docker run command ending as well as our docker container with the same output as we had with the process.
$ docker run -t poc:latest
Server running at: http://0.0.0.0:3000, PID: 1
Received SIGTERM
Server stopped.
Now let’s do some K8S!
First, we need to load our container image into the cluster:
$ kind load docker-image poc:latest — name my-cluster
K8S deployment manifest:
apiVersion: v1
kind: Namespace
metadata:
name: poc-namespace
— -
apiVersion: apps/v1
kind: Deployment
metadata:
name: poc-deployment
namespace: poc-namespace
spec:
selector:
matchLabels:
app: poc-app
template:
metadata:
labels:
app: poc-app
spec:
containers:
— name: poc
image: poc:latest
ports:
— containerPort: 3000
Deploy to our cluster:
$ kubectl apply -f ./deployment.yaml
namespace/poc-namespace created
deployment.apps/poc-deployment created
If you stream pod logs:
$ k logs -f poc-deployment-bf749f576-wfmv9
and then delete pod:
$ kubectl delete pod poc-deployment-bf749f576-wfmv9
Should get the same result:
$ k logs -f poc-deployment-bf749f576-wfmv9
Server running at: http://0.0.0.0:3000, PID: 1
Received SIGTERM
Server stopped.
and that’s a wrap!
In this article, we covered SIGTERM
Shows the practical applications of signals, how they are used in Unix-based and cloud-based systems as well as handling these signals and their potential impact.
SIGTERM
need to handle horizontally scalable cloud application. This allows the application to scale up and down on demand without affecting the stability of the client.