Cómo cambiamos la configuración de 300 millones de jobs en runtime
Uno de los primeros programas computacionales funcionaba usando cintas perforadas para ingresar datos e instrucciones. Las cintas almacenaban datos y el “tape reader” o lector de cinta leía los datos almacenados en los papeles perforados.
Imagínate que quisieras, sin apagar el tape reader que está leyendo el código, intercambiar un papel por otro y que todo siga funcionando bien. Tendrías que tener mucho cuidado, una motricidad fina envidiable, pero aún así podría salir mal. La otra alternativa sería apagar la máquina, cambiar la cinta y volver a leer todo el papel de nuevo, aunque esa opción no es muy eficiente.
En Fintoc nos tocó enfrentarnos a algo parecido.
Cuando queremos mejorar algoritmos que clasifican datos, subimos versiones “alternativas” al algoritmo y solo pasamos una parte del flujo. Generamos alertas y mediciones y luego de comparar intercambiamos el algoritmo original por el algoritmo alternativo.
Para hacer las pruebas más eficientes, dejamos algunos parámetros configurables en los algoritmos. Así podemos cambiarlos para experimentar distintas configuraciones y ver si hay mejoras.
Acá te cuento cómo cambiamos en runtime la configuración de nuestros 300 millones de jobs mensuales sin apagar ningún producto ni hacer deploy.
Lo primero: definir variables de configuración
Este proyecto fue inicialmente motivado por la necesidad de implementar feature flags. Es decir, poder prender y apagar cierto flujos de código en producción. Rápidamente, esta necesidad se extendió a querer modificar ciertos parámetros, que ahora tratamos como variables de configuración.
Con variable de configuración nos referimos a variables un tanto “arbitrarias” que necesitamos para que nuestros sistemas funcionen bien. Por ejemplo: esperarX
minutos antes de enviar una notificación, excluir a una lista deY
usuarios de un proceso automático, tiempo máximo de pollingZ
antes de tirar un timeout.
Primera aproximación
Hay varias formas de hacer esto. La primera y más simple es dejarlas en el mismo código.
La manera de usarlo sería en un archivo de constantes:
class Mx::Constants
VAT = T.let(0.16, Float)
end
Luego, en el archivo donde queremos usar la constante:
...
sig { returns(Float) }
def vat
return Cl::Constants::VAT if amount_currency.casecmp('CLP')&.zero?
return Mx::Constants::VAT if amount_currency.casecmp('MXN')&.zero?
raise "Unable to find VAT value from currency #{amount_currency}"
end
...
Pros:
✅ Muy fácil de usar por parte de los devs, solo hay que definir la variable y usarla.
✅ El acceso al valor de la variable es muy rápido, ya que es simplemente leer el valor de una constante.
✅ Hay trazabilidad de los cambios que se hicieron en el historial de Git.
Cons:
❌ Es necesario hacer un PR para cambiarla, lo que quita tiempo por lo menos a un dev y un reviewer.
❌ Es necesario pasar por todo el flujo de deploy a producción para aplicar los cambios.
Para variables que no se modifican a menudo, esta solución podría bastar. Sin embargo, para valores que cambian más seguido, encontramos que esta opción era demasiado lenta para el ritmo que necesitábamos y decidimos seguir buscando alternativas.
Otra opción (y la que veníamos haciendo hasta ahora): variables de entorno
Usamos un secret manager que sirve para guardar y utilizar de manera segura variables confidenciales (secretos), como contraseñas, llaves privadas, etc. La manera en que funciona es que puedes definir variables que pueden ser cargadas como variables de entorno.
Luego, en el código puedes acceder a estas variables sin que sus valores aparezcan en ningún lugar del código. Además, se puede configurar que apenas se haga un cambio de una variable se gatille un redeploy de tu aplicación, permitiendo que se actualicen las variables de entorno.
Siguiendo el mismo ejemplo anterior, la manera de usar un secreto sería:
class Mx::Constants
VAT = T.let(ENV.fetch('MX_VAT', '0.16').to_f, Float)
end
y
...
sig { returns(Float) }
def vat
return Cl::Constants::VAT if amount_currency.casecmp('CLP')&.zero?
return Mx::Constants::VAT if amount_currency.casecmp('MXN')&.zero?
raise "Unable to find VAT value from currency #{amount_currency}"
end
...
La función ENV.fetch
recibe dos parámetros: el nombre de la variable que hay que ir a buscar en las variables de entorno y un valor default que se debe usar en caso de no encontrar la variable.
Pros:
✅ Es fácil de usar para los devs (solo tienes que saber el nombre de la variable).
✅ El acceso al valor de la variable es muy rápido. Durante el deploy leemos las variables de entorno y guardamos las variables relevantes en constantes.
Cons:
❌ Por seguridad, pocas personas tienen acceso al secret manager. Si un dev necesita cambiar una configuración, tiene que pedirle ayuda a alguien con permisos (generalmente alguien del equipo de infraestructura).
❌ Es necesario hacer un re-deploy para que se apliquen los cambios. Esto es mucho mas rápido que en el caso anterior, pero igual se puede demorar varios minutos.
El secret manager se puede configurar para que gatille un redeploy al cambiar una variable. Esto es necesario para actualizar las variables de ambiente y que el código pueda fetchear los valores actualizados.
Como ya estábamos usando el secret manager, el esfuerzo de implementación era muy bajo y nos servía para avanzar rápido. Tuvimos que hacer un trade-off y nos fuimos por esta solución “temporal” (que terminó durando casi 3 años).
Esta opción nos sirvió para escalar muchísimo, tanto así que hoy llegamos a procesar sobre 5 millones de requests al día en nuestra API. Sin embargo, a medida que el equipo y la cantidad de variables de configuración fue creciendo, los problemas de este sistema se fueron haciendo cada vez mas notorios.
Se volvió común que los desarrolladores tuviéramos que esperar al equipo de infraestructura para modificar o ver alguna variable definida en el secret manager. Esto entorpecía el trabajo de los devs y le quitaba tiempo al equipo de infraestructura, por lo que llegó el momento de revisar estos problemas.
Desafios técnicos a considerar para el problema
Necesitamos un sistema que conservara los puntos buenos de la solución actual:
✅ Fácil de usar por parte de los devs.
✅ Acceso rápido a las variables.
Pero debe resolver los principales dolores de la solución actual:
❌ Los desarrolladores deben poder ver, crear, editar o eliminar los valores de las variables de configuración.
❌ El cambio de una variable de configuración debe ser aplicado lo más rápido posible.
Además, hay otro requisito del que las soluciones anteriores no necesitaban preocuparse: las variables de configuración deben mantenerse consistentes durante la ejecución de un job.
Veámoslo en este código:
element_array.each do |element|
if condition_is_met(element, ENV_CONFIG_THAT_AFFECTS_CONDITION)
do A
else
do B
end
end
Sería muy peligroso que el valor de ENV_CONFIG_THAT_AFFECTS_CONDITION
cambie durante la ejecución del loop. Algunos elementos serían evaluados con una condición y otros con otra, lo que podría llevar a comportamientos inesperados que serían muy difíciles de revisar.
Un desarrollador trabaja bajo el supuesto que la variable va a tener un único valor y no debería tener que hacerse cargo de qué puede pasar si su valor cambia a mitad de camino en un mismo proceso.
Primera aproximación: usar la base de datos
En Fintoc, para desarrollar proyectos que puedan ser complejos —o que no estamos del todo convencidos de la solución—, escribimos un documento que llamamos Engineering Review Documents (ERD) donde detallamos la solución que queremos implementar.
La primera aproximación que quería proponer en el ERD era usar la base de datos para coordinar entre diferentes servicios los valores de las variables de configuración.
El principal foco era la usabilidad para otros devs. Queríamos que fuese fácil para los desarrolladores ver y editar los valores de las configuraciones.
En Fintoc usamos un backoffice o admin donde puedes interactuar con la aplicación en tiempo real para ver o editar campos, iniciar procesos como la facturación de clientes y mucho más. Por eso, decidimos usarlo para mostrar la configuración de variables para los devs del equipo y que ahí puedan crear o editar la configuración de la app.
Y para mantener la experiencia de desarrollo consistente que teníamos usando el secret manager, se implementó un fetcher (EnvironmentConfig::Fetcher.new("VALUE", 0.16)
) muy parecido al anterior (ENV.fetch(”VALUE”, 0.16)
) que obtiene el valor desde la base de datos.
class Mx::Constants
VAT = T.let(EnvironmentConfig::Fetcher.new('MX_VAT', 0.16), EnvironmentConfig::Fetcher)
end
...
sig { returns(Float) }
def vat
return Cl::Constants::VAT.fetch_as_float if amount_currency.casecmp('CLP')&.zero?
return Mx::Constants::VAT.fetch_as_float if amount_currency.casecmp('MXN')&.zero?
raise "Unable to find VAT value from currency #{amount_currency}"
end
...
Con esta primera aproximación logramos:
✅ Fácil de usar por parte de los devs. La manera de definir el fetcher es muy parecida a la manera de obtener una variable de entorno. Además, solo hay que agregar un .fetch()
al momento de usar la variable.
✅ Modificable por devs. Hay una vista en nuestro admin en que cualquier dev puede ver y modificar los valores de las variables de configuración.
✅ Cambios de configuración son aplicados rápidamente. Apenas se modifica el valor de una variable de configuración en la base de datos, este valor queda disponible para cualquier fetcher. i.e el cambio es instantáneo, sin necesidad de hacer un re-deploy.
Pero aún quedaban algunos problemas:
❌ Acceso rápido a las variables. Cada vez que el código necesita usar una variable de configuración, tiene que hacer una query a la base de datos. Incluso agregando índices para acelerar la operación, sigue siendo muy lento.
❌ Consistencia de variables a nivel de job. No hay nada que impida que se actualice el valor de una variable en la mitad de un job.
Acceso rápido a las variables
Para mejorar el performance de la solución decidimos usar Redis para guardar los valores de configuración. Con esto nos evitamos hacer queries a la base de datos y permitimos a la solución escalar mucho mejor.
Para esto, lo que hicimos fue guardar los valores en nuestra base de datos y mantener una copia de estos valores en Redis.
Cada vez que se crea o modifica una variable en la base de datos, hacemos la actualización en Redis. Como ahora debemos mantener dos bases de datos sincronizadas, agregamos un job recurrente que se encarga de revisar que las variables definidas en Redis tengan los mismos valores que las variables definidas en la base de datos. Lo hacemos como una medida extra de seguridad por si un error inesperado permitiera que solo se actualizara una de las bases de datos en la modificación desde el admin.
Y para evitar la query a la base de datos por cada configuración, lo que hicimos fue leer los datos desde Redis directamente (leer un dato en Redis toma muy poco tiempo).
Caché a nivel de job: cómo mantener la consistencia en cada proceso
Ahora que estamos leyendo el valor en runtime y estos pueden cambiar, podría pasar que un valor que se está usando en un proceso cambie repentinamente. Así que agregamos una caché para cada proceso que corre en los servidores de Fintoc.
Cada proceso guarda una copia de los valores fijos con los que se ejecutó y así se asegura de que todo el proceso use esos valores (en vez de los que están escritos en Redis).
La caché se borra cada vez que inicia un job.
Si se necesita usar el valor de alguna configuración, el fetcher revisa la caché. Si el valor está, lo usa directamente, y si no está lo trae desde Redis y se guarda el valor para que sea exactamente el mismo si se necesita de nuevo.
Esto tiene dos beneficios:
- Primero, mejoramos la velocidad de acceso a las variables de configuración. Solo la primera consulta hace un fetch a Redis, cualquier consulta subsiguiente va a consultar la constante definida en la caché.
- Segundo, con esto arreglamos el problema de consistencia: un job siempre va a usar un único valor de cada variable, aunque el valor en Redis se actualice durante su ejecución.
Para implementar una caché para los distintos repositorios que tenemos tuvimos que hacer dos implementaciones: una en Typescript y otra en Ruby.
Caché en Typescript
Por lógica de nuestro código, cada job en este repositorio se ejecuta en su propio proceso. O sea, tiene su propio espacio de memoria distinto al de cualquier otro job que se esté ejecutando al mismo tiempo que él.
Entonces, lo que hacemos para implementar la caché es definir un singleton, el que actúa como caché durante la ejecución del job:
// Cache definition
export class Current {
private static instance: Current;
public cache: Record<string, Nullable<string>>;
private constructor() {
this.cache = {};
}
public static getInstance(): Current {
if (!Current.instance) {
Current.instance = new Current();
}
return Current.instance;
}
public async fetch(variableName: string): Promise<Nullable<string>> {
if (!(variableName in this.cache)) {
await this.fetchRedisValue(variableName);
}
return this.cache[variableName];
}
public async fetchRedisValue(variableName: string): Promise<void> {
const redisGetAsync = promisify(redisClient.get).bind(redisClient);
const variableValue = await redisGetAsync(
`environment_config:${variableName}`,
);
this.setCacheValue(variableName, variableValue);
}
public setCacheValue(
variableName: string,
variableValue: Nullable<string>,
): void {
this.cache[variableName] = variableValue;
}
public async clearCache(): Promise<void> {
this.cache = {};
}
}
Un job funcionaría así:
- Se inicia un nuevo job dentro de algún proceso
- El job llama al singleton
Current
y usa el métodoclearCache
para limpiar la caché - El job se ejecuta, poblando la caché en el proceso y permitiéndole reutilizar los valores fetcheados de Redis
Como cada job corre en su propio proceso y como cada proceso tiene su propio espacio de memoria, ningún job puede acceder a la misma instancia del singleton al mismo tiempo. Por lo tanto, cada job puede cachear las variables que usa, manteniendo la consistencia de las configuraciones, pero sin permitir un overlap de cachés entre jobs que podría provocar problemas inesperados.
La magia de Ruby
Nuestra app principal está escrita usando Ruby on Rails.
En Rails ya existía una forma elegante de solucionar esto. Utilizamos la clase CurrentAttributes para la caché. Esta clase nos permite crear un Singleton a nivel de thread, el cual se resetea entre requests. Podemos querer usar la caché en dos contextos:
- En el webserver, donde estamos procesando requests a nuestra API o de nuestro backoffice. Cada vez que llega un request, se resetea la instancia del singleton del thread correspondiente.
- En el worker. En Fintoc, todos los jobs se heredan de una misma clase base. Gracias a esto, podemos configurar la caché dentro de la clase base. Lo que hacemos es configurar la caché para cada job cuando se inicializa.
El singleton que usamos para la caché se define de la siguiente forma: