Clustering Tomcat

Avant d’entrer dans les détails, précisons la signification du terme cluster ici. Pour faire simple, il s’agit du fonctionnement de l’élément <Cluster>.

Lorsqu’on met en place un cluster de Tomcat, il faut avant tout gérer la répartition des requêtes HTTP avec Nginx, HAproxy ou Apache Web Server. Ensuite, pour assurer un bon niveau de tolérances aux pannes, il faut répliquer les sessions entre les différents noeuds du cluster. Cette partie est assurée par cet élément <Cluster> dont nous allons voir le fonctionnement.

Schéma d’ensemble d’un cluster Tomcat

Mise en place avec Docker Compose

L’installation de Tomcat en cluster est théoriquement simple. Il suffit d’ajouter un élément <Cluster> dans le <Engine> ou le <Host>.

<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"/>

Pour tester ça, j’ai utilisé Docker Compose avec 3 instances de Tomcat et un Nginx pour la répartition des requêtes HTTP.

services:
  tomcat1:
    image: tomcat:11
    volumes:
      - ./tomcat/conf/server.xml:/usr/local/tomcat/conf/server.xml
      - ./tomcat/msg.war:/usr/local/tomcat/webapps/msg.war
    environment:
      CATALINA_OPTS: "-XX:MaxRAMPercentage=75"
    deploy:
      resources:
        limits:
          memory: 512M
  tomcat2:
    extends: tomcat1
  tomcat3:
    extends: tomcat1
  nginx:
    image: nginx
    ports:
      - 1080:80
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf

Pour Tomcat, on passe deux fichiers en volume. Il y a le war de l’application déployée et le principal fichier de configuration server.xml.

Dans ce dernier, on ajoute juste deux bouts de configuration, la route et le cluster.

  <Engine name="Catalina" defaultHost="localhost">
    <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster">
    ...
  </Engine>

Pour Nginx, on a ajouté le proxy_pass et l'`upstream` dans le fichier default.conf.

upstream tomcat {
  server tomcat1:8080;
  server tomcat2:8080;
  server tomcat3:8080;
}

server {
  location /msg {
    proxy_pass http://tomcat/msg;
  }
  ...
}

Après avoir démarré l’ensemble avec docker compose up, on peut accéder à l’application sur l’adresse http://localhost:1080, et à chaque requête, on passe d’un Tomcat à l’autre.

Lors de mes premiers essais, j’ai quand même eu un problème de temps de démarrage. Une instance a démarré en 3 secondes alors que les autres ont mis 63 secondes. Pour comprendre et résoudre le problème, il faut entrer un peu plus dans les détails.

Configuration détaillée

En déclarant le cluster avec cette simple ligne, il faut être conscient qu’on met en place plusieurs éléments dans leur configuration par défaut.

Détail du composant Cluste de Tomcat

Cette architecture du composant cluster se retrouve lorsqu’on utilise une configuration détaillée du Cluster.

Gestionnaire de sessions

Le manager est le gestionnaire de sessions. Dans une configuration simple de Tomcat, chaque application a une instance de StandardManager pour la gestion du cycle de vie de ses sessions. Lorsqu’on passe en mode cluster, c’est un DeltaManager, qui est capable de créer des sessions locales et d’échanger des sessions avec ses pairs.

Lorsqu’un manager sait qu’un autre membre est présent dans le cluster, il lui envoye une demande de sessions. S’il ne reçoit rien, il se met en attente jusqu’au timeout et retarde le démarrage. C’est ce qu’il se passe avc notre configuration et apparait dans les logs ci-dessous.

tomcat2-1  | 12:00:13.182 INFO DeltaManager.getAllClusterSessions
    Manager [localhost#/msg], requesting session state from [MemberImpl[tcp://{172, 21, 0, 2}:4000].
    This operation will timeout if no session state has been received within [60] seconds.
tomcat1-1  | 12:00:13.264 WARNING DeltaManager.waitForSendAllSessions
    Manager [localhost#/msg]: In reply to the 'Get all session data' message sent at [11/20/25, 10:59 PM],
    a 'No matching context manager' message was received after [112] ms.
tomcat1-1  | 12:00:13.265 WARNING DeltaManager.getAllClusterSessions
    Manager [localhost#/msg]: Drop message [SESSION-GET-ALL] inside GET_ALL_SESSIONS
    sync phase start date [11:59 AM] message date [11:59 PM]

Dans notre configuration, comme on sait qu’on démarre les instances de Tomcat en même temps, on doit éviter cette attente. La solution rudimentaire consiste à démarrer les instances de Tomcat les unes après les autres, en déclarant un dépendance entre les services.

  tomcat1:
    image: tomcat:11
    ...
  tomcat2:
    extends: tomcat1
    depends_on: [tomcat1]
  tomcat3:
    extends: tomcat1
    depends_on: [tomcat2]

L’inconvénient, c’est que ça augmente le temps de démarrage de l’ensemble puisqu’il n’y a plus rien en parallèle. Un autre solution consiste à désactiver le timeout de l’élément Manager, dans le Cluster.

  <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster">
    <Manager className="org.apache.catalina.ha.session.DeltaManager"
              stateTransferTimeout="0" />
  </Cluster>

BackupManager

Une troisième solution serait de passer à un BackupManager au lieu du DeltaManager. Avec lui, chaque session n’est répliquée que sur un seul autre noeud du cluster. Et il n’essaie pas de récupérer d’autres sessions au démarrage.

  <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster">
    <Manager className="org.apache.catalina.ha.session.BackupManager" />
  </Cluster>

Son terrain de prédilection est le cluster large, au delà de 4 instances, et la résolution du problème au démarrage n’est qu’un effet de bord. Et il a quand même un point faible, comme chaque session n’a qu’un seul replica, si deux noeuds tombent en même temps, on perd des sessions.

Dans mes essais, j’ai aussi eu des grosses instabilités avec des événements d’apparition et de disparition de membre toutes les 25 secondes, qui l’ont rendu inutilisable.

Multicast vs TCP

Dans la configuration par défaut, les instances de Tomcat détectent leur présence en multicast. Avec notre réseau local, ça fonctionne bien, mais sur un vrai réseau, les admins n’aiment pas trop ça parce que ça parle trop. Nous allons donc modifier la configuration pour passer en connexions TCP/IP.

Les composants du GroupChannel sont concernés par cette modification, et plus particulièrement le Membership. Il communique en multicast pour découvrir d’autres membres sur la même adresse et le même port de multicast (228.0.0.4:45564, par défaut). Chaque instance envoie à cette adresse sa propre adresse IP et le port sur lequel écoute son Receiver (4000 par défaut). Les communications entre chaque Sender et les Receiver des autres instances se font en TCP/IP.

Pour éviter le multicast, il faut donc changer le service de membership et basculer vers un fonctionnement statique. Avec le StaticMembershipService, chaque instance est identifiée dans la configuration.

<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster">
  <Channel className="org.apache.catalina.tribes.group.GroupChannel">
    <Membership className="org.apache.catalina.tribes.membership.StaticMembershipService">
      <Member className="org.apache.catalina.tribes.membership.StaticMember"
              host="tomcat1" port="4000"
              uniqueId="{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1}" />
      <Member className="org.apache.catalina.tribes.membership.StaticMember"
              host="tomcat2" port="4000"
              uniqueId="{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2}" />
      <Member className="org.apache.catalina.tribes.membership.StaticMember"
              host="tomcat3" port="4000"
              uniqueId="{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3}" />
    </Membership>
  </Channel>
</Cluster>

Au passage, le démarrage est bien plus rapide, on passe d’un peu plus de 3 seconde à une grosse seconde (1.2 s lors de mes tests).

Par contre, ça limite le fonctionnement dynamique pour l’ajout d’instance en cours de route. Il semble toujours possible d’ajouter une instance sans modifier les instances existantes. Pour ça, la nouvelle instance doit avoir la liste de tous les autres membres du cluster. Mais cette configuration est instable, avec beaucoup d’événement memberDisappeared (toutes les 25 secondes). Pour stabiliser la nouvelle topologie, il faut redémarrer tous les membres avec une liste complète.

Cluster dans le cloud

Aucune des deux solutions présentées (multicast et static) n’est satisfaisante dans un environnement dynamique comme un cloud ou kubernetes. Pour moderniser notre cluster Tomcat il faut utiliser CloudMembershipService, qui existe depuis Tomcat 9. Il n’apparaît pas dans la documentation mais on trouve un peu d’information dans la javadoc.

Pour l’utiliser avec Docker Compose, on va le coupler avec le DNSMembershipProvider. Pour Kubernetes, on pourrait utiliser le même provider ou KubernetesMembershipProvider.

Pour cet exemple, nous avons un environnement composé d’un Nginx et de 3 replicas identiques de Tomcat.

services:
  nginx:
    image: nginx
    ports:
      - 1080:80
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on: [tomcat0]
  tomcat0:
    image: tomcat:11
    volumes:
      - ./tomcat/conf/server.xml:/usr/local/tomcat/conf/server.xml
      - ./tomcat/msg.war:/usr/local/tomcat/webapps/msg.war
    environment:
      CATALINA_OPTS: "-XX:MaxRAMPercentage=75"
      DNS_MEMBERSHIP_SERVICE_NAME: "tomcat0"
    deploy:
      replicas: 3

La variable DNS_MEMBERSHIP_SERVICE_NAME est utilisée par le DNSMembershipProvider pour rechercher les instances par leur nom de service. Sans cette variable, il recherche un service nommé tomcat.

La configuration de Tomcat ressemble à ça:

<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster">
  <Channel className="org.apache.catalina.tribes.group.GroupChannel">
    <Membership className="org.apache.catalina.tribes.membership.cloud.CloudMembershipService"
                membershipProviderClassName="org.apache.catalina.tribes.membership.cloud.DNSMembershipProvider"/>
  </Channel>
</Cluster>

Du coté de Nginx, on ne déclare plus qu"un server, la rotation sera assurée par le DNS de Docker.

upstream tomcat {
  server tomcat0:8080;
}

La même configuration peut fonctionner avec Docker Compose, Swarm ou Kubernetes.

Sécurité

Afin de mitiger le risque de MITM et d’injection, on peut mettre en place le chiffrement des messages.

<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster">
  ...

  <Interceptor className="org.apache.catalina.tribes.group.interceptors.EncryptInterceptor"
                encryptionKey="AES is the way"/>
  <Interceptor className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector"/>
  <Interceptor className="org.apache.catalina.tribes.group.interceptors.MessageDispatchInterceptor"/>
</Cluster>

Ceci ne réduit pas les risques d’attaque par DoS.

Affinité de session

En production, du moment que l’application est stateful, et même avec une bonne réplication de session, il vaut mieux utiliser l’affinité.

La technique historique est de déclarer une jvmRoute. Elle est utilisée pour suffixer l’identifiant des sessions, afin que le répartiteur puisse réorienter chaque session sur l’instance qui l’a créée.

<Engine name="Catalina" defaultHost="localhost" jvmRoute="${tomcat.route}">
  ...
</Engine>

Dans cet exemple, on utilise les capacités de substitution de la configuration. Il faut renseigner la propriété système tomcat.route.

  tomcat1:
    image: tomcat:11
    volumes:
      - ./tomcat/conf/server-static.xml:/usr/local/tomcat/conf/server.xml
      - ./tomcat/msg.war:/usr/local/tomcat/webapps/msg.war
    environment:
      CATALINA_OPTS: "-XX:MaxRAMPercentage=75 -Dtomcat.route=tct1"
  tomcat2:
    extends: tomcat1
    environment:
      CATALINA_OPTS: "-XX:MaxRAMPercentage=75 -Dtomcat.route=tct2"
  tomcat3:
    extends: tomcat1
    environment:
      CATALINA_OPTS: "-XX:MaxRAMPercentage=75 -Dtomcat.route=tct3"

Enfin, on exploite la route au niveau du répartiteur de requêtes. Apache httpd, avec mod_proxy ou mod_jk, utilisait cette notion de route. Avec Nginx, il faut l’extension jvm_route qui n’est pas implantée dans la version gratuite. Et cette technique ne fonctionne plus si les instances de Tomcat sont des replicas d’un même service.

Nginx peut se passer de route avec ip_hash qui organise la répartition des requêtes en se basant sur le hash de l’adresse IP du client.

upstream tomcat {
  ip_hash;
  server tomcat1:8080;
  server tomcat2:8080;
  server tomcat3:8080;
}

Synthèse

La configuration par défaut fonctionne bien sur un petit réseau local, avec des instances de Tomcat qui ne démarrent pas en même temps.

Le problème de démarrage a été résolu en configurant le DeltaManager avec stateTransferTimeout="0".

Le problème de réseau a été résolu en désactivant la détection de membres par multicast, grâce au StaticMembershipService ou au CloudMembershipService.

La sécurité peut être renforcée grâce au EncryptInterceptor.