Passer du shell à Puppet
Dans la lignée de cet article, où il était question d'administrer son parc de machines avec des scripts shell (ce que nous faisions), et de celui-ci, qui traite des outils de déploiement automatisés (ce que nous projetions de faire), dont Puppet, voici l'article qui traite de Puppet. Par la pratique. Avec du sang, des larmes, et des victoires.
Car oui, passer d'un parc administré par scripts shell à Puppet (ce que nous sommes en train de faire), quand on ne connaît pas Puppet, ça fait mal.
D'où nous partons
Sur le projet, nous avons cinq environnements de chacun trois machines : un serveur web (Apache), un serveur d'app (Tomcat), et un serveur de base de données (PostgreSQL). Plus un serveur ftp, plus deux machines de monitoring (Zabbix) (qui hébergent un serveur web et un serveur de base de données) et de déploiement (OCS). Plus un serveur de démo. Plus deux serveurs pour un autre projets (mais qui hébergent les mêmes composants).
Pour résumer, nous avons 17 serveurs presque identiques, plus 4 atypiques.
De fait, nous utilisons des scripts shell (Bash) pour installer ces serveurs :
- Configuration de base (packages, utilisateurs, DNS, variables d'environnement, démons de monitoring...),
- Installation des socles logiciels, sans leur configuration car celle-ci est fournie par les applis.
Plus un autre pour vérifier que tout est en ordre, et un peu de colle autour (Makefile, modules Perl maison, répertoire lib pour les fonctions partagées, fichier de méta-configuration, ...)
En toute humilité, nous maitrisons le shell. C'est même un plaisir de répondre à une problématique par quelques lignes bien emballées. Mais bien qu'assez puissants, ces scripts montrent leurs limites :
- Non idempotent : lancé deux fois de suite, un même script d'installation peut casser des choses ;
- Ne gèrent pas les mises à jour : passer de Postgresql 8.4.3 à Postresql 8.4.7 n'est pas comme juste installer la 8.4.7 ;
- Trop spécifiques à un projet : ils sont câblés pour un projet. Pour installer un serveur pour un autre projet, il faut modifier le script.
- Ne sont pas dev-compliant : dans l'optique devops nous voulons que les devs puissent installer des serveurs/VM. C'est possible avec les scripts mais les devs ne connaissant pas tous le shell, alors qu'une conf est à leur portée.
Deux choix se posent donc :
- Continuer en scripts, au risque qu'ils deviennent obèses et immaintenables.
- Passer aux choses sérieuses : Puppet/Chef/cfEngine
Nous choisissons Puppet pour ces raisons :
- Un PoC fait dans nos locaux nous montre qu'il répond à nos besoins (et, de fait, nous fournit un bootstrap),
- Nous entendons parler de Puppet un peu partout autour de nous,
- Nous ne connaissons pas Puppet ;)
Nous puppetisons donc nos scripts.
Migrations
Puppetiser nos scripts veut dire que nous allons détruire de nos mains l’œuvre de notre vie (de plus d'un an que nous sommes sur le projet), et nous mettre à rebâtir (pour paraphraser Kypling).
L'exercice est un effort en soi. Agile a beau nous enseigner qu'il ne faut pas être affectif avec le code (sinon vous freinez son éventuel refactoring), ça fait tout de même quelque chose.
Une fois le deuil fait, il faut apprendre à connaître Puppet. Et c'est à ce moment que nous avons pris conscience des difficultés...
La philosophie de Puppet est 'convention over configuration'. Ce qui signifie que vous spécifiez moins, mais devez apprendre plus de choses (les "conventions"). Et il y a beaucoup de choses à apprendre...
Puppet marche par contrat : vous lui spécifiez, dans son langage qui est dérivé du Ruby, ce que vous voulez, et il se débrouille pour le faire.
J'insiste : vous spécifiez, il fait. Ce qui signifie que vous ne faites pas. Ce qui signifie que si vous aviez l'habitude de coder (d'agir, donc, pour atteindre un but), ce n'est plus le cas : vous spécifiez une cible, il l'atteint. Vous ne savez pas vraiment comment, d'ailleurs...
Cette philosophie entraîne une manière de pensée différente, et dérangeante, à laquelle il n'est pas aisé de se faire à moins d'avoir déjà eu un aperçu de cette manière de faire, ce qui n'était clairement pas mon cas : ce paradigme ne se retrouve dans aucun des langages que j'ai appris (sur le tas).
Nous avons commencé par les choses simples et pour lesquelles Puppet est doué : créer des utilisateurs, tripatouiller des fichiers systèmes, installer des packages. Le web est rempli de documentation à ce sujet.
La documentation, tiens, parlons-en. J'ai rarement rencontré une doc aussi mal organisée. Je passe systématiquement sur trois pages différentes avant de trouver ce que je cherche : est-ce un métaparamètre ? une fonction ? un type ? un fact ? quelque chose défini ailleurs ?
Avec le temps, la syntaxe de Puppet finit par rentrer. Mais plus par mémoire que par logique : un peu comme pour apprendre un texte qui n'a ni queue ni tête (et pour le reste, il y ce document, très bien fait)
Une fois les choses simples puppetisées, nous nous sommes lancés dans les choses moins simples : télécharger un paquetage depuis Nexus, l'exécuter si c'est un binaire, l'installer si c'est un rpm, créer une base de données si elle n'existe pas, créer les rôles correspondant, mettre à jour le pg_hba.conf et le .pgpass. Visiblement Puppet n'a pas adopté la philosophie de Perl (rendre plus simple ce qui est compliqué, garder simple ce qui l'est) et sortir de ce que Puppet sait facilement faire n'est pas une mince affaire : il nous a fallu nous battre pour lui faire faire ce que nous voulions. Je ne peux pas m'empêcher d'avoir un sentiment d'échec quand je dois exécuter du shell dans un programme pour lui faire faire ce que je veux...
Rajoutez à tout cela des messages d'erreur sibyllins qui ne vous indiquent à peu près jamais la réelle cause de l'erreur, et vous comprendrez que j'ai pris Puppet en grippe assez rapidement.
Pour résumer, voici ce que je reproche à Puppet :
- Il est lent. Je fais mes tests dans une VM, et c'est affreusement lent
- La syntaxe absconse : pourquoi de temps en temps écrire en camel case et parfois pas ? Pourquoi devoir mettre de temps en temps des accolades, de temps en temps des crochets ? pourquoi 'owner' plutôt que 'uid' ? Pourquoi devoir toujours spécifier le 'title' ? et pourquoi devoir mettre deux points après ? pourquoi faut-il parfois finir les lignes par point virgule, parfois pas et parfois par une virgule ? pourquoi certaines fois il accepte des virgules en fin de dernière ligne et parfois pas ? Pourquoi 'title' et 'name' ? pourquoi les facts ont-ils la même syntaxe que les variables définies dans la classe ? pourquoi faire que 'source' RESSEMBLE à un chemin, mais SANS 'manifests' ? pourquoi faire une syntaxe qui RESSEMBLE au Ruby mais qui n'en est pas ? Utiliser des classes ou des nodes ?
- La documentation, mal organisée.
- Les messages d'erreur sont loin d'être explicites : au lieu de me dire que ma variable est undef tu ne peux pas me suggérer qu'elle pourrait venir d'un plugin qui n'est pas initialisé ?
Pour moi qui n'ai jamais fait de Ruby ni de programmation par contrat, le ticket d'entrée dans le royaume de Puppet est bien cher.
Quelques tips
Une pyramide de classes
Migrer de nos scripts shell à Puppet s'est fait progressivement : nous avons créé un module par composant fonctionnel : Tomcat, Postgresql, Apache...
Nous nous sommes retrouvés avec beaucoup plus de composants fonctionnels que prévu : pki, sudo, user (pour créer les utilisateurs mais aussi installer des scripts pour leur ~/bin), <composant>db (pour la base de données de ces composants), ntp, logrotate...
La doc de Puppet a beau indiquer qu'il faut utiliser la directive 'node', nous avons utilisé 'class' afin de spécifier et passer des paramètres (tels que les mots de passe) de classes en classes. Ainsi, nous pouvons changer les mots de passe de chaque composants d'un coup d'un seul.
Nous avons donc regroupé ces composants dans de grandes classes, et empilé ces classes.
Puis il a suffit d'affecter les bonnes classes au serveur, et le tour était joué.
class basenode ($user_hash=undef, monitored=true) {
class { 'basesystem': }
class { 'puppet': }
class { 'nexus': }
class { 'hosts': }
class { 'user': hash => $user_hash }
class { 'env': }
class { 'ntp': }
class { 'ssh_keys': }
class { 'sudo': }
class { 'baseapp': }
if $monitored { class { 'monitorednode': } }
}
class dbnode ($min_sec=undef, $i_pass=undef, $mo_pass=undef, $g_pass=undef, $pgversion='x.y.z-1', $backup_retention_days=undef, $monitored=true ) {
class { 'basenode': monitored => $monitored}
class { 'pgsql': log_min_duration_statement => $min_sec, pgversion => $pgversion, backup_retention_days => $backup_retention_days }
if $monitored { class { 'pgsql::zabbix': } }
class { 'pgsql::tools': }
class { 'db': i_pass => $i_pass, mo_pass => $mo_pass, g_pass => $gestion_pass}
if $monitored { class { 'db::zabbix': } }
class { 'pshops': }
}
node 'db1' {
class { 'dbnode': min_sec => '1000', i_pass=>'hop', mo_pass=>'zou', g_pass=>'bla'}
}
Avec cet exemple, db1 est un dbnode, qui lui même est un basenode. Et des paramètres sont passés pour spécifier les mots de passe.
Par un jeu de Lego nous avons construit nos classes et mis nos serveurs dedans. Au fur et à mesure que la puppetisation avançait nous avons supprimé nos scripts d'installation.
Wrapping
Nous ne nous sommes pas privés de wrapper certaines directives :
define user::userfile (
$basedir = "/home/${user::username}",
$source = '',
$content = '',
$mode = '0644',
$owner = $user::username,
$group = $user::username,
$replace = ''
)
{
file { "${basedir}/${title}":
owner => $owner,
group => $group,
mode => $mode,
}
if $source { File["${basedir}/${title}"] { source => $source } }
if $content { File["${basedir}/${title}"] { content => $content } }
if $replace { File["${basedir}/${title}"] { replace => $replace } }
}
Cette fonction installe un script dans le ~/bin de l'utilisateur, avec les bons droits. Ça nous évite de spécifier toujours les mêmes choses.
Nexus
Certains de nos binaires d'installation sont dans Nexus. Et devinez ? Puppet ne savait pas aller chercher dedans. Il a fallu lui apprendre :
nexus/init.pp :
class nexus {
$repo = 'thirdparty'
$target_dir = ''
$user = 'user'
$pass = 'pass'
$server = 'some.serveur.tld'
$port = '7853'
$url = "http://${user}:${nexus::pass}@${server}:${port}/nexus/service/local/artifact/maven/content"
}
nexus/get.pp :
define nexus::get (
$groupeid,
$artifactid,
$version,
$type,
$classifier = '',
$mode = '0644'
)
{
include nexus
$full_url = "${nexus::url}?r=${nexus::repo}&g=${groupeid}&a=${artifactid}&e=${type}&v=${version}&c=$classifier"
exec { "download_via_nexus ${title}":
command => "mkdir -p \$(dirname \"$title\") ; /usr/bin/wget -q \"$full_url\" -O \"${$title}\" ; /bin/chmod ${mode} \"${$title}\"",
path => [ '/usr/bin','/bin'],
timeout => 0, unless => "test -e \"${title}\" && test `/usr/bin/wget -q \"${full_md5_url}\" -O -` = `md5sum \"${title}\" | /bin/cut -d' ' -f1`",
}
}
nexus/exec.pp :
define nexus::exec (
$groupeid,
$artifactid,
$version,
$classifier = '',
$type = '',
$command = 'echo "I should do something with \"<%= title %>\""',
$purge_after = false,
$mode = '0644',
$creates = '', $unless = '', $onlyif = '',
$path = ['/bin', '/usr/bin']
)
{
nexus::get {$title:
groupeid => $groupeid,
artifactid => $artifactid,
version => $version,
type => $type,
mode => $mode,
classifier => $classifier
}
$real_command = inline_template($command)
exec { "exec_from_nexus ${title}":
command => $real_command,
path => $path,
logoutput => true,
timeout => 0,
require => Nexus::Get["$title"]
}
if $unless { Exec["exec_from_nexus ${title}"] { unless => $unless } }
if $onlyif { Exec["exec_from_nexus ${title}"] { onlyif => $onlyif } }
if $creates { Exec["exec_from_nexus ${title}"] { creates => $creates } }
}
nexus/extract.pp :
define nexus::extract (
$repo = thirdparty,
$groupeid,
$artifactid,
$version,
$type = '',
$classifier = '',
$target_dir = '',
$owner = $user::username,
$group = $user::username,
$extract_cmd = "tar --owner ${owner} --group ${group} -C '${target_dir}' -xz",
$creates,
$nexus_url = 'http://user:pass@some.serveur.tld:7853/nexus/service/local/artifact/maven/content'
)
{
$full_url = "$nexus_url?r=$repo&g=$groupeid&a=$artifactid&e=$type&v=$version&c=$classifier"
exec { "extract_from_nexus ${title}":
command => "/usr/bin/wget -q '$full_url' -O- | ${extract_cmd}",
path => ['/bin', '/usr/bin'],
unless => "test -d '${creates}'",
timeout => 0,
require => File[$target_dir]
}
exec { "ensure correct u:g to ${creates}":
path => ['/bin', '/usr/bin'],
command => "chown -R ${owner}:${group} '${creates}'",
unless => "test `stat -c '%U:%G' '${creates}'` = '${owner}:${group}'",
require => Exec["extract_from_nexus ${title}"],
}
}
Observez les commande shell qu'il a fallu utiliser par ci par là avant de le faire réagir comme voulu : ne pas télécharger deux fois le même fichier, déposer au bon endroit, positionner les droits...
Shell
La partie la plus compliquée a été de faire installer Postgresql. En effet, nous installons Postgresql à partir des binaires (mis à disposition via Nexus), et nous voulions que Puppet gère l'installation mais aussi la mise à jour de version mineure à mineure (x.y.z -> x.y.z1) et de version mineure à majeure (x.y.z -> x1.y1.z1), donc avec dump et restore au milieu. Puppet n'étant pas équipé pour ce genre de mission, il a fallu sortir le gros shell :
nexus::exec { "/var/cache/puppet/pgsql-${pgsql::params::version}":
groupeid => 'org', artifactid => 'postgresql',
version => "${pgsql::params::version}",
classifier => "linux-x64",
type => 'bin',
mode => '0755',
purge_after => false,
path => [ '/bin','/sbin','/usr/bin','/usr/sbin' ],
command => $minipgsqlversion ? { # En fonction de la version installée ...
# Même version majeur que ce qu'on me demande (x.y.z vs x.y.z1) : mise à jour sans dump
$pgsql::params::miniPgVersion => "bash -c 'if test $pgsqlversion != ${pgsql::params::dirPgVersion} ; then echo -e \"\\n\\n\\n\\n\\n\\n\\n\\nn\\n\" | <%= title %> --prefix ${pgsql::params::prefix} --datadir ${pgsql::params::datadir} --locale ${pgsql::params::loc} --mode text --servicename ${pgsql::params::service} --superpassword ${pgsql::params::pass} ; else /bin/true ; fi'",
# Aucune version trouvée : installation
'notfound' => "echo -e \"\\n\\n\\n\\n\\n\\n\\n\\nn\\n\" | <%= title %> --prefix ${pgsql::params::prefix} --datadir ${pgsql::params::datadir} --locale ${pgsql::params::loc} --mode text --servicename ${pgsql::params::service} --superpassword ${pgsql::params::pass}",
# Par défaut (par ex. x.y.z vs x1.y1.z1) mise à jour avec dump
default => "bash -c 'name=/opt/pgsql-$pgsqlversion-to-${pgsql::params::dirPgVersion}-\$(date '+%Y%m%d-%H%M%S').sql.gz ; /usr/local/postgresql-$minipgsqlversion/bin/pg_dumpall -U postgres | gzip > \$name ; service postgresql-$minipgsqlversion stop ; echo -e \"\\n\\n\\n\\n\\n\\n\\n\\nn\\n\" | <%= title %> --prefix ${pgsql::params::prefix} --datadir ${pgsql::params::datadir} --locale ${pgsql::params::loc} --mode text --servicename ${pgsql::params::service} --superpassword ${pgsql::params::pass} ; service ${pgsql::params::service} start || /bin/true ; gunzip -c \$name | ${pgsql::params::prefix}/bin/psql -U postgres '",
}
}
Là encore, observez l'arsenal déployé.
Le facter qui fournit la version de Postgresql installée :
Facter.add("pgsqlversion") do setcode do psql=Dir.glob("/usr/local/*/bin/psql").sort[-1] if psql %x(#{psql} --version).split("\n")[0].split()[-1] else 'notfound' end end end Facter.add("minipgsqlversion") do setcode do psql=Dir.glob("/usr/local/*/bin/psql").sort[-1] if psql %x(#{psql} --version).split("\n")[0].split()[-1].gsub(/\.\d*$/,'') else 'notfound' end end end
Une fois construite la configuration de Puppet nous avons pu savourer notre œuvre et dormir un peu plus tranquillement la nuit.
Bénéfices
Nous n'avons pas utilisé Puppet dès le début du projet car nous voulions aller vite, et nous ne connaissions pas Puppet. De plus l'équipe était encore jeune, et Puppet n'était pas le plus indispensable des outils par lequel commencer (avouons-le : c'était le shell)
De fait, commencer par du shell était une bonne idée, mais qui a atteint les limites que j'ai citées plus haut.
La puppetisation des plateforme n'a pas fait du mal qu'à moi, mais aussi aux autres équipes, mais dans le bon sens : la psychorigidité de Puppet nous a fait prendre conscience que certaines choses sur les serveurs n'étaient pas carrées (notre user de référence n'avait pas le même uid partout, par exemple), et cela nous a forcé à remettre ces choses d'aplomb, pour notre bien.
Là où nous hésitions à lancer nos scripts d'installation, nous demandons maintenant à Puppet de le faire : le bonheur de la programmation par contrat est là : quand quelque chose est fait, il n'est plus à faire, et vous n'avez pas à vous soucier de comment le faire (sauf dans de rares cas).
Quand une brique du socle est puppetisée (l'installation de Tomcat, par exemple) nous n'avons plus de doute sur elle : elle est installée de la même manière partout, et elle fonctionne.
Installer une nouvelle machine à partir d'une VM prends 10', décomposées ainsi : 30" à installer le kit de base (qui change le nom de la machine et installe Puppet) et à lancer Puppet, et 9'30" à siroter son thé en attendant qu'il ait terminé. Suivi de l'agréable sentiment que la chose a été bien faite.
Nous avons, en vérité, simplifié notre processus d'installation et de mise à jour : tout est à un seul endroit (dans le puppet master), et il y a plus qu'une commande à lancer.
Conclusion
Le ticket d'entrée à Puppet est cher. Il aurait largement pu l'être moins. Je ne connais malheureusement pas les autres outils pour pouvoir comparer mais par rapport à du shell il apporte un véritable plus. Et puis nous avons fini par nous faire à ses défauts ;)
Aurions-nous dû mettre Puppet dès le début du projet ? Vu le contexte, non : il était important que les équipes apprennent le shell, quitte à ce que les scripts d'installation des machines aient une durée de vie limitée.
Quelques liens utiles :
- Puppet Cheat Code Sheet, ou tout ce que vous oubliez toujours sur la syntaxe des directives de Puppet les plus courantes.
- Des modules Puppet. Énormément de modules qui, dans les rares cas où il ne sont pas adaptés à votre cas, vous mettront le pied à l'étrier.