En nuestro anterior artículo de esta serie tratamos la decisión que tomamos en DoorDash de pasar a una arquitectura de microservicios, las tecnologías que elegimos y cómo abordamos la transición. Como ocurre con la mayoría de las decisiones en informática, ésta también vino acompañada de su propio conjunto de retos y deficiencias. Este artículo se centra en los principales puntos conflictivos que encontramos al abandonar el monolito, con especial atención a los siguientes:
- Cambios en los patrones de fiabilidad
- Migración de datos
- Visibilidad del flujo de trabajo
- Capa de acceso a los datos
- Comprobabilidad general
- Etiqueta de despliegue
- Proliferación de pilas tecnológicas
Esperamos que nuestro resumen de estos retos sea útil para aquellos que están empezando a migrar a una arquitectura de microservicios, ya que ofrecemos una visión general de los problemas más comunes al abandonar el monolito, así como soluciones de alto nivel que hemos implementado o estamos implementando para resolverlos.
Cambios en los patrones de fiabilidad
Cuando se rediseña un sistema de un monolito a una arquitectura orientada a servicios (SOA), es habitual encontrarse con problemas de fiabilidad que pueden descontrolarse si no se abordan en una fase temprana del proceso. Estos retos se deben normalmente a la naturaleza intrínseca de trabajar con una SOA en lugar de con un monolito: cuando se trata del monolito, trabajar con lógica de negocio entre dominios es tan fácil como hacer una simple llamada a una función. Cuando se pasa a los microservicios, este tipo de interacción entre dominios se complica debido a que la comunicación de procesos internos se sustituye por RPC, lo que puede afectar a la latencia y la fiabilidad a menos que se establezcan las barandillas adecuadas.
El monolito de DoorDash era un único gran servicio respaldado por un único clúster Postgres como dependencia externa de acceso más frecuente. Mientras ambos componentes estén sanos, el sistema puede considerarse funcional en su mayor parte. Sin embargo, una vez que los microservicios entran en escena, comienzan a aparecer antipatrones como los siguientes:
- Dependencias fuertes, en las que los servicios se vuelven incapaces de satisfacer una petición del usuario, aunque sea parcialmente, a menos que uno o más servicios dependientes también funcionen.
- Servicios T0 (Golden Workflows), una nueva clase de servicios críticos para la lógica empresarial, hasta el punto de que cualquier interrupción en cualquiera de ellos provoca que la aplicación deje de estar disponible.
En los dos casos anteriores, el efecto neto es que los SLOde la plataforma, ya sea a nivel de servicio o de todo el sistema, pasan a estar sujetos a la probabilidad compuesta de tiempo de actividad: si dos servicios forman parte de los flujos de trabajo dorados (como el cumplimiento de pedidos y el servicio logístico de envío en la plataforma de DoorDash) y ambos tienen un tiempo de actividad del 99,9%, el tiempo de actividad del flujo de trabajo en sí pasa a ser del 99,8%:
Cuando demasiados servicios se enredan en una malla de fuertes dependencias, la plataforma se convierte en un monolito distribuido. Un monolito distribuido es estrictamente peor que un monolito normal en términos de fiabilidad, porque los fallos, incluso en flujos aparentemente sin importancia, pueden hacer caer todo el sistema.
Para evitar la proliferación de casos como el descrito y mitigar el efecto negativo de las dependencias entre servicios, introdujimos nuevos patrones de acceso para la comunicación entre servicios, como:
- Solicitud de reserva, en la que nos aseguramos de que existe una fuente alternativa de datos críticos en caso de que la fuente principal no esté disponible. De este modo, se aumenta la probabilidad de tiempo de inactividad en lugar del tiempo de actividad, lo que se traduce en una mayor disponibilidad. Por ejemplo, si la fuente principal de unos datos concretos tiene una disponibilidad del 99,9% y la fuente alternativa también tiene una disponibilidad del 99,9%, el tiempo de actividad de un servicio no se verá tan afectado, como se muestra en la Fórmula 2, a continuación.
- Fail open, si podemos servir parcialmente una petición cuando una dependencia no está disponible simplemente ignorando el fallo y aplicando algún comportamiento por defecto, habremos eliminado una dependencia fuerte de nuestro microservicio. Por ejemplo, si no podemos acortar una URL que deseamos enviar por SMS a un consumidor, utilizamos la URL completa en lugar de fallar la solicitud.
Con el ánimo de generalizar el uso de estos patrones, hemos introducido bibliotecas que se ocupan de ellos automáticamente, mediante recomendaciones del propietario del servicio. También hemos creado un campamento de fiabilidad en el que ayudamos a los nuevos desarrolladores a conocer los problemas más comunes de la creación de microservicios.
Migración de datos desde el almacenamiento monolítico
Al migrar a una arquitectura de microservicios, existe la expectativa de que cada servicio individual sea el propietario y la única fuente de verdad de sus datos. En el caso de DoorDash, el monolito no se limitaba simplemente al código de la aplicación, sino que nuestro almacenamiento de datos también era monolítico. En su mayor parte, nuestros datos existían en un único clúster Postgres que estaba alcanzando rápidamente sus límites en términos de escalabilidad vertical del nodo primario. Al descomponer la aplicación monolítica en microservicios, también tuvimos que realizar la extracción de datos del almacenamiento monolítico en varios clústeres.
La extracción de datos de una base de datos (BD) es un problema bien conocido en el sector y que no tiene fácil solución, sobre todo cuando la expectativa es no tener tiempo de inactividad durante la migración. Uno de los enfoques más comunes para las extracciones de datos es el uso de dobles escrituras y dobles lecturas, donde los servicios cambian gradualmente su tráfico de la instancia de base de datos monolítica a una nueva, tabla por tabla, como se muestra en la Figura 1, a continuación:
Para dar un resumen de alto nivel de cómo funciona, el enfoque consiste en escribir todas las filas nuevas de un conjunto específico de tablas tanto en la BD nueva como en la antigua, mientras que un trabajo de backfiller copia las filas antiguas. Una vez que hay suficiente confianza en la nueva BD, el tráfico de lectura se desplaza gradualmente a la nueva BD hasta que la antigua es finalmente desmantelada.
Aunque este enfoque funciona en la mayoría de los casos, en DoorDash encontramos algunas complejidades ocultas en forma de condiciones de carrera que pueden producirse en función de la especificidad de los patrones de acceso y los esquemas que se están migrando. De hecho, ninguna configuración puede propagarse instantáneamente a todos los componentes de un sistema distribuido, por lo que durante breves periodos de tiempo no había una única fuente de verdad para una fila o partición de filas determinada. En las tablas que exponen múltiples restricciones de unicidad, esto puede dar lugar a incoherencias o conflictos de datos que a menudo deben resolverse manualmente.
Además, este enfoque requeriría o bien una capa común de acceso a los datos para gestionar la doble lectura/escritura, o bien que todos los propietarios de los servicios realizaran algún trabajo servicio por servicio, lo que resultaría costoso. Una capa de acceso a datos común suele estar presente en un monolito, pero dependiendo del orden en que se extraigan los datos, respectivo a la extracción de la lógica de la aplicación, esto podría no ser cierto. En un momento en el que la proliferación de pilas era un problema, ya que los nuevos microservicios nacían más rápido de lo que se creaban los estándares de la empresa, optamos por un enfoque diferente pero exitoso: el intercambio atómico de una única fuente de verdad.
Este tema merece por sí solo un artículo, que publicaremos en el futuro. Y va a tocar montones de aspectos técnicos interesantes de los sistemas de gestión de bases de datos en general y de Postgres en particular.
Garantizar la visibilidad y observabilidad del flujo de trabajo
Una de las ventajas de ejecutar una aplicación monolítica es que, en la mayoría de los casos, existe una capa de middleware común que intercepta todas las llamadas y puede imponer todo tipo de funcionalidades comunes. Esto es muy útil porque puede controlar todo, desde la autenticación hasta la autorización, así como las métricas y el registro.
Cuando se ejecuta un monolito, las métricas son predecibles, los cuadros de mando pueden reproducirse fácilmente y se necesita un conocimiento mínimo del dominio para obtener un conjunto común de indicadores relevantes para los flujos de trabajo que sean útiles para crear nuevas mediciones entre dominios. Por ejemplo, los indicadores de nivel de servicio (SLI) pueden identificarse para todos los flujos de trabajo, ya que todos exponen las mismas métricas, con la misma denominación y etiquetas, lo que permite una definición más coherente de los SLO por flujo de trabajo.
En el frenesí de la extracción de microservicios, es fácil acabar en una situación en la que cada equipo adopta una pila tecnológica y versiones de bibliotecas diferentes, creando sus propias convenciones en torno a las métricas. Esta situación da lugar a que los equipos desarrollen su propio conocimiento tribal separado.
Cuando esto sucede, no sólo se hace difícil para los no propietarios de dominios entender las métricas de otros dominios, sino que a menudo surgen situaciones en las que es realmente difícil saber qué servicio está involucrado en un flujo de trabajo determinado. Esta ambigüedad hace que sea muy difícil identificar dependencias fuertes superfluas (como las definidas anteriormente) hasta que se produce una interrupción total.
Para resolver este problema de tribalismo de dominios, es importante hacer el esfuerzo inicial de especificar un estándar de observabilidad, un conjunto de recomendaciones para toda la empresa que definan lo que debe medirse en cada servicio, así como la denominación y el etiquetado. Además de esa norma, adoptar soluciones transparentes para la trazabilidad distribuida (a la OTEL) más pronto que tarde ahorra muchos quebraderos de cabeza a la hora de responder a preguntas como: "¿Por qué el aumento del tiempo de respuesta p99 de un determinado servicio provocó una enorme caída del tráfico de un servicio aparentemente no relacionado?".
A medida que el esfuerzo de estandarización se hace más sustancial, y nacen nuevos marcos internos, también es esencial incluir todo el conocimiento anterior en estos marcos para que las futuras generaciones de arquitectura puedan beneficiarse de ellos y obtener una vez más esa capa centralizada de control para la observabilidad de los extremos.
Construir una capa de acceso a los datos
Una vez más en este artículo, parece que vamos a alabar al monolito por todos sus componentes centralizados que pueden ser retocados por unos pocos ingenieros expertos de manera que beneficien a todos en la empresa. En este caso, nos referimos a la capa de acceso a datos del monolito, en la que incluso el cambio más pequeño puede resultar en enormes beneficios para todo el equipo. La capa de acceso a datos es un componente que suele encontrarse en las aplicaciones monolíticas y que intercepta todas las consultas a almacenes de datos externos, pudiendo ejecutar cualquier código personalizado para observar, optimizar y redirigir dichas consultas.
While it’s risky to have a single database cluster that holds all a company's data, it is actually good to have a single codebase that handles all the storage access. This centralized access layer can be used and tweaked to obtain things like:
- Observabilidad predecible de la consulta (descrita en la sección anterior)
- Almacenamiento automático
- Multiarrendamiento
- Enrutamiento automático primario/de réplica con funciones de lectura y escritura propias
- Optimización de consultas
- Agrupación de conexiones
- Control de los patrones de acceso subóptimos( ¿alguien quiereN+1?)
- Control de las migraciones de esquemas para tablas en línea (una simple palabra clave CONCURRENTLY puede marcar la diferencia entre una interrupción y una creación de índices sin problemas).
Para ser completamente justos, una de las ventajas de pasar a una arquitectura de microservicios es la posibilidad de experimentar con nuevas tecnologías de bases de datos que podrían encajar mejor que otras en un caso de uso específico. Pero, al fin y al cabo, existe la posibilidad de que la mayoría de los servicios de una organización de ingeniería utilicen tipos de bases de datos homogéneos. Y les vendría muy bien todo lo mencionado anteriormente.
Pasar de una capa de acceso a datos centralizada a un sistema distribuido es un problema que todavía está muy abierto en DoorDash, y también ampliamente debatido. Las posibles soluciones incluyen cosas como la creación de una herramienta de migración de esquemas centralizada, gestionada por el equipo de almacenamiento, que proporcione revisiones de código y pelusas que garanticen que las migraciones son seguras antes de que se ejecuten en producción. Además, los equipos de plataforma central y almacenamiento de DoorDash han invertido recientemente en una capa de acceso a datos centralizada en forma de pasarela de base de datos, que se despliega de forma aislada para cada clúster de base de datos y sustituye la interfaz SQL para microservicios por una API abstracta servida por una pasarela gRPC. Una pasarela de este tipo requiere muchas precauciones, como despliegues aislados, control de versiones y configuración, para garantizar que no se convierta en un punto único de fallo. La figura 4 muestra a grandes rasgos el aspecto de esta pasarela de datos.
Garantizar la comprobabilidad
Los ingenieros experimentados sentirán esa sensación de déjà vu cada vez que vean un entorno de staging caer en el olvido a una velocidad que es directamente proporcional al número de servicios heterogéneos que lo pueblan. La degradación del entorno de staging es un proceso que corre el riesgo de acelerarse durante el frenesí de la migración de microservicios: es un momento en el que los nuevos servicios son extremadamente fluidos y cambian continuamente, especialmente en staging, que a menudo no tiene los mismos SLO que se esperan de un entorno de producción, lo que acaba por dejarlo casi inutilizable. Los servicios con muchas dependencias agravan esta degradación.
Para superar este problema, las pruebas deben evolucionar junto con la arquitectura. Junto con la introducción de nuevos marcos de pruebas para las pruebas de integración, la supervisión sintética y las pruebas de carga, DoorDash ha iniciado recientemente el proceso de implantación de las barreras necesarias en su entorno de producción para permitir la realización de pruebas seguras en producción. Estas barandillas se basan en el principio de permitir a nuestros desarrolladores experimentar con nuevas funciones y correcciones de errores en producción sin riesgo de contaminar el tráfico real o, peor aún, los datos.
Este tema está ampliamente cubierto en la industria, y entrar en los detalles de lo que DoorDash está construyendo para que esto suceda probablemente merece su propio artículo. Por ahora, aquí está una visión general de alto nivel de los principales componentes y barandillas que conforman nuestro entorno de pruebas de producción:
- Proxies que redirigen el tráfico de prueba desde la periferia a entornos de desarrollo locales.
- Definición y propagación estandarizada del tráfico de prueba en cada salto de una solicitud a través del equipaje OpenTelemetry (OTEL)
- Aislamiento de datos en reposo mediante enrutamiento y filtrado de consultas en función de la tenencia del tráfico (aplicado por la capa de acceso a datos antes mencionada).
- Aislamiento de la configuración mediante el espaciado de nombres de nuestros experimentos, la configuración en tiempo de ejecución y los secretos, en función de la tenencia de tráfico (aplicada mediante bibliotecas comunes).
- Servicio de Actores de Pruebas que proporciona usuarios de pruebas a los desarrolladores
- Consola de desarrollo para gestionar los entornos de prueba y crear nuevos escenarios
Un objetivo importante del proyecto de pruebas en producción de DoorDash es que, una vez generado el tráfico de prueba, todas las barreras en torno a los datos de prueba se apliquen a nivel de plataforma/infraestructura sin necesidad de que los microservicios tengan conocimiento alguno. Al asegurarnos de que nuestros servicios son agnósticos para el arrendatario, evitamos la proliferación de ramificaciones específicas del arrendatario en la base de código de nuestros servicios que inevitablemente tendríamos de otro modo.
Hacer frente a la proliferación de pilas tecnológicas
Al recordar los retos a los que se enfrentó DoorDash en la construcción de la arquitectura existente, a menudo nos venía a la mente una respuesta sencilla: basta con construir una biblioteca común.
En el mundo monolítico, en el que todo se ejecutaba en una única base de código basada en el framework Django, era realmente fácil crear nuevas bibliotecas para uso común, así como actualizar las existentes cada vez que se publicaba una nueva función o una mejora de la seguridad. La idea de tener un único archivo de requisitos que actualizar para que todos los equipos se beneficiaran de él era reconfortante.
A medida que avanzábamos hacia los microservicios, los desarrolladores empezaron a experimentar con los lenguajes y soluciones que consideraban más adecuados para el problema en cuestión. Al principio, los servicios nacían utilizando diversos lenguajes, concretamente Python3, Kotlin, Java y Go. Por un lado, este fue un momento realmente bueno para la empresa: al adquirir experiencia práctica con múltiples lenguajes pudimos estandarizarnos finalmente en unas pocas tecnologías, y comenzamos nuestro esfuerzo de estandarización interna basado en Kotlin. Por otro lado, sin embargo, se hizo realmente difícil compartir código y añadir nuevas funcionalidades a nivel de servicio. Acomodar todas nuestras pilas diferentes ahora requería escribir bibliotecas para múltiples lenguajes, así como depender de la cadencia de despliegue de cada servicio para que los servicios pudieran recoger cualquier nueva versión de biblioteca que fuera necesaria.
Después de ese período inicial de experimentación, empezamos a construir frameworks y librerías soportados internamente para servicios greenfield, añadimos linting a todos nuestros repositorios para detectar dependencias que necesitan ser actualizadas, y comenzamos el esfuerzo de reducir el número de repositorios, manteniendo aproximadamente el mismo número de microservicios (algunas organizaciones están probando actualmente un monorepo por org). En su mayor parte, el equipo de la Plataforma Kotlin en DoorDash es responsable de liderar estos esfuerzos de estandarización, proporcionando a los desarrolladores las plantillas y marcos básicos que resuelven algunos de los problemas discutidos en las secciones anteriores de este artículo.
Definir el protocolo de despliegue
Hasta ahora nos hemos centrado en una serie de retos relacionados con la construcción de una arquitectura de microservicios que, en su mayoría, se basaban en el mismo principio: la base de código compartida de un monolito tiene algunas ventajas que los microservicios corren el riesgo de perder. Otro aspecto a considerar, sin embargo, es cómo las cosas que normalmente se perciben como una ventaja de alejarse de una arquitectura monolítica podrían en realidad ocultar algunos desafíos. Por ejemplo, la capacidad de desplegar libremente servicios independientes entre sí.
Con el monolito, los despliegues eran más predecibles: un único equipo de lanzamiento se encargaba de todos los despliegues, y una nueva versión de toda la aplicación se lanzaba al público con una cadencia regular. Sin embargo, la libertad de despliegue que ofrecen los microservicios puede dar lugar a la proliferación tanto de buenas como de malas prácticas, y estas últimas pueden causar distintos tipos de interrupciones de vez en cuando. Además, los tiempos de despliegue impredecibles pueden provocar retrasos en la respuesta de las llamadas a servicios relacionados, como las dependencias ascendentes.
Para mitigar estos problemas y establecer un protocolo de despliegue adecuado, el equipo de lanzamiento de DoorDash tuvo que dejar de ser el guardián del despliegue para crear herramientas de despliegue destinadas a hacer cumplir estas prácticas recomendadas, como avisar cada vez que se intenta realizar un despliegue en hora punta o proporcionar formas sencillas de deshacer el código con sólo pulsar un botón. Además, se han establecido interruptores de bloqueo globales para congelar todos los despliegues no aprobados en determinadas situaciones críticas, con el fin de evitar que desarrolladores inconscientes desplieguen código nuevo durante, por ejemplo, una interrupción del servicio. Por último, se han creado canalizaciones para implantar un registro de cambios global, que da visibilidad a todos y cada uno de los cambios que se producen en nuestro entorno de producción, desde el despliegue hasta los cambios de configuración en tiempo de ejecución. El registro global de cambios es un poderoso recurso en caso de interrupción, ya que permite a los ingenieros identificar la causa del problema y revertirlo rápidamente.
Lecciones aprendidas de la migración fuera del monolito
Después de discutir todos los puntos de dolor de dejar el monolito casi hace que uno se pregunte por qué lo hicimos en primer lugar. A pesar de todas las ventajas que un pequeño equipo de ingeniería podría obtener de trabajar en un monolito, las ventajas que vienen de pasar a microservicios son enormes y vale la pena resolver los puntos de dolor mencionados anteriormente. Obtenemos la capacidad de escalar y desplegar componentes individuales de forma independiente, reducir el radio de explosión de los despliegues erróneos, escalar como organización y experimentar con mayor rapidez.
Para beneficiarse de una arquitectura de microservicios, una organización debe abordar la extracción con cuidado. Después de todo, la pérdida de código comúnmente compartido puede dar lugar a comportamientos incoherentes entre los servicios para cosas como la visibilidad y el acceso a los datos, la libertad de despliegue puede ser perjudicial sin las barandillas adecuadas en su lugar, la proliferación de la pila tecnológica puede crecer fuera de control, la comprobabilidad puede ser más difícil, y los malos patrones en el establecimiento de dependencias de servicio pueden conducir a monolitos distribuidos.
En DoorDash nos enfrentamos a todos estos retos de una forma u otra, y aprendimos que invertir en estandarización, buenas prácticas, comprobabilidad y capas comunes de acceso/observabilidad de datos dará como resultado un ecosistema de microservicios más fiable y mantenible a largo plazo.