11 Apagado elegante

En esta próxima sección del libro vamos a hablar sobre un tema importante pero a menudo pasado por alto: cómo detener de manera segura tu aplicación en ejecución.

En este momento, cuando detenemos nuestra aplicación de API (generalmente al presionar Ctrl+C), se termina de inmediato sin dar oportunidad para que las solicitudes HTTP en curso se completen. Esto no es ideal por dos razones:

  • Significa que los clientes no recibirán respuestas a sus solicitudes en curso; todo lo que experimentarán es un cierre abrupto de la conexión HTTP
  • Cualquier tarea en curso realizada por nuestros controladores podría quedar en un estado incompleto.

Vamos a mitigar estos problemas agregando funcionalidad de cierre ordenado a nuestra aplicación, de modo que las solicitudes HTTP en curso tengan la oportunidad de completarse antes de que la aplicación sea terminada.

Aprenderas:

  • Señales de apagado: qué son, cómo enviarlas y cómo escucharlas en tu aplicación de API.

  • Cómo utilizar estas señales para activar un apagado ordenado del servidor HTTP mediante el método Shutdown() de Go.

11.1 Enviando seniales de apagado

Cuando nuestra aplicación está en ejecución, podemos terminarla en cualquier momento enviándole una señal específica. Una forma común de hacer esto, que probablemente hayas estado utilizando, es presionar Ctrl+C en tu teclado para enviar una señal de interrupción, también conocida como SIGINT.

Pero esta no es la única señal que puede detener nuestra aplicación. Algunas de las otras comunes son:

SignalDescriptionKeyboard ShortcutCatchable
SIGINTInterrupt from keyboardCtrl+CYes
SIGQUITQuit from keyboardCtrl+\Yes
SIGKILLKill process (terminate immediately)-No
SIGTERMTerminate process in orderly manner-Yes

Es importante explicar desde el principio que algunas señales son capturables y otras no lo son. Las señales capturables pueden ser interceptadas por nuestra aplicación y, o bien ignoradas, o bien utilizadas para activar una acción específica (como un cierre ordenado). Otras señales, como SIGKILL, no son capturables y no pueden ser interceptadas.

Echemos un vistazo rápido a estas señales en acción. Si estás siguiendo, adelante y inicia la aplicación de la API con el mismo comando go run ./cmd/api como de costumbre:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development

Realizar esto debería iniciar un proceso con el nombre “api” en tu máquina. Puedes usar el comando pgrep para verificar que este proceso existe, de la siguiente manera:

$ pgrep -l api
4414 api

En mi caso, puedo ver que el proceso “api” está en ejecución y tiene el ID de proceso 4414 (es probable que tu ID de proceso sea diferente).

Una vez confirmado eso, procede a intentar enviar una señal SIGKILL al proceso “api” usando el comando pkill de la siguiente manera:

Si vuelves a la ventana de la terminal donde se está ejecutando la aplicación de la API, deberías ver que ha sido terminada y la última línea en el flujo de salida es “signal: killed”. Será algo similar a esto:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
signal: killed

Siéntete libre de repetir el mismo proceso, pero enviando una señal SIGTERM en su lugar:

$ pkill -SIGTERM api

Esta vez deberías ver la línea “signal: terminated” al final de la salida, de la siguiente manera:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
signal: terminated

También puedes intentar enviar una señal SIGQUIT, ya sea presionando Ctrl+\ en tu teclado o ejecutando pkill -SIGQUIT api. Esto hará que la aplicación salga con un volcado de pila, similar a esto:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
SIGQUIT: quit
PC=0x46ebe1 m=0 sigcode=0
goroutine 0 [idle]:
runtime.futex(0x964870, 0x80, 0x0, 0x0, 0x0, 0x964720, 0x7ffd551034f8, 0x964420, 0x7ffd55103508, 0x40dcbf, ...)
/usr/local/go/src/runtime/sys_linux_amd64.s:579 +0x21
...

Podemos ver que estas señales son efectivas para terminar nuestra aplicación, pero el problema que tenemos es que todas hacen que nuestra aplicación salga inmediatamente. Afortunadamente, Go proporciona herramientas en el paquete os/signal que podemos usar para interceptar señales capturables y activar un cierre ordenado de nuestra aplicación. Veremos cómo hacer eso en el próximo capítulo.

11.2 Intercaptando señales de apagado

Antes de sumergirnos en los detalles de cómo interceptar señales, movamos el código relacionado con nuestro http.Server fuera de la función main() y coloquémoslo en un archivo separado. Esto nos proporcionará un punto de partida limpio y claro desde el cual podemos desarrollar la funcionalidad de cierre ordenado.

Si estás siguiendo el ejemplo, crea un nuevo archivo cmd/api/server.go:

$ touch cmd/api/server.go

Luego, agrega un nuevo método app.serve() que inicializa y arranca nuestro http.Server, de la siguiente manera:

package main

import (
	"fmt"
	"log/slog"
	"net/http"
	"time"
)

func (app *application) serve() error {
	// Declare a HTTP server using the same settings as in our main() function.
	srv := &http.Server{
		Addr:         fmt.Sprintf(":%d", app.config.port),
		Handler:      app.routes(),
		IdleTimeout:  time.Minute,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		ErrorLog:     slog.NewLogLogger(app.logger.Handler(), slog.LevelError),
	}
	// Likewise log a "starting server" message.
	app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.env)
	// Start the server as normal, returning any error.
	return srv.ListenAndServe()
}

Con eso en su lugar, podemos simplificar nuestra función main() para utilizar este nuevo método app.serve() de la siguiente manera:

func main() {
	var cfg config

	flag.IntVar(&cfg.port, "port", 4000, "API server port")
	flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")

	flag.StringVar(&cfg.db.dsn, "db-dsn", "postgres://greenlight:pa55word@localhost/greenlight", "PostgreSQL DSN")

	flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
	flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
	flag.DurationVar(&cfg.db.maxIdleTime, "db-max-idle-time", 15*time.Minute, "PostgreSQL max connection idle time")

	// Create command line flags to read the setting values into the config struct.
	// Notice that we use true as the default for the 'enabled' setting?
	flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum requests per second")
	flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst")
	flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter")
	flag.Parse()

	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

	db, err := openDB(cfg)
	if err != nil {
		logger.Error(err.Error())
		os.Exit(1)
	}

	defer db.Close()

	// Use the data.NewModels() function to initialize a Models struct, passing in the
	// connection pool as a parameter.
	app := application{
		config: cfg,
		logger: logger,
		models: data.NewModels(db),
	}

	// Call app.serve() to start the server.
	err = app.serve()

	if err != nil {
		logger.Error(err.Error())
		os.Exit(1)
	}
}

11.2 Capturando seniales SIGINT y SIGTERM

Lo siguiente que queremos hacer es actualizar nuestra aplicación para que “capture” cualquier señal SIGINT y SIGTERM. Como mencionamos anteriormente, las señales SIGKILL no son capturables (y siempre harán que la aplicación se termine de inmediato), y dejaremos SIGQUIT con su comportamiento predeterminado (ya que es útil si deseas ejecutar un apagado no ordenado a través de un atajo de teclado).

Para capturar las señales, necesitaremos iniciar una goroutine en segundo plano que se ejecute durante toda la vida de nuestra aplicación. En esta goroutine en segundo plano, podemos usar la función signal.Notify() para escuchar señales específicas y transmitirlas a un canal para su procesamiento adicional. Te mostraré cómo hacerlo.

Abre el archivo cmd/api/server.go y actualízalo de la siguiente manera:

package main

import (
	"fmt"
	"log/slog"
	"net/http"
	"os"        // New import
	"os/signal" // New import
	"syscall"   // New import
	"time"
)

func (app *application) serve() error {
	srv := &http.Server{
		Addr:         fmt.Sprintf(":%d", app.config.port),
		Handler:      app.routes(),
		IdleTimeout:  time.Minute,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		ErrorLog:     slog.NewLogLogger(app.logger.Handler(), slog.LevelError),
	}
	// Start a background goroutine.
	go func() {
		// Create a quit channel which carries os.Signal values.
		quit := make(chan os.Signal, 1)
		// Use signal.Notify() to listen for incoming SIGINT and SIGTERM signals and
		// relay them to the quit channel. Any other signals will not be caught by
		// signal.Notify() and will retain their default behavior.
		signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
		// Read the signal from the quit channel. This code will block until a signal is
		// received.
		s := <-quit
		// Log a message to say that the signal has been caught. Notice that we also
		// call the String() method on the signal to get the signal name and include it
		// in the log entry attributes.
		app.logger.Info("caught signal", "signal", s.String())
		// Exit the application with a 0 (success) status code.
		os.Exit(0)
	}()
	// Start the server as normal.
	app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.env)
	return srv.ListenAndServe()
}

En este momento, este nuevo código no está haciendo mucho; después de interceptar la señal, todo lo que hacemos es registrar un mensaje y luego salir de nuestra aplicación. Pero lo importante es que demuestra el patrón de cómo capturar señales específicas y manejarlas en tu código.

Quisiera enfatizar rápidamente algo sobre esto: nuestro canal quit es un canal con búfer de tamaño 1. Necesitamos usar un canal con búfer aquí porque signal.Notify() no espera a que haya un receptor disponible al enviar una señal al canal quit. Si hubiéramos utilizado un canal regular (sin búfer) aquí en su lugar, podríamos ‘perder’ una señal si nuestro canal quit no está listo para recibir en el momento exacto en que se envía la señal. Al usar un canal con búfer, evitamos este problema y nos aseguramos de que nunca perdamos una señal.

Vamos a probar esto.

Primero, ejecuta la aplicación y luego presiona Ctrl+C en tu teclado para enviar una señal SIGINT. Deberías ver una entrada de registro “caught signal” con “signal”:“interrupt” en los atributos, similar a esto:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:14.345+02:00 level=INFO msg="caught signal" signal=interrupt

También puedes reiniciar la aplicación y probar enviando una señal SIGTERM. Esta vez, los atributos de la entrada de registro deberían contener signal=terminated, como se muestra a continuación:

$ pkill -SIGTERM api
$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:14.345+02:00 level=INFO msg="caught signal" signal=terminated

En cambio, enviar una señal SIGKILL o SIGQUIT seguirá haciendo que la aplicación se cierre inmediatamente sin que la señal sea capturada, por lo que no verás un mensaje “caught signal” en los registros. Por ejemplo, si reinicias la aplicación y emites un SIGKILL:

$ pkill -SIGKILL api

La aplicación debería ser terminada de inmediato, y los registros se verán así:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
signal: killed

11.3 Ejecutando el apagado

Interceptar las señales está bien, pero no es muy útil hasta que hagamos algo con ellas. En este capítulo, vamos a actualizar nuestra aplicación para que las señales SIGINT y SIGTERM que interceptamos activen un cierre ordenado de nuestra API.

Específicamente, después de recibir una de estas señales, llamaremos al método Shutdown() en nuestro servidor HTTP. La documentación oficial describe esto de la siguiente manera:

Shutdown cierra el servidor de manera ordenada sin interrumpir ninguna conexión activa. Funciona cerrando primero todos los oyentes abiertos, luego cerrando todas las conexiones inactivas y, finalmente, esperando indefinidamente a que las conexiones vuelvan a estar inactivas para luego cerrarse.

El patrón para implementar esto en la práctica es difícil de describir con palabras, así que sumerjámonos en el código y hablemos de los detalles a medida que avanzamos.

package main

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

func (app *application) serve() error {
	srv := &http.Server{
		Addr:         fmt.Sprintf(":%d", app.config.port),
		Handler:      app.routes(),
		IdleTimeout:  time.Minute,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		ErrorLog:     slog.NewLogLogger(app.logger.Handler(), slog.LevelError),
	}
	// Create a shutdownError channel. We will use this to receive any errors returned
	// by the graceful Shutdown() function.
	shutdownError := make(chan error)
	go func() {
		// Intercept the signals, as before.
		quit := make(chan os.Signal, 1)
		signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
		s := <-quit // Update the log entry to say "shutting down server" instead of "caught signal".
		app.logger.Info("shutting down server", "signal", s.String())
		// Create a context with a 30-second timeout.
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		// Call Shutdown() on our server, passing in the context we just made.
		// Shutdown() will return nil if the graceful shutdown was successful, or an
		// error (which may happen because of a problem closing the listeners, or
		// because the shutdown didn't complete before the 30-second context deadline is
		// hit). We relay this return value to the shutdownError channel.
		shutdownError <- srv.Shutdown(ctx)
	}()
	app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.env)
	// Calling Shutdown() on our server will cause ListenAndServe() to immediately
	// return a http.ErrServerClosed error. So if we see this error, it is actually a
	// good thing and an indication that the graceful shutdown has started. So we check
	// specifically for this, only returning the error if it is NOT http.ErrServerClosed.
	err := srv.ListenAndServe()
	if !errors.Is(err, http.ErrServerClosed) {
		return err
	}
	// Otherwise, we wait to receive the return value from Shutdown() on the
	// shutdownError channel. If return value is an error, we know that there was a
	// problem with the graceful shutdown and we return the error.
	err = <-shutdownError
	if err != nil {
		return err
	}
	// At this point we know that the graceful shutdown completed successfully and we
	// log a "stopped server" message.
	app.logger.Info("stopped server", "addr", srv.Addr)
	return nil
}

A primera vista, este código puede parecer un poco complejo, pero en un nivel alto, lo que hace se puede resumir de manera muy simple: cuando recibimos una señal SIGINT o SIGTERM, instruimos a nuestro servidor que deje de aceptar nuevas solicitudes HTTP y damos un “período de gracia” de 30 segundos para que se completen las solicitudes en curso antes de que se termine la aplicación.

Es importante tener en cuenta que el método Shutdown() no espera a que se completen las tareas en segundo plano ni cierra conexiones prolongadas tomadas como WebSockets. En su lugar, deberás implementar tu propia lógica para coordinar un cierre ordenado de estas cosas. Exploraremos algunas técnicas para hacer esto más adelante en el libro.

Dejando eso de lado, esto debería estar funcionando bien en nuestra aplicación. Para demostrar la funcionalidad de cierre ordenado, puedes agregar un retraso de 4 segundos al método healthcheckHandler, de la siguiente manera:

package main

import (
	"net/http"
	"time" // New import
)

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
	env := envelope{
		"status": "available",
		"system_info": map[string]string{
			"environment": app.config.env,
			"version":     version,
		},
	}
	// Add a 4 second delay.
	time.Sleep(4 * time.Second)
	err := app.writeJSON(w, http.StatusOK, env, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

Entonces, inicia la API y en otra ventana de terminal emite una solicitud al punto de control de salud (healthcheck) seguido de una señal SIGTERM.

$ curl localhost:4000/v1/healthcheck & pkill -SIGTERM api

En los registros del servidor, deberías ver inmediatamente un mensaje “shutting down server” después de la señal SIGTERM, similar a esto:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:14.722+02:00 level=INFO msg="shutting down server" signal=terminated

Luego, después de un retraso de 4 segundos para que la solicitud en curso se complete, nuestro healthcheckHandler debería devolver la respuesta JSON como de costumbre, y deberías ver que nuestra API ha registrado un mensaje final “stopped server” antes de salir limpiamente:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:14.722+02:00 level=INFO msg="shutting down server" signal=terminated
time=2023-09-10T10:59:18.722+02:00 level=INFO msg="stopped server" addr=:4000

¿Notaste el retraso de 4 segundos entre los mensajes “shutting down server” y “stopped server” en las marcas de tiempo anteriores?

Esto está funcionando muy bien ahora. Cada vez que queramos cerrar nuestra aplicación de manera ordenada, podemos hacerlo enviando una señal SIGINT (Ctrl+C) o SIGTERM. Siempre y cuando ninguna solicitud en curso tome más de 30 segundos para completarse, nuestros controladores tendrán tiempo para finalizar su trabajo y nuestros clientes recibirán una respuesta HTTP adecuada. Y si alguna vez queremos salir inmediatamente, sin un cierre ordenado, aún podemos hacerlo enviando una señal SIGQUIT (Ctrl+) o SIGKILL en su lugar.

Por último, si estás siguiendo, por favor, revierte healthcheckHandler para eliminar el retraso de 4 segundos, de la siguiente manera:

package main

import (
	"net/http"
)

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
	env := envelope{
		"status": "available",
		"system_info": map[string]string{
			"environment": app.config.env,
			"version":     version,
		},
	}
	err := app.writeJSON(w, http.StatusOK, env, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

Post Relacionados