O como escribimos la biblioteca C ++ del cliente para ZooKeeper, etcd y Consul KV
En el mundo de los sistemas distribuidos, hay una serie de tareas típicas: almacenar información sobre la composición del clúster, administrar la configuración de nodos, detectar nodos fallidos, elegir un líder
y otros . Para resolver estos problemas, se han creado sistemas distribuidos especiales: servicios de coordinación. Ahora nos interesarán tres de ellos: ZooKeeper, etcd y Consul. De todas las funciones ricas de Consul, nos centraremos en Consul KV.

De hecho, todos estos sistemas son almacenes de valores clave linealizados con tolerancia a fallas. Aunque sus modelos de datos tienen diferencias significativas, que discutiremos más adelante, nos permiten resolver los mismos problemas prácticos. Obviamente, cada aplicación que utiliza el servicio de coordinación está vinculada a una de ellas, lo que puede llevar a la necesidad de admitir varios sistemas que resuelven las mismas tareas en un centro de datos para diferentes aplicaciones.
Una idea diseñada para resolver este problema se originó en una agencia de consultoría australiana, y nosotros, un pequeño equipo de estudiantes, tuvimos que implementarla, de lo que les voy a hablar.
Pudimos crear una biblioteca que proporciona una interfaz común para trabajar con ZooKeeper, etcd y Consul KV. La biblioteca está escrita en C ++, pero hay planes para portar a otros lenguajes.
Modelos de datos
Para desarrollar una interfaz común para tres sistemas diferentes, debe comprender qué tienen en común y en qué se diferencian. Vamos a hacerlo bien.
Zookeeper
Las claves se organizan en un árbol y se denominan nodos (znodes). En consecuencia, para el sitio puede obtener una lista de sus hijos. Las operaciones de crear znode (crear) y cambiar el valor (setData) son independientes: solo las claves existentes pueden leer y cambiar valores. Los relojes se pueden adjuntar a operaciones de verificar la existencia de un nodo, leer un valor y obtener hijos. Watch es un desencadenante único que se activa cuando cambia la versión de los datos correspondientes en el servidor. Los nodos efímeros se utilizan para detectar fallas. Se adjuntan a la sesión del cliente que los creó. Cuando un cliente cierra una sesión o deja de notificar a ZooKeeper sobre su existencia, estos nodos se eliminan automáticamente. Se admiten transacciones simples: un conjunto de operaciones que tienen éxito o fallan, si al menos una de ellas es imposible.
etcd
Los desarrolladores de este sistema se inspiraron claramente en ZooKeeper y, por lo tanto, hicieron todo de manera diferente. La jerarquía de claves no está aquí, pero forman un conjunto ordenado lexicográficamente. Puede obtener o eliminar todas las claves que pertenecen a un determinado rango. Tal estructura puede parecer extraña, pero de hecho es muy expresiva, y la visión jerárquica a través de ella se emula fácilmente.
No hay una operación estándar de comparación y configuración en etcd, pero hay algo mejor: las transacciones. Por supuesto, están en los tres sistemas, pero en las transacciones etc. son especialmente buenas. Consisten en tres bloques: verificación, éxito, fracaso. El primer bloque contiene un conjunto de condiciones, la segunda y tercera operaciones. Una transacción se realiza atómicamente. Si todas las condiciones son verdaderas, se ejecuta el bloque de éxito; de lo contrario, falla. En API versión 3.3, los bloques de éxito y error pueden contener transacciones anidadas. Es decir, es posible ejecutar atómicamente construcciones condicionales de un nivel casi arbitrario de anidamiento. Puede obtener más información sobre las comprobaciones y operaciones que existen en la
documentación .
Los relojes también existen aquí, aunque son un poco más complejos y reutilizables. Es decir, después de instalar el reloj en un rango de teclas, recibirá todas las actualizaciones en este rango hasta que cancele el reloj, y no solo la primera. En etcd, los arrendamientos son equivalentes a las sesiones de cliente de ZooKeeper.
Cónsul KVTampoco existe una estructura jerárquica estricta, pero Consul puede crear la apariencia de que existe: puede recibir y eliminar todas las claves con el prefijo especificado, es decir, trabajar con el "subárbol" de la clave. Tales consultas se llaman recursivas. Además, el Cónsul solo puede seleccionar teclas que no contengan el carácter especificado después del prefijo, que corresponde a la recepción de "hijos" inmediatos. Pero vale la pena recordar que esta es precisamente la apariencia de una estructura jerárquica: es muy posible crear una clave si su padre no existe o eliminar una clave que tiene elementos secundarios, mientras que los elementos secundarios continuarán almacenados en el sistema.

En lugar de relojes, hay solicitudes HTTP bloqueadas en Consul. En esencia, se trata de llamadas ordinarias al método de lectura de datos, para el cual, junto con otros parámetros, se indica la última versión conocida de los datos. Si la versión actual de los datos correspondientes en el servidor es mayor que la especificada, la respuesta se devuelve inmediatamente, de lo contrario, cuando el valor cambia. También hay sesiones aquí que se pueden adjuntar a las teclas en cualquier momento. Vale la pena señalar que, a diferencia de etcd y ZooKeeper, donde eliminar sesiones conduce a la eliminación de claves relacionadas, hay un modo en el que la sesión simplemente se separa de ellas.
Las transacciones están disponibles, sin ramificación, pero con todo tipo de cheques.
Reúne todo
El modelo de datos más riguroso tiene ZooKeeper. Las solicitudes de rango expresivo disponibles en etcd no se pueden emular de manera eficiente en ZooKeeper o Consul. Tratando de aprovechar lo mejor de todos los servicios, obtuvimos una interfaz casi equivalente a la interfaz de ZooKeeper con las siguientes excepciones significativas:
- secuencia, contenedor y nodos TTL no son compatibles
- Las ACL no son compatibles
- El método set crea una clave si no existía (en ZK setData devuelve un error en este caso)
- los métodos set y cas están separados (en ZK, son esencialmente lo mismo)
- El método de borrado elimina el vértice junto con el subárbol (en ZK delete devuelve un error si el vértice tiene hijos)
- para cada clave solo hay una versión: la versión del valor (en ZK hay tres de ellas )
El rechazo de los nodos secuenciales se debe al hecho de que en etcd y Consul no hay soporte incorporado para ellos, y además de la interfaz de la biblioteca resultante, el usuario puede implementarlos fácilmente.
Implementar el mismo comportamiento al eliminar un vértice requeriría mantener un contador secundario separado en etcd y Consul para etcd y Consul. Como intentamos evitar almacenar metainformación, se decidió eliminar todo el subárbol.
Sutilezas de implementación
Consideremos con más detalle algunos aspectos de la implementación de la interfaz de la biblioteca en diferentes sistemas.
Jerarquía en etcdMantener una vista jerárquica en etcd fue una de las tareas más interesantes. Las solicitudes de rango facilitan la obtención de una lista de claves con un prefijo especificado. Por ejemplo, si desea todo lo que comienza con
"/foo"
, solicite el rango
["/foo", "/fop")
. Pero esto devolvería todo el subárbol completo de la clave, lo que puede no ser aceptable si el subárbol es grande. Al principio, planeamos usar el mecanismo de conversión de claves
implementado en zetcd . Implica agregar un byte al comienzo de la clave, igual a la profundidad del nodo en el árbol. Daré un ejemplo.
"/foo" -> "\u01/foo" "/foo/bar" -> "\u02/foo/bar"
Entonces puede obtener todos los elementos
["\u02/foo/", "\u02/foo0")
inmediatos de la tecla
"/foo"
solicitando el rango
["\u02/foo/", "\u02/foo0")
. Sí, en ASCII,
"0"
sigue inmediatamente a
"/"
.
Pero, ¿cómo, entonces, eliminar un vértice? Resulta que necesita eliminar todos los rangos de la forma
["\uXX/foo/", "\uXX/foo0")
para XX de 01 a FF. Y luego nos topamos con un
límite en el número de operaciones dentro de una sola transacción.
Como resultado, se inventó un sistema simple de conversión de claves, que nos permitió implementar de manera efectiva tanto la eliminación de la clave como la recepción de una lista de niños. Es suficiente agregar un símbolo especial antes del último token. Por ejemplo:
"/very" -> "/\u00very" "/very/long" -> "/very/\u00long" "/very/long/path" -> "/very/long/\u00path"
Luego, eliminar la tecla
"/very"
convierte en eliminar
"/\u00very"
y el rango
["/very/", "/very0")
, y obtener todos los elementos
["/very/", "/very0")
en una solicitud de claves del rango
["/very/\u00", "/very/\u01")
.
Eliminar una llave en ZooKeeperComo ya mencioné, en ZooKeeper no puede eliminar un nodo si tiene hijos. Queremos eliminar la clave junto con el subárbol. Como ser Lo estamos haciendo con optimismo. Primero, atravesamos recursivamente el subárbol, obteniendo los hijos de cada vértice en una consulta separada. Luego construimos una transacción que intenta eliminar todos los nodos del subárbol en el orden correcto. Por supuesto, pueden ocurrir cambios entre leer un subárbol y eliminarlo. En este caso, la transacción fallará. Además, el subárbol puede cambiar durante el proceso de lectura. Una consulta para los hijos del siguiente nodo puede devolver un error si, por ejemplo, este vértice ya se ha eliminado. En ambos casos, repetimos todo el proceso nuevamente.
Este enfoque hace que la eliminación de una clave sea muy ineficaz si tiene elementos secundarios, y aún más si la aplicación continúa trabajando con el subárbol, eliminando y creando claves. Sin embargo, esto nos permitió no complicar la implementación de otros métodos en etcd y Consul.
establecido en ZooKeeperEn ZooKeeper, hay métodos por separado que funcionan con la estructura de árbol (crear, eliminar, getChildren) y que funcionan con datos en nodos (setData, getData). Además, todos los métodos tienen condiciones previas estrictas: crear devolverá un error si el nodo ya está creado, eliminar o setData: si aún no existe. Necesitábamos el método set, al que se puede llamar sin pensar en la clave.
Una opción es aplicar un enfoque optimista, como al eliminar. Verifique si el nodo existe. Si existe, llame a setData; de lo contrario, cree. Si el último método devolvió un error, repita de nuevo. Lo primero a tener en cuenta es la inutilidad de verificar la existencia. Puede llamar de inmediato a crear. La finalización exitosa significará que el nodo no existía y se creó. De lo contrario, crear devolverá el error correspondiente, después de lo cual se debe llamar a setData. Por supuesto, entre llamadas, el vértice puede eliminarse mediante una llamada competitiva, y setData también devolverá un error. En este caso, puede repetir todo de nuevo, pero ¿vale la pena?
Si ambos métodos devolvieron un error, entonces sabemos con certeza que hubo una eliminación competitiva. Imagine que esta eliminación se produjo después de llamar al conjunto. Entonces, no importa qué valor intentemos establecer, ya está borrado. Entonces puede suponer que el conjunto fue exitoso, incluso si de hecho no se escribió nada.
Más detalles técnicos
En esta sección, nos desviamos de los sistemas distribuidos y hablamos de codificación.
Uno de los principales requisitos del cliente era multiplataforma: en Linux, MacOS y Windows, al menos uno de los servicios debe ser compatible. Inicialmente, realizamos el desarrollo solo bajo Linux, y en otros sistemas comenzamos a probar más tarde. Esto causó muchos problemas, que durante algún tiempo no fue completamente claro cómo abordarlo. Como resultado, los tres servicios de coordinación ahora son compatibles con Linux y MacOS, y solo Consul KV en Windows.
Desde el principio, intentamos usar bibliotecas listas para acceder a los servicios. En el caso de ZooKeeper, la elección recayó en
ZooKeeper C ++ , que al final no se pudo compilar en Windows. Esto, sin embargo, no es sorprendente: la biblioteca está posicionada como solo para Linux. Para el cónsul,
ppconsul era la única opción. Tuve que agregarle soporte para
sesiones y
transacciones . Para etcd, nunca se encontró una biblioteca completa que admita la última versión del protocolo, por lo que solo
generamos un cliente grpc .
Inspirado por la interfaz asincrónica de la biblioteca ZooKeeper C ++, decidimos implementar también la interfaz asincrónica. En ZooKeeper C ++, se utilizan primitivas futuras / prometedoras para esto. En STL, desafortunadamente, se implementan de manera muy modesta. Por ejemplo, no existe un
método que aplique la función pasada al resultado futuro cuando esté disponible. En nuestro caso, dicho método es necesario para convertir el resultado al formato de nuestra biblioteca. Para solucionar este problema, tuvimos que implementar nuestro grupo de subprocesos simple, porque a petición del cliente no podíamos usar bibliotecas de terceros pesadas, como Boost.
Nuestra implementación de entonces funciona de la siguiente manera. Cuando se llama, se crea una promesa adicional / futuro par. Se devuelve el nuevo futuro, y el transferido se coloca junto con la función correspondiente y una promesa adicional en la cola. Un subproceso del grupo selecciona varios futuros de la cola y los sondea usando wait_for. Cuando el resultado está disponible, se llama a la función correspondiente y su valor de retorno se pasa a promesa.
Utilizamos el mismo grupo de subprocesos para ejecutar solicitudes a etcd y Consul. Esto significa que varios subprocesos diferentes pueden funcionar con las bibliotecas subyacentes. ppconsul no es seguro para subprocesos, por lo que las llamadas están protegidas por bloqueos.
Puede trabajar con grpc desde varios hilos, pero hay sutilezas. Los relojes Etcd se implementan a través de flujos grpc. Estos son canales bidireccionales para ciertos tipos de mensajes. La biblioteca crea un flujo único para todos los relojes y un flujo único que procesa los mensajes entrantes. Entonces grpc prohíbe la transmisión de grabaciones paralelas. Esto significa que al inicializar o eliminar el reloj, debe esperar hasta que se complete el envío de la solicitud anterior antes de enviar la siguiente. Utilizamos
variables condicionales para la sincronización.
Resumen
Véalo usted mismo:
liboffkv .
Nuestro equipo:
Raed Romanov ,
Ivan Glushenkov ,
Dmitry Kamaldinov ,
Victor Krapivensky ,
Vitaly Ivanin .