Daemon en Linux con Python
Un proceso daemon (o desacoplado del terminal) que se comporte correctamente en entornos Unix requiere de una programación específica, pero una vez hecho uno, hechos todos. El package daemon de Python sigue la especificación del PEP 3143, definiendo un contexto que ofrece el comportamiento y la configuración de entorno para que un programa escrito en Python adquiera el estado de daemon. Tras un año de uso, esta librería demostró ser incompleta, sobredimensionada e inestable para nuestro gusto.
Nuestras necesidades son muy concretas. Tanto en la arquitectura de Popeye como la de Yoplait, se dispone de una capa de servicios que abstrae el modelo de datos y que actúa como único punto de acceso a éstos. Esta capa de servicios está disponible a través de un daemon process, formado a su vez por un dispatcher y un número variable de workers. A menudo, estos servicios reciben peticiones que no requieren ser atendidas de forma síncrona (por ejemplo, crear el thumbnail de una imagen que acaba de subirse), por lo que son desviadas a una cola de trabajos. Una serie de agentes, también implementados como daemons, se encargan de ir atendiendo esas tareas pendientes o jobs.
Hay tres condiciones sine qua non que determinan que un proceso pueda considerarse un daemon:
- Su padre debe ser el proceso init.
- Debe ser el líder de la sesión.
- No puede estar asociado a ninguna terminal (tty o pts).
La primera condición se alcanza mediante la combinación de una llamada a la función os.fork() seguido de una llamada a la función os._exit() en el proceso padre, de modo que el proceso hijo quede huérfano y su paternidad deba ser asumida por el proceso init.
if os.fork() > 0: os._exit(0) os.setsid()
La llamada a la función os.fork() crea un nuevo proceso, que es una copia exacta del proceso anterior. Por lo tanto, la ejecución de ambos procesos continua en el mismo punto una vez completada la llamada de la función, es decir, en la evaluación de la condición del if. Si se completa la llamada adecuadamente, la función os.fork() retorna 0 al proceso hijo y el identificador de proceso hijo al padre. En otro caso, se lanza una excepción de tipo OSError. Esta excepción no se captura, pues conviene que se propague y finalice así la ejecución del proceso. Una llamada a la función os.fork() puede fallar bien porque el sistema no disponga de los recursos necesarios para crear otro proceso, bien por un límite autoimpuesto sobre el total de procesos en ejecución (del conjunto del sistema o del usuario).
El paso inmediatamente posterior a la creación del nuevo proceso hijo es la finalización del proceso padre. Para conseguir este propósito utilizamos la función os._exit(), la cual se encarga de cerrar todos los descriptores de fichero que estén abiertos. La función os._exit(), a diferencia de la función sys.exit(), no llama a ninguna función registrada mediante atexit.register(). Asimismo, téngase en cuenta que el uso de la función sys.exit() puede provocar que se haga un flush a disco por duplicado de todos los streams de la entrada y salida estándar y que se borren ficheros temporales de forma accidental. Por lo tanto, se recomienda que tanto el proceso hijo como el padre de un daemon usen os._exit().
La segunda y la tercera condiciones se alcanzan con una llamada a la función de sistema os.setsid() en el proceso hijo. Esta llamada tiene tres propósitos:
- Crear una nueva sesión y convertir al proceso en su líder.
- Convertir al proceso en el líder del nuevo grupo de procesos.
- Desacoplar al proceso del terminal.
Para entender porqué es necesario llevar a cabo estos pasos es preciso entender el concepto de grupo de procesos del estándar POSIX. En los sistemas operativos que siguen la especificación POSIX, un grupo de procesos denota un conjunto de uno o más procesos y se utilizan para controlar la distribución de señales. Una señal dirigida a un grupo de procesos es enviada a todos y cada uno de los procesos que pertenecen al grupo.
A su vez, una sesión agrupa a un grupo de procesos. A los grupos de procesos no se les permite migrar de una sesión a otra. Por lo tanto, un proceso sólo puede crear nuevos grupos de procesos pertenecientes a su sesión. Del mismo modo, un proceso no puede unirse a un grupo de procesos a menos que pertenezcan a la misma sesión. Los procesos creados por otros procesos (hijos) heredan el grupo de procesos y la sesión de su creador (padre).
Luego, para convertir un proceso en daemon, tras su creación es necesario darle la independencia necesaria (su propia sesión y grupo de procesos) a él y a todos los procesos y threads que precise crear para llevar a cabo su función (heredarán automáticamente estos nuevos valores).
Una vez disponemos de un proceso daemon, ya sólo nos queda implementar una serie de funcionalidades y salvaguardas que ofrezcan un funcionamiento elegante, seguro y útil. Por ejemplo:
- Asignar un directorio de trabajo y una máscara de ficheros.
- Cerrar los descriptores de ficheros.
- Controlar stdin, stdout y stderr.
- Crear un pidfile.
- Capturar y gestionar las señales que nos interesen.
- Procesar las opciones de ejecución recibidas durante la llamada.
- Guardar los mensajes de salida en un fichero de log o mostrarlos por la salida estándar.
Las dos primeras acciones que nos conviene llevar a cabo una vez creado el daemon son establecer la raíz como directorio de trabajo y establecer un umask. La primera es necesaria ya que el directorio de trabajo actual podría ser un punto de montaje cualquiera en el sistema de ficheros, de modo que el proceso podría bloquearlo permanentemente, impidiendo que se desmontara. La segunda es conveniente para no heredar una máscara de permisos del padre demasiado poco restrictiva.
if workdir: os.chdir(workdir) if umask: os.umask(umask)
El siguiente paso es cerrar los descriptores de ficheros para evitar que el proceso hijo mantenga abierto los descriptores de fichero que haya heredado del padre. Para ello tratamos de obtener el número máximo de descriptores de ficheros que puede tener abierto el proceso o usamos un valor predefinido en una constante (MAXFD). Luego los iteramos y los cerramos, teniendo en cuenta que puede que alguno haya dejado de existir durante la ejecución del bucle.
maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1] if maxfd == resource.RLIM_INFINITY: maxfd = MAXFD for fd in xrange(maxfd): try: os.close(fd) except OSError: pass
El siguiente paso es redirigir los descriptores de E/S estándares. Ya que un proceso daemon no tiene terminal alguno que lo controle, la opción más habitual es redirigir stdin, stdout y stderr a /dev/null, lo cual evita efectos no deseados producto de lecturas y escrituras sobre dichos descriptores. La forma más sencilla de obtener esto es abrir el descriptor stdin y duplicarlo a stdout y stderr. La llamada a la función os.open() nos retorna el descriptor de fichero con el identificador más bajo (cero), pues previamente acabamos de cerrar todos los descriptores abiertos. Así se consigue, de forma sencilla, asignar /dev/null a stdin.
os.open(os.devnull, os.O_RDWR) os.dup2(0, 1) os.dup2(0, 2)
La creación de un pidfile es una tradición que conviene respetar. En nuestro caso, hemos aislado el código encargado de esta labor en una simple función que recibe como único parámetro la ruta absoluta al fichero.
def create_pidfile(filename): pidfile = open(filename, 'w') pidfile.write("%d\n" % os.getpid()) pidfile.close() atexit.register(lambda: os.unlink(filename))
Esta función, además, registra una función mediante la función atexit.register() que tiene como objetivo borrar el pidfile durante la finalización del proceso. Por simplicidad, esta función es una lambda. Todas las funciones registradas con atexit.register() se ejecutarán, en orden LIFO, como consecuencia de la llamada a la función sys.exit(), lo cual nos permite acumular una serie de tareas a realizar antes de que termine el proceso.
La siguiente acción que vamos a tomar es la captura de las señales que nos interese. La señal SIGTERM es siempre conveniente capturarla para ejecutar un sys.exit() de forma controlada. El siguiente ejemplo ilustra el procedimiento a seguir para las señales SIGTERM, SIGINT y SIGHUP mediante el uso del módulo signal.
def handle_sigterm(signum, frame): logging.warn('TERM signal received. Exitting.') logging.shutdown() sys.exit(0) # Calls functions registered with atexit, in LIFO order signal.signal(signal.SIGTERM, handle_sigterm) def handle_sigint(signum, frame): logging.warn('INT signal received. Exitting.') logging.shutdown() sys.exit(0) signal.signal(signal.SIGINT, handle_sigint) def handle_sighup(signum, frame): logging.warn('HUP signal received. Closing and reopening the log file descriptor.') logging.shutdown() _create_logger(options) signal.signal(signal.SIGHUP, handle_sighup)
Para facilitar la claridad del código, se ha agrupado la definición de cada handler y su registro. La función handle_sigterm guarda un último apunte en el fichero de log y lo cierra antes de llamar a sys.exit(). La función handle_sigint se ejecuta cuando ejecutamos el script en la consola sin convertirlo en daemon, pues es la señal que se recibe al pulsar Ctrl+C. Finalmente, la función handle_sighup reabre el fichero de log, acción útil durante una rotación de logs con logrotate o similar. El registro de estas funciones como handlers de las señales es muy sencillo e idéntico en los tres casos, según puede verse en el código.
Por otra parte, una tarea habitual a la hora de programar un daemon es la captura y el tratamiento de los parámetros recibidos en la línea de comando. En Python existe el package optparse que cubre la inmensa mayoría de las necesidades que podamos tener a tal efecto. Este package ha sido sustituido por argparse a partir de la versión 2.7 de Python.
Finalmente, la gestión de un fichero de log mediante el módulo logging y la opción de cambiar el nombre del proceso por uno propio, fácil de reconocer y filtrar en la salida del comando ps y similares, son funcionalidades habituales en este contexto. A partir de aquí ya sólo queda programar la lógica específica del programa, utilizando una iteración infinita, el bloqueo del proceso a la espera de recibir una señal del kernel, etc.
El resultado final, en forma de plantilla para quién la quiera usar, puede encontrarse en el repositorio python-daemon en Bitbucket.
- Jaume Sabater's blog
- Log in or register to post comments
