Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 23

Graceful Shutdown with Signals and Context

Recommended resources

Hello! Welcome back to our advanced Go tutorial.

In our last lesson, we made our application significantly more resilient by integrating context.Context. You learned how to propagate timeouts, cancellation signals, and request-scoped values, ensuring that individual operations don't hang indefinitely. This gave us fine-grained control over the lifecycle of a single request.

Today, we will zoom out and apply similar principles to the lifecycle of the entire server.

Your Goal for Today

Learning Outcome: Implement graceful shutdown of the HTTP server using OS signal handling and context cancellation.

When you run a service in production, it will inevitably need to be stopped or restarted—for deployments, scaling, or maintenance. Simply killing the process can lead to dropped connections, corrupted data, and a poor user experience. A graceful shutdown ensures that the server stops accepting new requests but allows any in-flight requests to complete before it finally exits. This is a hallmark of a production-ready system.

You will learn how to listen for operating system signals (like Ctrl+C), trigger a graceful shutdown using the http.Server's built-in methods, and coordinate the process using goroutines and channels.



1. The "Why" of Graceful Shutdown

Before diving into the code, it's essential to understand the concept. A forceful shutdown is like a restaurant manager flipping on the lights at closing time and telling everyone to leave immediately, even with plates full of food. In contrast, a graceful shutdown is like the staff locking the doors to new customers but allowing current diners to finish their meals.

For a clear and memorable explanation of this concept, let's start with a short article that uses this exact analogy.

Graceful Shutdown in Go

This article, 'Graceful Shutdown in Go' by Bryan Primus, provides an excellent restaurant analogy to explain graceful vs. forceful shutdown. It perfectly frames the problem we are solving today.

Read the section titled 'Example Scenario'. The flowcharts provide a great visual comparison between a graceful shutdown and a forceful one in both a restaurant and a web service context.

As the article illustrates, a graceful shutdown process for a web service involves:

  1. Receiving a shutdown signal (SIGINT or SIGTERM).
  2. Stopping the acceptance of new incoming requests.
  3. Waiting for all in-flight requests to complete, up to a certain timeout.
  4. Cleaning up resources (like database connections) and exiting.
Go HTTP Server Graceful Shutdown Terminal Output
This terminal output shows the graceful shutdown process in action. After the interrupt signal (`^C`) is received, the server stops listening but waits for existing requests to finish before completing the shutdown.


2. The Core Pattern: Goroutines, Signals, and Shutdown

To implement this, we need to solve a fundamental problem: http.Server.ListenAndServe() is a blocking call. If you run it directly in your main function, no code after it will ever execute, preventing you from listening for shutdown signals.

The solution involves a well-established pattern in Go:

  1. Run the http.Server.ListenAndServe() call in a separate goroutine.
  2. Use the main goroutine to listen for OS signals.
  3. When a signal is received, call http.Server.Shutdown() to initiate the graceful stop.
  4. The main goroutine waits for the shutdown process to complete before exiting.

The following video provides a complete, step-by-step walkthrough of building this entire mechanism from scratch. It's the most effective way to see how all the pieces fit together.

Graceful Shutdown in Go: Key Patterns you need to know!

This video, 'Graceful Shutdown in Go' by Flo Woelki, is a comprehensive guide to implementing the entire pattern. It covers everything from setting up a test server to handling signals and managing timeouts.

Please watch the video from the beginning (00:00) to the end of the demonstration (17:16). Pay close attention to these key stages: Server Setup (00:40): A 'slow' endpoint is created to simulate a long-running request, which is crucial for testing the graceful behavior. Running in a Goroutine (05:55): ListenAndServe is moved into a goroutine, and a channel is used to communicate startup errors back to the main thread. Listening for OS Signals (08:28): The os/signal package is used to create a channel that receives SIGINT (Ctrl+C) and SIGTERM signals. Orchestration with select (10:52): A select statement elegantly waits for one of three events: a server error, an OS signal, or context cancellation. The Shutdown Call (12:28): server.Shutdown() is called with a new timeout context to drain active connections. Demonstration (15:03): The final section shows the difference between a shutdown that times out and one that completes successfully.



3. A Modern Refinement: signal.NotifyContext

The video you just watched uses signal.Notify to send OS signals to a dedicated channel. This is a perfectly valid and common pattern.

However, since Go 1.16, there's a more direct and idiomatic way to integrate signal handling with the context package you learned about in the last lesson: signal.NotifyContext.

This function creates a context that is automatically canceled when one of the specified OS signals is received. This simplifies the select block, as you no longer need a separate channel just for signals; you can listen directly on the context's Done() channel.

// Creates a context that is canceled on SIGINT or SIGTERM.
// The stop function should be called to release resources when the context is no longer needed.
shutdownCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

// ... server setup and start in a goroutine ...

// The main goroutine now blocks here until a signal is received.
<-shutdownCtx.Done()

// When it unblocks, the signal has been received. Time to shut down.
log.Println("Shutdown signal received, starting graceful shutdown...")

// ... call server.Shutdown() with a new timeout context ...

This approach ties the application's top-level lifecycle directly into the context framework, which is a powerful and consistent design. The article "Graceful Shutdown in Go" also uses this modern pattern in its full code example.



4. Project Integration

Now, let's bring this robust shutdown mechanism into our REST API project. We will modify the main function in your cmd/api/main.go file to incorporate the full pattern.

Here is the complete code for main.go. It combines the best practices from the resources you've reviewed: running the server in a goroutine, using signal.NotifyContext for clean signal handling, and calling server.Shutdown with a dedicated timeout.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	// This is where you would initialize your dependencies, like the
	// storage layer (Postgres or in-memory) and the HTTP server/router.
	// For now, we'll create a simple server.
	
	server := &http.Server{
		Addr:    ":8080",
		Handler: http.NewServeMux(), // In our real app, this would be our chi router
	}

	// Create a context that is canceled when a shutdown signal is received.
	shutdownCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	// Channel to receive errors from the server's goroutine.
	serverErrors := make(chan error, 1)

	// Start the server in a separate goroutine.
	go func() {
		log.Printf("Server starting on %s", server.Addr)
		// ListenAndServe blocks until an error occurs or the server is shut down.
		// The error will be http.ErrServerClosed on a graceful shutdown.
		serverErrors <- server.ListenAndServe()
	}()

	// Block until we receive a shutdown signal or the server fails to start.
	select {
	case err := <-serverErrors:
		// This case handles startup errors (e.g., port already in use).
		if !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("Server failed to start: %v", err)
		}
	case <-shutdownCtx.Done():
		// This case handles a shutdown signal from the OS.
		log.Println("Shutdown signal received. Starting graceful shutdown...")

		// Create a new context with a timeout for the shutdown process.
		shutdownTimeout := 10 * time.Second
		shutdownTimeoutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
		defer cancel()

		// Attempt to gracefully shut down the server.
		if err := server.Shutdown(shutdownTimeoutCtx); err != nil {
			log.Printf("Graceful shutdown failed: %v. Forcing immediate shutdown.", err)
			// If shutdown fails, you might want to force close.
			// server.Close() can be used here.
		}
		
		log.Println("Server has shut down gracefully.")
	}
	
	// Perform any final cleanup here after the server is down.
	// For example, closing a database connection pool.
	fmt.Println("Application has exited.")
}

To integrate this:

  1. Replace the content of your cmd/api/main.go with the code above.
  2. You will need to adapt it slightly to initialize and use your existing chi router instead of http.NewServeMux(). The rest of the logic remains the same.
  3. Run your server (go run ./cmd/api) and press Ctrl+C in the terminal. You should see the graceful shutdown messages printed to your console.


Conclusion

Congratulations! You have now implemented one of the most important features for a production-ready web service. By handling OS signals and managing the server lifecycle correctly, you ensure your application is a good citizen in any containerized or orchestrated environment.

Key Takeaways:

  • http.ListenAndServe is blocking and must be run in a goroutine to allow the main thread to handle shutdown logic.
  • os/signal.NotifyContext is the modern, idiomatic way to create a context that is canceled upon receiving OS signals like SIGINT and SIGTERM.
  • http.Server.Shutdown(ctx) is the key method for initiating a graceful stop. It stops accepting new requests and waits for existing ones to finish.
  • A new context with a timeout must be created for the Shutdown call itself to act as a safety net, preventing the shutdown process from hanging indefinitely.
  • A select statement is the perfect tool to orchestrate the application's main loop, waiting for either a server startup error or a shutdown signal.

Next Up

Our server is becoming increasingly robust. It handles per-request cancellation and application-wide graceful shutdown. In the next lesson, we will focus on observability by implementing structured logging using Go's slog package and adding middleware for request tracing. This will give us clear, machine-readable logs that are invaluable for debugging in production.