Déployer une application symfony dans un cluster
Par Francis Besset le mercredi 24 février 2010 - Symfony | Lien permanent

Lors du Symfony Live 2010, j'ai pu assister à la conférence de Kris Wallsmith qui nous a présenté la possibilité de déployer une application symfony au sein d'un cluster.
De plus, à la suite du commentaire de Quiche, je me suis engagé à faire cet article.
Il est vrai qu'à première vu ce n'est pas chose aisé, et pourtant. Je vais tenter de vous présenter les différents mécanismes mis en place pour en arriver à notre fameux déploiement sur cluster.
Tout d'abord lors du commencement de sa présentation, Kris nous a montré quelques lignes de son CV, puis il est en est venu à présenter Nebul.us qui est en quelque sorte le cousin de Facebook. L'application est déployée sur plusieurs serveurs web, plusieurs serveurs SQL et des serveurs de fichiers pour les uploads directement mis en place par le service S3 d'Amazon.
Voici à quoi va ressembler grosso modo notre cluster :

Nous avons là un répartiteur de charge. C'est sur lui que toutes les requêtes HTTP vont arriver. Son rôle est de connaître l'état de chaque serveur Web afin de savoir quel est le serveur le moins chargé pour lui transmettre les requêtes.
Nous avons ensuite les serveurs web qui détiennent notre application symfony. Ils interrogeront nos serveurs SQL.
Par ailleurs, remarquez qu'il n'y a qu'un maître et des esclaves. On accèdera au maître uniquement pour faire des requêtes de modifications sur la base de données tel que INSERT, UPDATE et DELETE. Les esclaves se chargeront de demander au maître quelles ont été les requêtes SQL qui ont été effectuées, afin que ceux-ci soient à jour, ceci se nomme la réplication. Les esclaves serviront uniquement à la lecture avec notamment des requêtes de type SELECT.
La configuration ci-dessus permet d'avoir de très bonnes performances en lecture sur la base de données.
Maintenant que le décor est planté, nous allons nous attaquer à la configuration de notre application symfony.
Nous allons commencer par configurer le fichier databases.yml :
all:
master:
class: sfDoctrineDatabase
param:
dsn: mysql:dbname=informathic;host=master.informathic.com
username: informathic
password: changeThis
sql1:
class: sfDoctrineDatabase
param:
dsn: mysql:dbname=informathic;host=sql1.informathic.com
username: informathic
password: changeThis
sql2:
class: sfDoctrineDatabase
param:
dsn: mysql:dbname=informathic;host=sql2.informathic.com
username: informathic
password: changeThis
sql3:
class: sfDoctrineDatabase
param:
dsn: mysql:dbname=informathic;host=sql3.informathic.com
username: informathic
password: changeThis
Dans ce fichier de configuration, nous avons notre serveur maître qui nous servira pour l'écriture et nos serveurs esclaves pour la lecture.
Nous partons désormais dans la modification de la classe ProjectConfiguration. A noter qu'il faut garder la méthode setup() :
<?php
class ProjectConfiguration extends sfProjectConfiguration
{
protected
$writeConnection = null,
$readConnection = null;
public function getWriteConnection()
{
$this->writeConnection || $this->setupConnections();
return $this->writeConnection;
}
public function getReadConnection()
{
$this->readConnection || $this->setupConnections();
return $this->readConnection;
}
protected function setupConnections()
{
$manager = Doctrine_Manager::getInstance();
$slaves = array();
foreach ($manager as $name => $conn)
{
if ('master' == $name)
{
$this->writeConnection = $conn;
}
else
{
$slaves[] = $conn;
}
}
if (!$this->writeConnection)
{
$this->writeConnection = $manager->getCurrentConnection();
}
$this->readConnection = count($slaves) ?
$slaves[array_rand($slaves)] : $this->writeConnection;
}
}
Explications sur les modification de la classe ProjectConnection :
La méthode publique getWriteConnection() nous permettra d'obtenir une connexion d'écriture. Si jamais les connexions n'ont pas été initialisées, alors on exécute la méthode protégée setupConnections().
La méthode publique getReadConnection() est similaire à la précédente. Sauf que cette fois il s'agit d'avoir une connexion uniquement pour la lecture.
La méthode protégée setupConnection() permet de faire le tri entre la connexion dédiée à l'écriture et les autres connexions quand à elles dédiées à la lecture. Pour cela on récupère toutes les connexions que l'on a indiquées précédemment dans notre fichier databases.yml grâce à Doctine_Manager. On applique une belle itération foreach. On essaye ensuite de retrouver notre serveur SQL maître en faisant une condition où l'on vérifie que le nom est bien master. Si ce n'est pas le cas alors on en déduit qu'il s'agit d'une connexion réservé à la lecture. On la stocke dans un tableau qui nous servira plus tard.
Si à la suite de notre itération, nous n'avons pas trouvé le serveur SQL maître alors nous demandons à Doctrine_Manager de nous renvoyer la connexion courante.
Pour sélectionner notre serveur de lecture, alors soit il n'en existe aucun et dans ce cas on prend notre serveur maître ou alors grâce à la fonction array_rand() nous en sélectionnons un au hasard. Le problème avec la sélection aléatoire c'est que l'on peut très bien par malchance tomber sur le serveur SQL le plus chargé. Donc au lieu de se retrouver dans une configuration où l'on est censé accroître les performances, on peut très bien les dégrader.
Maintenant on passe à la création d'une classe qui permettra de détecter automatiquement s'il faut utiliser une connexion maître ou esclave selon le type de la requête. On ouvre à nouveau son éditeur pour créer notre nouvelle classe que l'on appellera InformatHicQuery :
<?php
class InformatHicQuery extends Doctrine_Query
{
public function preQuery()
{
if (Doctrine_Query::SELECT == $this->_type)
{
$this->_conn = ProjectConfiguration::getActive()
->getReadConnection();
}
else
{
$this->_conn = ProjectConfiguration::getActive()
->getWriteConnection();
}
}
}
Notre méthode publique preQuery() détectera s'il s'agit d'une requête de type SELECT auquel cas on sélectionnera la connexion dédiée à la lecture ou alors s'il s'agit de tout autre type de requête on sélectionnera la connexion vers le serveur SQL maître.
Nous devons maintenant créer une classe pour les enregistrements. On sait dors et déjà que cette classe gère des enregistrements... alors ? Eh bien on utilisera constamment la connexion vers notre serveur maître !
On crée une classe que l'on va nommer InformatHicRecord :
<?php
class InformatHicRecord extends sfDoctrineRecord
{
public function save(Doctrine_Connection $conn = null)
{
if (null === $conn)
{
$conn = ProjectConfiguration::getActive()
->getWriteConnection();
}
return parent::save($conn);
}
public function replace(Doctrine_Connection $conn = null)
{
if (null === $conn)
{
$conn = ProjectConfiguration::getActive()
->getWriteConnection();
}
return parent::replace($conn);
}
public function delete(Doctrine_Connection $conn = null)
{
if (null === $conn)
{
$conn = ProjectConfiguration::getActive()
->getWriteConnection();
}
return parent::delete($conn);
}
}
On en fait de même pour la classe de Collection :
<?php
class InformatHicCollection extends Doctrine_Collection
{
public function save(Doctrine_Connection $conn = null, $processDiff = true)
{
if (null === $conn)
{
$conn = ProjectConfiguration::getActive()
->getWriteConnection();
}
return parent::save($conn, $processDiff);
}
public function delete(Doctrine_Connection $conn = null, $clearColl = true)
{
if (null === $conn)
{
$conn = ProjectConfiguration::getActive()
->getWriteConnection();
}
return parent::delete($conn, $clearColl);
}
}
Nous allons maintenant configurer Doctrine pour lui indiquer d'utiliser les classes que nous venons de créer.
Pour cela, on retourne dans notre classe ProjectConfiguration et on ajoute la méthode qui suit :
<?php
class ProjectConfiguration extends sfProjectConfiguration
{
// ...
public function configurationDoctrine($manager)
{
$manager->setAttribute(Doctrine::ATTR_QUERY_CLASS, 'InformatHicQuery');
$manager->setAttribute(Doctrine::ATTR_COLLECTION_CLASS, 'InformatHicCollection');
sfConfig::set('doctrine_model_builder_options', array(
'baseClassName' => 'InformatHicRecord'
));
}
}
Nous allons passer à un exemple de l'utilisation de notre nouvelle configuration :
Auparavant, notre classe pour gérer chaque utilisateur inscrit dans la base de données ressemblait à ça (il y a volontairement qu'une seule méthode — pas besoin d'en créer 50. Vous comprendrez vite le changement) :
<?php
class InformatHicUserActive extends BaseInformatHicUserActive
{
public function disable()
{
$conn = $this->getTable()->getConnection();
try
{
$conn->beginTransaction();
Doctrine::getTable('InformatHicInactive')
->insertForUser($this);
$this->delete();
$conn->commit();
}
catch (Exception $e)
{
$conn->rollback();
throw $e;
}
}
}
Maintenant nous allons adapter notre classe pour qu'elle puisse fonctionner avec notre nouvelle architecture.
Rassurez-vous il n'y a pas grand chose à changer ! Juste une ligne... si si :
<?php
class InformatHicUserActive extends BaseInformatHicUserActive
{
public function disable()
{
$conn = ProjectConfiguration::getActive()
->getWriteConnection();
try
{
$conn->beginTransaction();
Doctrine::getTable('InformatHicInactive')
->insertForUser($this);
$this->delete();
$conn->commit();
}
catch (Exception $e)
{
$conn->rollback();
throw $e;
}
}
}
Nous avons récupéré notre connexion d'écriture afin de pouvoir déclarer notre transaction puis, par la suite, la valider si tout c'est bien passé ou l'invalider en cas d'erreur. Ceci permet d'avoir toujours une base de données cohérente.
Passons maintenant à notre ConnectionListener qui nous sera utile lors de la phase de débug et de test de notre application.
Nous allons créer notre classe InformatHicConnectionListener :
<?php
class InformatHicConnectionListener extends Doctrine_EventListener
{
protected $writeConnection = null;
public function __construct($writeConnection)
{
$this->writeConnection = $writeConnection;
}
public function checkConnection(Doctrine_Connection $conn, $boolean = true)
{
if ($this->writeConnection instanceof sfCallable)
{
$this->writeConnection = $this->writeConnection->call();
}
if ($boolean != $this->writeConnection === $conn)
{
throw new LogicException('Wrong connection!');
}
}
public function preQuery(Doctrine_Event $event)
{
$this->checkConnection($event->getInvoker(), false);
}
public function preExec(Doctrine_Event $event)
{
$this->checkConnection($event->getInvoker());
}
public function prePrepare(Doctrine_Event $event)
{
$boolean = 0 !== strpos(trim(strtolower($event->getQuery())), 'select');
$this->checkConnection($event->getInvoker(), $boolean);
}
public function preTransactionBegin(Doctrine_Event $event)
{
$this->checkConnection($event->getInvoker()->getConnection());
}
public function preTransactionCommit(Doctrine_Event $event)
{
$this->checkConnection($event->getInvoker()->getConnection());
}
public function preTransactionRollback(Doctrine_Event $event)
{
$this->checkConnection($event->getInvoker()->getConnection());
}
}
Comme tout à l'heure, nous allons devoir créer une nouvelle méthode dans la classe ProjectConfiguration pour indiquer la présence de notre connection listener :
<?php
class ProjectConfiguration extends sfProjectConfiguration
{
// ...
public function configureDoctrineConnection($conn)
{
if (sfConfig::get('sf_debug') || sfConfig::get('sf_test'))
{
$callable = new sfCallable(array($this, 'getWriteConnection'));
$listener = new InformatHicConnectionListener($callable);
$conn->addListener($listener, 'informathic_debug');
}
}
}
Et voilà c'est terminé !
Toutefois si pour le développement vous n'avez qu'une seule machine, sachez que vous pouvez mettre dans le fichier databases.yml en host "localhost" pour le maître et les esclaves. Ainsi vous aurez votre configuration toute prête et juste les hosts à changer.
Mais pour vous dire la vérité, j'ai fait le cachottier parce que durant la conférence, Kris a montré tout ça, mais il ne s'est pas arrêté là. En effet pourquoi ne pas en faire un plugin ? Et c'est ce qu'il a fait ! Il l'a par ailleurs mis en ligne durant la conférence. Vous pouvez dès à présent télécharger le plugin sfDoctrineMasterSlavePlugin.
J'ai trouvé ça absolument énorme. Vous avez désormais presque plus rien à faire !
Sachez que Kris est allé bien plus loin en indiquant la création des sessions en base de données dans le fichier factories.yml, en utilisant le service S3 d'Amazon pour les envois de fichier depuis son application, il utilise aussi un autre service pour déployer les mises à jour de son application sur tout les serveurs Web.
Simplement je n'ai pas abordé tous les points dans cet article. Vous pouvez par contre retrouver les slides de sa conférence sur SlideShare.



