Graceful shutdown with HTTP and gRPC in Go
At Netflix, we run our containers on Kubernetes. Sometimes Kubernetes has to terminate your container, for any number of reasons: a new version is being deployed, or your container is being migrated elsewhere, or a scale down is happening, etc. As the Kubernetes Pod Lifecycle docs describes, the application in your container will be sent a SIGTERM/SIGINT, and then Kubernetes will wait for your application to gracefully shut down, and if it hasn’t done so after a small grace period, Kubernetes will SIGKILL it.
So, I often find myself starting applications by writing graceful shutdown logic. And since most of the application programming that I do uses HTTP and gRPC servers, I end up writing graceful shutdown for those fairly frequently.
I thought I’d codify that here for myself, and for anyone else interested in seeing how to gracefully shut down your gRPC and HTTP servers in Go.
The key parts are:
- We use
signal.NotifyContextto attach SIGINT/SIGTERM to context cancellation. - We then feed that context to an
errgroup.WithContextto create context cancellation aware goroutines. - We use that errgroup to spawn goroutines to serve HTTP/gRPC.
- We use that errgroup to spawn HTTP/gRPC goroutines that watch for context
cancellation and begin graceful shutdown.
- Apart from the cancellation provided by
signal.NotifyContext, any other reason for cancellation also triggers graceful shutdown. So this is a nice generic way to wire up your application for shutdown due to any reason you might care to express.
- Apart from the cancellation provided by
- We bound graceful shutdown to a timeout, after which we forcefully shut down.
- This part is take it or leave it: Kubernetes will send SIGKILL after whatever grace period it allows. So, you could just use that mechanism. I personally prefer to own shutdown as much as I can, though, so that I can make sure to call all the logs/metrics/tracing flushing.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
pb "google.golang.org/grpc/examples/helloworld/helloworld"
)
var httpPort = flag.Int("httpPort", 7000, "The port to serve HTTP on")
var grpcPort = flag.Int("grpcPort", 8000, "The port to server gRPC on")
const httpShutdownTimeout = 5 * time.Second // How long HTTP shutdown is allowed to take.
const grpcShutdownTimeout = 5 * time.Second // How long gRPC shutdown is allowed to take.
func main() {
flag.Parse()
// SIGINT/SIGTERM signals to our application will result in the ctx being cancelled.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil {
slog.Error("application exited with 1", "error", err)
// Make sure to flush logs/metrics/traces/etc by this point! And keep in
// mind that defer doesn't run when you os.Exit(1), so you might need
// to invoke them manually depending on where you performed init.
os.Exit(1)
}
slog.Info("application exited with 0")
}
func run(ctx context.Context) error {
// Start with port binding.
httpListener, err := net.Listen("tcp", fmt.Sprintf(":%d", *httpPort))
if err != nil {
return fmt.Errorf("http listen on port %d failed: %v", *httpPort, err)
}
defer httpListener.Close()
grpcServerListener, err := net.Listen("tcp", fmt.Sprintf(":%d", *grpcPort))
if err != nil {
return fmt.Errorf("grpc listen on port %d failed: %v", *grpcPort, err)
}
defer grpcServerListener.Close()
eg, gCtx := errgroup.WithContext(ctx)
// HTTP server.
mux := http.NewServeMux()
mux.HandleFunc("/foo", func(w http.ResponseWriter, _ *http.Request) {
if _, err := fmt.Fprintln(w, "bar"); err != nil {
slog.Error("/foo failed during write", "error", err)
}
})
httpServer := &http.Server{Handler: mux}
// Start HTTP serve and shutdown goroutines.
eg.Go(func() error { // HTTP serve.
slog.Info(fmt.Sprintf("HTTP server starting at port :%d", *httpPort))
if err := httpServer.Serve(httpListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("HTTP server failed", "error", err)
return err
}
slog.Info("HTTP server shut down")
return nil
})
eg.Go(func() error { // HTTP graceful -> forceful shutdown.
<-gCtx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), httpShutdownTimeout)
defer shutdownCancel()
slog.Info("HTTP server gracefully shutting down")
if err := httpServer.Shutdown(shutdownCtx); err != nil {
slog.Error("HTTP server shutdown failed. forcefully shutting down")
return errors.Join(err, httpServer.Close())
}
return nil
})
// gRPC server.
grpcServer := grpc.NewServer()
pb.RegisterGreeterServer(grpcServer, &fooService{})
// Start gRPC serve and shutdown goroutines.
eg.Go(func() error { // gRPC serve.
slog.Info(fmt.Sprintf("gRPC server starting at port :%d", *grpcPort))
if err := grpcServer.Serve(grpcServerListener); err != nil {
slog.Error("gRPC server failed", "error", err)
return err
}
slog.Info("gRPC server shut down")
return nil
})
eg.Go(func() error { // gRPC graceful -> forceful shutdown.
<-gCtx.Done()
stopped := make(chan struct{})
go func() {
slog.Info("gRPC server gracefully shutting down")
grpcServer.GracefulStop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(grpcShutdownTimeout):
slog.Error("gRPC server forcefully shutting down")
grpcServer.Stop()
return fmt.Errorf("gRPC server was unable to gracefully shut down")
}
return nil
})
// This returns nil if shutdown was successful, or the first err for any
// shutdown issue.
return eg.Wait()
}
type fooService struct {
pb.UnimplementedGreeterServer
}
Let’s run it and send it a SIGINT:
$ go run .
2026/08/18 09:19:39 INFO HTTP server starting at port :7000
2026/08/18 09:19:39 INFO gRPC server starting at port :8000
^C2026/08/18 09:19:40 INFO gRPC server gracefully shutting down
2026/08/18 09:19:40 INFO HTTP server gracefully shutting down
2026/08/18 09:19:40 INFO HTTP server shut down
2026/08/18 09:19:40 INFO gRPC server shut down
2026/08/18 09:19:40 INFO application exited with 0