Actualización de Binarios en Ejecución sin Interrupción de Servicio

Aunque la arquitectura distribuida y herramientas como Keepalived han mitigado la necesidad de actualizaciones de binarios en caliente, existen escenarios específicos donde esta funcionalidad sigue siendo crucial. Este artículo explora cómo lograr actualizaciones de binarios sin interrumpir el servicio, inspirándose en la implementación de Nginx.

Desafíos Clave

  • Mantener las conexoines existentes activas hasta que el cliente complete su operación.
  • Utilizar una nueva instancia del programa para procesar nuevas solicitudes.
  • Permitir que ambas instancias (la antigua y la nueva) escuchen en el mismo puerto simultáneamente.

Enfoque Propuesto

Para que dos procesos escuchen en el mismo puerto, generalmente se emplea un modelo de padre-hijo. Las llamadas convencionales como fork no son suficientes para actualizar el binario; se requiere una recarga del ejecutable mediante exec. La clave para compartir el puerto de escucha entre los procesos se logra pasando el descriptor de archivo del socket como argumento o variable de entorno a las funciones de la familia exec.

Referencia: ngx_exec_new_binary

Proceso Detallado

  1. Se reemplaza el ejecutable existente por la nueva versión.
  2. Se envía una señal al proceso principal (por ejemplo, SIGUSR2) para activar una variable global que indica el inicio de la actualización.
  3. El bucle de manejo de eventos en el proceso antiguo detecta esta variable global. A partir de ese momento, deja de aceptar nuevas conexiones, pero sigue procesando las existentes.
  4. El proceso antiguo crea un nuevo proceso hijo mediante fork.
  5. El proceso hijo utiliza una función de la familia exec para cargar el nuevo binario. El descriptor de archivo del socket de escucha se transfiere al hijo a través de argumentos o variables de entorno.
  6. El nuevo proceso hijo comienza a aceptar nuevas conexiones, que serán gestionadas por la versión actualizada del programa.
  7. El proceso antiguo continúa ejecutándose hasta que todas las conexiones activas hayan finalizado (se puede definir un tiempo de espera). Una vez completado, el proceso antiguo se cierra.
  8. En este punto, solo la nueva instancia del programa está activa y escuchando en el puerto.

Ejemplo de Implementación

Se crean dos archivos: app_old.cpp y app_new.cpp. El archivo app_old.cpp contiene la lógica inicial:


#include <stdlib.h>
#include <iostream>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>

using namespace std;

extern char **environ; // Entorno global

// Diferencia entre el binario antiguo y el nuevo
const char *APP_TITLE = "APP-OLD ";

bool should_update = false;
bool stop_processing = false;

void signal_handler(int sig) {
    cout << "Signal received: " << sig << endl;
    if (sig == SIGUSR2) {
        should_update = true;
    }
    stop_processing = true;
}

void replace_binary(char* argv[], int listening_socket_fd) {
    if (fork() != 0) { // No es el proceso hijo
        return;
    }

    // Prepara la variable de entorno para pasar el FD
    char fd_env_var[100];
    snprintf(fd_env_var, sizeof(fd_env_var), "FD=%d", listening_socket_fd);

    int env_count;
    for (env_count = 0; environ[env_count]; ++env_count);

    // Copia el entorno existente
    char **new_env = new char*[env_count + 1];
    for (int i = 0; i < env_count - 1; ++i) {
        new_env[i] = environ[i];
    }

    // Agrega el descriptor de archivo al entorno
    new_env[env_count - 1] = fd_env_var;
    new_env[env_count] = NULL;

    // Carga el nuevo binario
    execvpe("./app_new", argv, new_env);

    // Si execvpe falla, se libera la memoria y se sale
    delete[] fd_env_var;
    delete[] new_env;
    perror("execvpe failed");
    exit(1);
}

int main(int argc, char* argv[]) {
    int server_socket_fd = -1;
    stop_processing = false;
    should_update = false;

    cout << APP_TITLE << "PID: " << getpid() << endl;

    char *inherited_fd_str = getenv("FD");

    if (inherited_fd_str) {
        // Proceso hijo que hereda el socket
        server_socket_fd = atoi(inherited_fd_str);
        cout << APP_TITLE << "PID: " << getpid() << " Inherited FD: " << server_socket_fd << endl;
    } else {
        // Proceso principal que crea el socket
        struct sockaddr_in server_address;
        server_address.sin_family = AF_INET;
        server_address.sin_port = htons(9999);
        server_address.sin_addr.s_addr = inet_addr("127.0.0.1");

        server_socket_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
        if (server_socket_fd < 0) {
            cerr << "Error creating socket." << endl;
            return 1;
        }

        if (bind(server_socket_fd, (struct sockaddr *)&server_address, sizeof(server_address)) < 0 ||
            listen(server_socket_fd, 4096) < 0) {
            cerr << "Error binding or listening." << endl;
            close(server_socket_fd);
            return 1;
        }

        // Configurar manejadores de señales
        if (signal(SIGUSR2, signal_handler) == SIG_ERR ||
            signal(SIGTERM, signal_handler) == SIG_ERR ||
            signal(SIGINT, signal_handler) == SIG_ERR) {
            cerr << "Error setting up signal handlers." << endl;
            close(server_socket_fd);
            return 1;
        }
    }

    // Bucle principle de procesamiento
    while (!stop_processing) {
        cout << APP_TITLE << "PID: " << getpid() << " Listening on FD: " << server_socket_fd << endl;
        // Aquí iría la lógica de aceptar y manejar conexiones
        sleep(1); // Simulación
    }

    // Si se recibió SIGUSR2, iniciar el proceso de actualización
    if (should_update) {
        replace_binary(argv, server_socket_fd);
    }

    // Simular espera para que las conexiones existentes terminen
    cout << APP_TITLE << "PID: " << getpid() << " Waiting for existing connections to finish..." << endl;
    for (int i = 0; i < 10; ++i) {
        cout << APP_TITLE << "PID: " << getpid() << " Active connections check: " << i << endl;
        sleep(1);
    }

    cout << APP_TITLE << "PID: " << getpid() << " Exiting." << endl;
    return 0;
}

El archivo app_new.cpp solo se diferencia en el valor de la constante APP_TITLE para distinguir las instancias:


--- app_old.cpp
+++ app_new.cpp
@@ -11,7 +11,7 @@
 using namespace std;
 
 // Diferencia entre el binario antiguo y el nuevo
-const char *APP_TITLE = "APP-OLD ";
+const char *APP_TITLE = "APP-NEW ";
 
 bool should_update = false;
 bool stop_processing = false;

Compilar ambos archivos para la prueba:


g++ app_old.cpp -o app_old
g++ app_new.cpp -o app_new

Ejecución de la prueba:


./app_old APP-OLD 12345
APP-OLD PID: 12345 Listening on FD: 3
APP-OLD PID: 12345 Listening on FD: 3
APP-OLD PID: 12345 Listening on FD: 3

# En otra terminal, enviar la señal de actualización
kill -USR2 12345

# Salida esperada (simplificada):
APP-OLD PID: 12345 Listening on FD: 3
APP-OLD PID: 12345 Listening on FD: 3
APP-OLD PID: 12345 Listening on FD: 3
# ... (proceso antiguo sigue corriendo)
APP-OLD PID: 12345 Waiting for existing connections to finish...
APP-OLD PID: 12345 Active connections check: 0
# El nuevo proceso se lanza y empieza a escuchar
APP-NEW PID: 12348 Inherited FD: 3
APP-NEW PID: 12348 Listening on FD: 3
APP-NEW PID: 12348 Listening on FD: 3
# ... (ambos procesos pueden coexistir temporalmente)
APP-OLD PID: 12345 Active connections check: 9
APP-OLD PID: 12345 Exiting.
# Solo queda el nuevo proceso
APP-NEW PID: 12348 Listening on FD: 3
APP-NEW PID: 12348 Listening on FD: 3
APP-NEW PID: 12348 Listening on FD: 3

Etiquetas: linux C++ actualización en caliente sysvipc Señales

Publicado el 11-1 08:53