De dependència compartida a microservei: quan deixar d'importar codi comú

Teníem un assistent conversacional funcionant en un projecte i el vam voler fer servir en els altres tres. El vam empaquetar com a dependència de Python i cada projecte l'importava. Durant uns mesos va semblar la decisió correcta. Va deixar de ser-ho tan bon punt el vam haver d'actualitzar.

Servei propi — demostració de capacitat tècnica, no producte amb clients

El teu equip té un càlcul lent o una capa de dades que ningú no vol tocar?

En parlem
Tests a la suite
126 → 142
Atacs creuats
1211 bloquejats en auditar
Productes servits
4un sol desplegament

Què es trenca quan comparteixes codi entre projectes?

El problema no era el codi: era que el codi havia nascut per a un sol projecte.

En empaquetar-lo com a dependència, cada producte va acabar amb una versió diferent instal·lada. Cada canvi obligava a reconstruir les imatges de tots. I el pitjor: actualitzar-lo per a un projecte el trencava en un altre, perquè les decisions que duia a dins estaven preses pensant en el cas original, no en un servei comú.

Un paquet compartit nascut d'un cas concret no és reutilitzable. És el mateix cas concret, ara instal·lat en quatre llocs.

Per què no publicar el paquet i prou?

Va ser el primer que vam considerar: pujar-lo a un índex de paquets, versionar-lo com cal i que cada projecte fixés la seva versió. El vam descartar per dues raons.

La primera és que resolia la sincronització però no el problema de fons. Continuaria sent codi dissenyat per a un cas, executant-se dins de quatre processos diferents.

La segona és de dimensionament: publicar un paquet té sentit quan el consumeixen tercers. Aquí només el fem servir nosaltres, en quatre productes. Muntar aquesta cerimònia és infraestructura que hauríem de mantenir sense necessitar-la. Amb un repositori propi i un servei desplegat tenim la mateixa garantia de versió única amb menys peces.

El problema que no era de codi: una sola base vectorial

Aquí hi ha la part que no vam veure al principi.

L'assistent respon amb RAG sobre el contingut de cada producte, i això vol dir embeddings. En el disseny original hi havia una sola base vectorial, la del projecte per al qual va néixer. Servia per a aquell chatbot i per a cap altre: posar-hi el contingut dels altres hauria fet que cadascun respongués amb informació de la resta.

Era el mateix error que el del paquet, una capa més avall. Una cosa pensada per a un cas, forçada a servir-ne quatre.

Amb el microservei, cada projecte té la seva pròpia base vectoritzada i els seus propis paràmetres de chatbot. El servei és comú; les dades i la configuració, de cadascun. Aquesta separació és el que fa l'assistent reutilitzable de debò — no el fet de viure en un repositori a part.

Què ens va costar el canvi?

Una tarda sencera de reconfigurar la infraestructura de producció. I a canvi vam acceptar tres costos permanents.

Un salt de xarxa on hi havia una crida a funció: el que era instantani passa a dependre que un altre servei contesti.

Un punt únic de fallada. Abans, si l'assistent fallava, fallava en un projecte. Ara els quatre depenen del mateix servei. Per això la degradació és part del disseny i no un pedaç: quan l'assistent no respon, la pàgina carrega sencera i el xat mostra un missatge que demana tornar-ho a provar més tard. Mai un error cru ni un indicador de càrrega infinit. Un servei compartit cau alguna vegada, i el que veu l'usuari en aquell moment decideix si l'aplicació sembla trencada o sembla ben feta.

I un desplegament més per mantenir, amb el seu propi cicle de versions. A canvi, actualitzem un repositori i el canvi arriba als quatre productes: sense reconstruir imatges, sense tocar quatre repos, sense descobrir tres setmanes després que un s'ha quedat enrere.

Com sabem que un projecte no pot llegir les dades d'un altre?

Perquè ho hem comprovat atacant-lo, no llegint el codi.

La identitat del projecte es deriva de la credencial, mai de la petició. Cada inquilí s'autentica amb el seu propi secret i el servei dedueix de quin es tracta comparant-lo; no hi ha cap capçalera, paràmetre ni camp del cos que el pugui alterar. Verificat per quatre vies diferents: capçalera d'inquilí, camp al cos, identificador de sessió aliè, i les tres combinades.

L'aïllament del contingut és per destinació, no per filtre. El servei no emmagatzema embeddings de ningú: consulta el backend del mateix projecte, amb una URL i un token de sortida diferents per a cadascun. No hi ha una base comuna amb una condició que es pugui oblidar en alguna ruta de consulta.

Un intent creuat retorna 404, no 403. Un 403 confirmaria que el recurs existeix. És una diferència d'un dígit i decideix si un atacant pot enumerar.

I una precisió que convé fer encara que resti: sessions i missatges comparteixen taula, separats per columna. El que està físicament separat és el magatzem vectorial, que és on viu el contingut del client.

El que vam trobar en auditar-lo: la cadena buida

Vam demanar una auditoria d'aïllament amb una instrucció explícita: «digues-me la veritat encara que sigui incòmoda, un "no està garantit" em serveix igual». Dotze atacs creuats. Onze van rebotar. Un no.

Amb un token heretat de l'època d'un sol projecte, el llistat de converses retornava les de tots els projectes. No el contingut dels missatges, però sí els seus títols — i els títols els genera el model a partir de la primera pregunta de l'usuari, així que eren contingut, no metadades.

La causa cabia en una línia: el filtre per projecte era condicional. Si el projecte era la cadena buida, la condició era falsa i la consulta sortia sense filtrar.

Detall que explica per què no va saltar abans: hi havia 126 tests en verd, i entre ells un que comprovava el llistat i un altre que comprovava el token heretat. Cada meitat de l'error estava coberta. La seva intersecció, no.

La causa real no era la línia. Era que la cadena buida significava dues coses alhora: "sense projecte" i, per omissió del filtre, "tots els projectes". Un valor que representa absència d'identitat ha de tancar una consulta, no obrir-la.

Per què l'arranjament no va ser denegar?

La sortida òbvia era "sense projecte, denega". Era l'equivocada, i es va veure abans d'escriure codi: els dos consumidors vius tenien el 100 % de les seves converses sota aquest valor. Denegar els hauria deixat tots dos sense llistat.

L'arranjament va ser treure-li el caràcter especial: filtrar sempre, sense branques, de manera que la cadena buida passi a ser un espai de noms ordinari i tancat — les sessions de qui no té projecte, i res més. Sense comodins i sense trencar res a ningú.

Els tests es van escriure primer i en vermell. Un d'ells deriva les operacions per introspecció en lloc d'enumerar-les, així que qualsevol operació futura hereta la regla sense que ningú s'hagi de recordar d'afegir-la a una llista. De 126 tests a 142, cap de trencat, i els dotze atacs originals tornats a executar sense tocar-los.

Quan aplicaria això i quan no?

Sí, quan un codi compartit va néixer per a un cas concret i vas per la segona adaptació forçada.

No, si només hi ha dos consumidors i el codi és estable: un paquet versionat costa molt menys que un desplegament.

La pregunta que ho decideix no és quants projectes el fan servir, sinó si el codi compartit té estat o configuració pròpia de cada consumidor. Si en té, tard o d'hora necessita ser un servei. Si no, n'hi ha prou amb un paquet.

Un valor sentinella que significa "absència" acaba significant "tots" tan bon punt algú el posa en una condició. La solució no és prohibir-lo: és que deixi de ser especial.

Tecnologies

  • Django
  • Django REST Framework
  • Ports i adaptadors
  • Base vectorial per projecte
  • Inferència sobre model local
  • Federació contra el backend de cada projecte