NodeJS Graceful Shutdown: A Beginner's Guide

NodeJS Graceful Shutdown: A Beginner's Guide

Imagine this scenario: Your Node.js application is happily running, processing requests, interacting with databases, and then suddenly, it gets terminated. The system administrator decided it was time to scale down, or perhaps a critical error forced the application to exit. In any case, the application was in the middle of processing requests, writing data to a file, and now all of that is abruptly stopped. What happens to that data? What happens to your users' requests? The consequences of an abrupt shutdown can range from minor inconveniences to significant data loss, and degraded user experience. To avoid these situations, it is important to shut down your applications gracefully. In this article, we'll discuss why graceful shutdowns are important, how to handle them in Node.js applications, particularly in the context of Docker, and the potential issues that could arise if not handled correctly.

Prerequisites

Before proceeding, you should have:

  • Basic knowledge of JavaScript and Node.js

  • Understanding of Express.js framework

  • Familiarity with Docker and its basic commands

What is a Graceful Shutdown?

A graceful shutdown involves carefully handling the shutdown signal, completing the in-progress tasks, closing the active connections, and then finally allowing the application to terminate. This ensures that the system resources are properly freed and that the application does not exit while it's in the middle of important tasks.

Why is a Graceful Shutdown Important?

Handling shutdown signals in your applications allows you to manage resources properly, provide a better user experience, and help your system degrade more gracefully. Not handling these signals can lead to issues like data loss or corruption, incomplete transactions, resource leaks, and unexpected behavior.

Implementing Graceful Shutdown in Node.js

In this section, we'll walk through the code required to listen for shutdown signals in a Node.js application and how to perform cleanup tasks before allowing the application to exit.

Listening for Shutdown Signals

In Node.js, we can listen to process-level signals, such as SIGINT and SIGTERM. These signals are emitted when the process is requested to shut down, whether by manual user interruption (SIGINT from Ctrl+C) or system-level termination (SIGTERM from Docker or another process manager). To listen for these signals, we can use the process.on method and provide a callback function that will be executed when these signals are received.

process.on('SIGTERM', () => {
  console.log('SIGTERM signal received.');
});

process.on('SIGINT', () => {
  console.log('SIGINT signal received.');
});

Performing Cleanup

Once a shutdown signal is received, it's important to perform necessary cleanup tasks to close any open resources, finish transactions, and prepare the application for a graceful exit. This may involve closing database connections, completing any in-progress writes to file systems, or other application-specific cleanup. This cleanup code should be placed inside the callback function provided to process.on.

process.on('SIGTERM', () => {
  console.log('SIGTERM signal received.');
  // Perform cleanup tasks here
});

process.on('SIGINT', () => {
  console.log('SIGINT signal received.');
  // Perform cleanup tasks here
});

Remember to handle asynchronous cleanup tasks correctly. If a cleanup task is asynchronous (like closing a database connection), you'll need to handle it with async/await or Promises to ensure it completes before the process exits.

Exiting the Process

After performing the necessary cleanup tasks, we must manually terminate the Node.js process by calling process.exit(). This signals to the system (or Docker) that our application has finished shutting down. We can provide an exit code to this method; a code of 0 indicates a successful exit, while any other number indicates an error occurred. Typically, if we've handled everything correctly in our cleanup, we'll want to exit with a code of 0.

process.on('SIGTERM', () => {
  console.log('SIGTERM signal received.');
  // Perform cleanup tasks here

  process.exit(0);
});

process.on('SIGINT', () => {
  console.log('SIGINT signal received.');
  // Perform cleanup tasks here

  process.exit(0);
});

Remember, the goal of all this is to allow your application to exit gracefully when it receives a shutdown signal. This helps reduce the risk of data corruption, loss of data, and other issues associated with an abrupt termination.

Handling Shutdown in Express.js Application

Express.js applications, in particular, have some unique considerations when it comes to graceful shutdowns. This section discusses how to handle shutdown signals in an Express.js application, including how to stop the server from accepting new connections and how to ensure all existing connections are closed before shutdown.

Creating and Starting the Server

First, we need to create an Express.js application and start a server. Once the server is created, we can use it to close existing connections when we're ready to shut down the application.

import express from 'express';

const app = express();
const server = app.listen(3000, () => {
  console.log('Server listening on port 3000');
});

Listening for Shutdown Signals

Just like in a basic Node.js application, we need to listen for SIGINT and SIGTERM signals. We can use the process.on method to add listeners for these signals. Inside the callback function for each listener, we'll call server.close() to stop the server from accepting new connections and to begin the process of shutting down.

process.on('SIGTERM', () => {
  console.log('SIGTERM signal received.');
  server.close(() => {
    console.log('Closed out remaining connections');
    // Additional cleanup tasks go here
  });
});

process.on('SIGINT', () => {
  console.log('SIGINT signal received.');
  server.close(() => {
    console.log('Closed out remaining connections');
    // Additional cleanup tasks go here
  });
});

Closing Existing Connections

When server.close() is called, the server stops accepting new connections and waits for all existing connections to close. The function that we pass to server.close() will be called once all connections are closed. This is where we can perform any additional cleanup tasks that need to happen before the application shuts down.

Performing Additional Cleanup

Depending on the needs of your application, you may have additional cleanup tasks that need to happen when your application shuts down. For example, if you have a database connection, you should close it before your application exits. This cleanup code should be placed inside the callback function that you pass to server.close().

process.on('SIGTERM', () => {
  console.log('SIGTERM signal received.');
  server.close(() => {
    console.log('Closed out remaining connections');
    // Additional cleanup tasks go here, e.g., close database connection
    process.exit(0);
  });
});

process.on('SIGINT', () => {
  console.log('SIGINT signal received.');
  server.close(() => {
    console.log('Closed out remaining connections');
    // Additional cleanup tasks go here, e.g., close database connection
    process.exit(0);
  });
});

Handling Shutdown in a Dockerized Node.js Application

When running a Node.js application in a Docker container, there are additional considerations to take into account. This section discusses how Docker sends shutdown signals and how to ensure your Node.js application can handle them correctly.

Understanding Docker Shutdown Signals

When Docker is asked to stop a running container, it sends a SIGTERM signal to the main process running in the container. This is Docker's way of asking the process to shut down gracefully, by finishing what it's currently doing, cleaning up as needed, and then terminating.

However, if the process does not terminate within a certain period (10 seconds by default), Docker will then send a SIGKILL signal to forcibly terminate the process. This is akin to pulling the plug on the application - it won't have a chance to finish what it's doing or clean up.

This is why our Node.js application needs to listen for and handle the SIGTERM signal, as we discussed in previous sections. By handling SIGTERM, our application can ensure it shuts down gracefully when Docker asks it to stop.

Adjusting Docker's Grace Period

Sometimes, our application may need more than 10 seconds to shut down gracefully. For example, it might need to finish processing a long-running request, or it might need to wait for a database transaction to commit.

In such cases, we can tell Docker to wait longer before it sends the SIGKILL signal, by using the --stop-timeout option when we run our Docker container. This option takes several seconds as its argument.

For example, to start a Docker container and give it 30 seconds to shut down gracefully before forcibly killing it, we would use a command like this:

docker run --stop-timeout 30 my-nodejs-app

Keep in mind that while extending the stop timeout can help in some situations, it's not a panacea. If your application consistently takes a long time to shut down, it may be a sign that it needs to be optimized or refactored.

Conclusion

Handling shutdown signals in your Node.js applications allows you to manage resources properly, reduce potential data loss or corruption, provide a better user experience, and more. By understanding how to handle these signals, you can make your applications more robust and reliable, both in development and in production.