Aller au contenu principal

· 14 minutes de lecture
TheBidouilleur

L'IPFS (InterPlanetary File System) est un protocole P2P (Peer to Peer) permettant de diffuser des fichiers de manière décentralisée.

La mention "InterPlanetary" nous donne l'objectif premier de l'IPFS : surmonter les difficultés techniques et les contraintes de la communication entre planètes.

En effet, le jour où Mars sera colonisée, le web devra s'adapter pour permettre à tous de pouvoir accéder au même internet. Car si nous arrivons à afficher Wikipédia en quelques millisecondes sur nos ordinateurs, 4min seront nécessaires depuis Mars dans les meilleures conditions et 48 min si vous êtes malchanceux.

Ces 48 minutes serviront uniquement à afficher une seule page, imaginez alors pour une vidéo YouTube ou une série.

C'est à ce moment précis que l'IPFS entre en scène. Comme c'est un système de partage de fichiers entre ordinateurs qui fonctionne sans serveurs centraux, il ne sera pas toujours nécessaire de communiquer avec un serveur terrien pour lire une page web. Il suffira de demander à un ordinateur proche de nous d'envoyer le fichier.

Ce protocole est un mélange entre le World Wide Web et Bit Torrent avec lequel un même fichier peut être partagé par plusieurs ordinateurs. Le réseau IPFS est donc une carte depuis laquelle nous allons demander un fichier (ou site) et le télécharger depuis un serveur qui n'est pas forcément le serveur dont provient la donnée.

Explication Réseau IPFS décentralisation

Comment accéder à un fichier ?

En Web classique, nous demandons à un serveur un fichier spécifique via une URL qui renvoie vers un fichier spécifique. Si nous revenons plus tard : le fichier ne sera pas forcément le même.

Demande de fichier sur le web classique

En IPFS, nous allons directement demander un fichier à partir d'un identifiant qui se base sur son contenu : un CID (Content Identifier), c'est un Hash unique permettant d'identifier votre donnée.

Demander de fichier en IPFS

Dès lors que nous envoyons un fichier dans le réseau IPFS, nous obtenons un CID qui pointe vers celui-ci. Ce CID est calculé en fonction de l'empreinte unique du fichier, et le CID est altéré si le fichier est modifié.

En lisant un fichier depuis le réseau IPFS, nous avons la preuve qu'il n'est pas censuré et qu'il est immuable.

Cas d'usages

J'ai découvert l'IPFS via le site Libgen, un moteur de recherche d'articles scientifiques. Le site officiel détaille un peu plus les différentes raisons d'utiliser ce réseau:

  • Archiver des données publiques sur le long terme : L'IPFS est un moyen fiable de transmettre des données aux générations futures en la décentralisant et en donnant l'opportunité aux utilisateurs lisant vos données de les repartager.
  • Héberger un site sans serveur : Depuis votre laptop, vous pouvez héberger un site qui sera lui-même hébergé (/repartagé) par les lecteurs qui accèdent à votre site.
  • Partager de gros fichiers : Les fichiers IPFS sont séparés en blocs, vous pouvez alors partager de grands volumes via l'IPFS et télécharger les blocs en parallèle depuis différents serveurs. (comme BitTorrent)
  • Rendre votre contenu incensurable : Comme chaque fichier s'accède via un Hash unique, vous avez constamment la preuve que le fichier n'est pas altéré par un hackeur ou une organisation.
  • Partager du contenu Offline sur votre réseau : Le partage peut se faire sans accès à Internet, les clients IPFS se découvrent(autodiscovery) sur un réseau local et peuvent continuer à relayer les fichiers en cache.

Cycle de vie d'un fichier

Si je souhaite envoyer mon image de profil dans le réseau IPFS, celle-ci va être séparée en plusieurs parties de (maximum) 256 ko. Nous calculons ensuite un Hash unique pour chaque morceau, et nous les combinons pour créer le CID du fichier complet.

Envoi d'un fichier vers IPFS

Le CID est alors une entité qui contient les différents Hash des morceaux de ~256 ko permettant de reconstituer le fichier d'origine.

Le fait de séparer un fichier en plusieurs blocs permet de faire de la déduplication. Si je stocke de nouveau mon image en ayant modifié que le haut du png : je peux réutiliser les blocs identiques et n'ajouter que la différence en IPFS. Le CID sera quand même différent (le hash des premiers blocs sera modifié).

Il est donc possible de reconstituer un fichier complet en utilisant les parties présentes dans le réseau IPFS.

Récupérer un fichier IPFS à partir du CID

Installer un client IPFS

Kubo Logo

Kubo est l'utilitaire le plus connu et le plus utilisé pour communiquer en IPFS. Il est écrit en Golang et peut s'utiliser en ligne de commande, ou via une interface web.

Il s'installe de manière assez simple :

git clone https://github.com/ipfs/kubo.git
cd kubo
make install

N'appréciant pas l'usage d'interfaces web, je vais uniquement présenter l'utilitaire en ligne de commande. L'interface web est disponible sur le port 5001 de votre machine et n'apporte pas de fonctionnalités supplémentaires.

Interface web de Kubo

L'IPFS en pratique

Je dispose de deux machines virtuelles sur lesquelles j'ai installé Kubo.

Première chose que nous pouvons faire, c'est stocker un fichier sur le réseau IPFS. La commande est simple : ipfs add <fichier>. Cette commande va nous retourner un CID qui correspond à notre fichier. Ce CID est unique et permet de retrouver notre fichier sur le réseau IPFS.

Avant tout, chacune des machines aura initialisé son client IPFS avec la commande ipfs init. Cela permet de créer un dossier .ipfs dans le dossier utilisateur de la machine.

Je crée un fichier hello.txt avec le contenu "Bonjour !". L'utilitaire ipfs add ajoute ce fichier sur notre nœud IPFS.

# machine 1
➜ ipfs add hello.txt
added QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11 hello.txt
# machine 2
➜ ipfs cat QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11
Error: block was not found locally (offline): ipld: could not find QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11

La raison ? C'est simple : Aucune des machines n'est reliée au réseau IPFS !

Pour cela, il faut lancer le daemon via la commande ipfs daemon sur les deux machines. Une fois la commande lancée, on peut lire le fichier sur la machine 2 :

# machine 2
➜ ipfs cat QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11
Bonjour !

Donc dans cette configuration, le fichier hello.txt est hébergé par le nœud IPFS sur la machine 1 et la machine 2 y accède.

Maintenant, faisons une simple expérience, éteignons la machine 1, et tentons d'accéder une nouvelle fois au fichier :

# machine 2
➜ ipfs cat QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11
Bonjour !

Le fichier est toujours accessible ! Cela s'explique par l'existence d'un cache sur notre client. Ce cache est paramétrable via le fichier de configuration situé à cet emplacement ~/.ipfs/config.

...
"Datastore": {
"StorageMax": "10GB",
"StorageGCWatermark": 90,
"GCPeriod": "1h",
...

Ou en ligne de commande :

ipfs config Datastore.StorageMax '"5GB"' --json

Nous avons un cache maximum de 10 Go. Le garbage collector supprimera ce cache dès lors que nous utilisons plus de 90% du StorageMax.

En dehors de permettre à la machine 2 de lire ce fichier, ce cache a également une autre utilité.

Ajoutons une 3ème machine virtuelle et tentons d'accéder au fichier QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11. (Sachant que la machine 1 est toujours éteinte, celle-ci ne pourra pas envoyer le fichier)

# machine 3
➜ ipfs cat QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11
Bonjour !

Le cache permet ainsi de participer à la diffusion de ce fichier (sans être le nœud de première diffusion).

À l'inverse (en ayant supprimé le cache de machine 3), si jamais j'éteins la machine 1 et 2 : le fichier devient injoignable :

# machine 3
➜ ipfs cat QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11
(Pas de réponse)

Pas de réponse

En résumé : Il faut toujours une machine stockant ce fichier sur le réseau IPFS pour pouvoir accéder aux données.

Mais le cache est éphémère et sera supprimé un jour ! Ne comptez pas dessus pour relayer votre fichier.

Pour demander à une machine de garder le fichier et de le partager, il est nécessaire de PIN le fichier.

Revenons au stade initial : hello.txt sur machine 1, et rien sur machine 2 et 3.

Nous allons demander à la machine 2 de pin notre CID pour que celui-ci soit stocké en dehors du cache et devienne persistant sur machine 2.

# machine 2
➜ ipfs pin add QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11
pinned QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11 recursively

Éteignons de nouveau machine 1 et tentons (encore une fois) de lire le fichier hello.txt sur la machine 3 :

# machine 3
➜ ipfs cat QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11
Bonjour !

Le fichier est maintenant lisible tant que machine 1 ou machine 2 sont sur le réseau IPFS.

Machine 2 vers machine 3

Récupérer un fichier sur le réseau IPFS sans client

Il existe de nombreuses passerelles publiques permettant d'accéder à un fichier du réseau IPFS sans se connecter à un client. Le CID de mon fichier étant QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11, je peux lire le fichier depuis Firefox via cette URL : https://ipfs.io/ipfs/QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11.

Si vous utilisez Kubo et que vous avez installé l'extension IPFS Companion (disponible ici), vous serez automatiquement redirigé vers votre passerelle locale : http://localhost:8080/ipfs/QmNURZjTooDCUKjtegXUDF8CeowSN8VLSnPARLGXnxiv11

Extension Firefox

Et puisque nous utilisons notre navigateur… rien ne nous empêche de lire du HTML !

Mon blog étant sous Docusaurus, je vais alors build le site et l'ajouter à mon nœud IPFS:

git clone https://github.com/QJoly/TheBidouilleur.xyz
cd TheBidouilleur.xyz
npm i
npm run build
ipfs add -r ./build

J'obtiens le CID QmXqrXHXuKB9tHrxUgNphRx8TyKBmtrisuRB2y9FkFta7x et j'accède à mon site via cette URL : http://localhost:8080/ipfs/QmXqrXHXuKB9tHrxUgNphRx8TyKBmtrisuRB2y9FkFta7x ou https://ipfs.io/ipfs/QmXqrXHXuKB9tHrxUgNphRx8TyKBmtrisuRB2y9FkFta7x/ (Attention aux erreurs de CSS. Mon Docusaurus n'aime pas ne pas être à la racine du site).

Pin un dossier

À noter qu'il n'est pas nécessaire de PIN chaque élément du dossier. Il suffit uniquement de le faire sur le dossier racine du site (QmXqrXHXuKB9tHrxUgNphRx8TyKBmtrisuRB2y9FkFta7x dans mon cas). Les fichiers à l'intérieur du répertoire auront un 'pin indirect'.

➜ ipfs pin add QmXqrXHXuKB9tHrxUgNphRx8TyKBmtrisuRB2y9FkFta7x
pinned QmXqrXHXuKB9tHrxUgNphRx8TyKBmtrisuRB2y9FkFta7x recursively
➜ ipfs pin ls
Qmce2mdHr1ufcGqtnR67DdshJqPCpZ6bSrXxuzQJdga1dy indirect
QmdGAYHsqhxiwNDhkoCR5ryrd74wxGKhcgfsh9NJg5ANqH recursive <-- Un dossier
QmcwA7f9HRwVVMMgJRt4mDbGLbr8jruyGPJxPYmWKhFqs2 indirect
QmednJCZK9SnxAy12rreveUqsMyP7Jfw2Aij1hFGWc3BJu indirect

ipfs.io est une passerelle, c'est un accès depuis le web permettant de lire un fichier sur le réseau IPFS. Il en existe de nombreuses, et nous verrons plus bas comment créer la nôtre.

Maintenant, le problème d'héberger un site sur l'IPFS est que chaque fichier est immuable (chaque entité se lit à l'aide de son hash unique). Il n'est alors pas possible de modifier vos fichiers en gardant le même CID (et par conséquent : en changeant l'URL d'accès), vos utilisateurs devront donc utiliser le nouveau CID pour voir la dernière version de votre site.

C'est pour cela qu'il existe une solution : InterPlanetary Name System (IPNS).

L'IPNS permet de faire pointer une URL vers un CID, nous pouvons mettre à jour à tout moment vers quel CID notre IPNS redirige.

Cette URL se forme à partir d'une clé (qui permet de vous identifier sur le réseau IPFS). Dès lors que vous communiquez sur le réseau : vous utilisez une clé ed25519 (ipfs key list) nommée self.

Si vous souhaitez utiliser plusieurs IPNS, il est possible d'en posséder plusieurs (et donc d'obtenir plusieurs 'domaines'), exemple:

➜ ipfs key gen --type=rsa --size=2048 mykey

Pour rediriger notre IPNS (à partir de la clé self) vers un CID, il faut utiliser l'argument publish :

➜ ipfs name publish /ipfs/QmXqrXHXuKB9tHrxUgNphRx8TyKBmtrisuRB2y9FkFta7x
Published to k51qzi5uqu5dl8idfkamiq22x12pr1rlha4i1izbi2hq5nlv3vuqt7nztq4krf: /ipfs/QmXqrXHXuKB9tHrxUgNphRx8TyKBmtrisuRB2y9FkFta7x

Il est également possible de spécifier la clé :

➜ ipfs name publish --key=mykey /ipfs/QmXqrXHXuKB9tHrxUgNphRx8TyKBmtrisuRB2y9FkFta7x
Published to k2k4r8jfpj0rsylz08ahbkar950da3a77wfcreiwh85hnp9op504l0e0: /ipfs/QmXqrXHXuKB9tHrxUgNphRx8TyKBmtrisuRB2y9FkFta7x
danger

Attention, la syntaxe du CID est bien /ipfs/CID.

Pour vérifier vers quoi un IPNS pointe, je peux faire un équivalent de nslookup via ipns name resolv :

➜ ipfs name resolve k51qzi5uqu5dl8idfkamiq22x12pr1rlha4i1izbi2hq5nlv3vuqt7nztq4krf
/ipfs/QmfEyL1zeaL7fWb6ugfzzh7zzdyyP7zSkb5smAyhttuQKS

IPNS Dans mon firefox à partir de IPFS Companion

Passerelles publiques

Durant l'écriture de cet article, aucune passerelle publique n'a réussi à afficher mon blog en utilisant mon IPNS. J'ai dû alors utiliser ma passerelle locale (localhost:8080) sur mon poste et héberger ma propre passerelle pour les machines n'ayant pas Kubo installé. Nous verrons comment créer notre passerelle plus bas.

Liste des gateways publiques

Mais retenir par cœur une clé est (légèrement) compliqué, il est alors possible d'utiliser votre propre nom de domaine en tant qu'IPNS. Pour cela, il suffit d'ajouter une entrée TXT à votre nom de domaine:

ipfs.thebidouilleur.xyz. 60 IN TXT "dnslink=/ipfs/QmXqrXHXuKB9tHrxUgNphRx8TyKBmtrisuRB2y9FkFta7x"
➜ ipfs name resolve ipfs.thebidouilleur.xyz
/ipfs/QmfEyL1zeaL7fWb6ugfzzh7zzdyyP7zSkb5smAyhttuQKS
remarque

Au lieu de régulièrement éditer votre entrée DNS pour changer le CID, il est également possible d'utiliser une clé IPNS :

ipfs.thebidouilleur.xyz. 60 IN TXT "dnslink=/ipns/k51qzi5uqu5di2e4jfi570at4g7qnoqx1vwsd2wc0pit1bxgxn22xwsaj5ppfr"

Il vous suffira donc de mettre à jour vers quel CID cet IPNS pointe via ipfs name publish.

Une instance de mon blog est ainsi joignable depuis IPFS :

  • Avec l'extension navigateur IPFS: ipfs.thebidouilleur.xyz
  • Depuis une passerelle locale : localhost:8080/ipns/ipfs.thebidouilleur.xyz
  • Depuis une passerelle publique : ipfs.io/ipns/ipfs.thebidouilleur.xyz (Non-fonctionnel pour moi)

Héberger une passerelle IPFS

Comme expliqué un peu plus haut, je ne parviens pas à résoudre les IPNS via les passerelles publiques.

J'ai dû alors me tourner vers la création de ma propre passerelle :

Il suffira d'initier votre configuration (ipfs init) comme ci-dessus et de la modifier comme suit :

➜ ipfs config --bool Swarm.RelayService.Enabled true 
➜ ipfs config --bool Swarm.RelayClient.Enabled true
➜ ipfs config AutoNAT.ServiceMode '"enabled"' --json
➜ ipfs config Addresses.Gateway "/ip4/0.0.0.0/tcp/8080"

Une fois ces commandes lancées, vous pourrez directement récupérer des objets IPFS dans votre navigateur via la même syntaxe que les autres passerelles. Ex: http://192.168.128.10:8080/ipns/k51qzi5uqu5di2e4jfi570at4g7qnoqx1vwsd2wc0pit1bxgxn22xwsaj5ppfr.

Dépôt Git en IPFS

En scrutant la documentation, j'ai trouvé cette page qui présente une procédure simple permettant de stocker un dépôt Git en ReadOnly.

Nous récupérons alors un dépôt avec l'argument --mirror permettant de récupérer le dépôt sous forme d'objets compressés. (l'équivalent du dossier .git d'un dépôt)

git clone --mirror https://github.com/qjoly/helm-charts

Si (comme je viens de le faire) vous avez cloné votre dépôt en HTTPS (et non SSH), il vous faudra générer des fichiers auxiliaires via la commande git update-server-info. Ces fichiers générés ou mis à jour par git update-server-info sont nécessaires pour que les clients Git puissent récupérer les objets et les références du dépôt.

git update-server-info

Nous ajoutons maintenant le dossier cloné à notre nœud IPFS :

ipfs add -r ./helm-charts.git
added QmbRUdVtdtxcpdqyJE3iZwTJq7FPcXR1ErQRFB76sQCg9H helm-charts.git/HEAD
added QmWadTGKYEjYf5Y7wKS66fLrTQm3ViH34QFoxbu88CbkG1 helm-charts.git/config
added Qmdy135ZFG4kUALkaMhr6Cy3VhhkxyAh264kyg3725x8be helm-charts.git/description
added QmUJ43sv5NVRBmfPHBwEitpz6D46xh4E79ponctVXEeMSH helm-charts.git/hooks/applypatch-msg.sample
added QmeuAksU8iLW2YeirL69ibjGxkNUjWkKq5iEvWhSdeRRXF helm-charts.git/hooks/commit-msg.sample
added QmV1Jv4eQcHrYtf97nofmUjzaaa6hmVXVt4LsqeG3hQKx8 helm-charts.git/hooks/fsmonitor-watchman.sample
added QmWkzb9d617XFnahXuorAQPxRMGA8TeZB7Vyq2oBMmW52d helm-charts.git/hooks/post-update.sample
added QmdgKBitxhbQ3APZt3CFAnfJUMCNC5uoGLkwjgbHciKPA8 helm-charts.git/hooks/pre-applypatch.sample
added QmVpNrG3G8aMcdScqwAkiKan2ACx6bfR35Dn9XJ2mw3LCC helm-charts.git/hooks/pre-commit.sample
added QmPep4RB3J5ERq3wrwEKFLznjnJeeFPqHZUjqcT3mCHej6 helm-charts.git/hooks/pre-merge-commit.sample
added QmQ52euRcb4YZf8PYfajNPQAuaW8WoBgzAksUqHLLttqjk helm-charts.git/hooks/pre-push.sample
added QmaTMXXEbvRSmpDTKqXf6kH3yeb7TNbiu3jttyYCbFpobD helm-charts.git/hooks/pre-rebase.sample
added QmNgDPe6oFz5jqqqdh9YhuqReBWkuPo6gsy45nHB6mSr2j helm-charts.git/hooks/pre-receive.sample
added QmPgMWyjZR1FzFaB1bYAWKkYLTtC5b6DGFVKroQp5eT7Ee helm-charts.git/hooks/prepare-commit-msg.sample
added QmW7VnBMgFcJNVCKfSNZRL5apU8X19mp7bsL8px6zjbmGn helm-charts.git/hooks/push-to-checkout.sample
added QmdBgUSUM2gmuHYMsk8Xy8AkWU5orkGKeBdK9JjfSCM2tC helm-charts.git/hooks/update.sample
added QmcfzxUpw36y8fu2GR3s7Vgq7RBgooKtc6BgsqFnadsDLc helm-charts.git/info/exclude
added QmW2BhLpMEmyhmvVi5xfRcym54NQEH5RfsqAyaL47NKzr1 helm-charts.git/info/refs
added QmYFqkUdpTZ2TwbpRwnyo7K4zMu8Ep9wWTpKELxHS33qiQ helm-charts.git/objects/info/packs
added QmPd9zs6bXigRrxEfLgpdV7nRmGF6UDgGLBiU4jz1zyfm8 helm-charts.git/objects/pack/pack-7d12aca4cae291e85bdb043dcbc34cc5ecf55d2d.idx
added QmX7zrvm2e2cAkLiakS4r8bqWTW7u8onxLuS4BXNUrBzSz helm-charts.git/objects/pack/pack-7d12aca4cae291e85bdb043dcbc34cc5ecf55d2d.pack
added QmZ9Es1CLRGWzasb4w3QRfvUi7NowtA78QKVuZahrMF1ix helm-charts.git/packed-refs
added QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn helm-charts.git/branches
added QmUDWwEzg33DPr6NNxeBDKgHKaTcDfbVfJrog6HpzLXTvg helm-charts.git/hooks
added QmXpvGWuzK8rPGrC7sDGsy7USx6v5mWgm1zfD2FGek5mwT helm-charts.git/info
added QmPmEe7i3mFqU1DfkENkkH1to3QrWJD5UGSNJ7tmQ3cCUy helm-charts.git/objects/info
added QmaodTHHrn5BZY8zaq4Lpj5Af4CmnQKbYract1aadwP8Aq helm-charts.git/objects/pack
added QmfBTRmFNY3t5UP4s2bRFtmNAi4jiffcHBiokxxLeVEjzE helm-charts.git/objects
added QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn helm-charts.git/refs/heads
added QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn helm-charts.git/refs/tags
added QmWYtSEta2Fzgy4u4ttdwwiKMUikwZrFHxa5quWXMVyBhy helm-charts.git/refs
added QmVeBgcRdV5AapyRa8wcfLsk8y4xWxAL93mTmyCdrEynR5 helm-charts.git
491.68 KiB / 491.68 KiB [========================================================================================================================================================]

Nous pouvons maintenant cloner notre dépôt via notre nœud local :

# via notre passerelle locale
git clone http://QmVeBgcRdV5AapyRa8wcfLsk8y4xWxAL93mTmyCdrEynR5.ipfs.localhost:8080
# via une passerelle publique
git clone https://ipfs.io/ipfs/QmVeBgcRdV5AapyRa8wcfLsk8y4xWxAL93mTmyCdrEynR5/

Conclusion

J'ai découvert l'IPFS en lisant un article sur la censure de Wikipédia en Turquie. Je n'ai pas de réel cas d'usage en dehors de rendre mes articles accessibles le jour où je décide de fermer mon site. Je vous laisse vous faire votre propre avis et en trouver votre propre utilité.

En attendant, je pense archiver quelques projets dans le réseau et essayer de toujours garder au moins un nœud actif.

Liens en vrac

· 4 minutes de lecture
TheBidouilleur

Si vous visitez régulièrement mon blog, vous avez sans doute remarqué que j'ai un faible pour les projets basés sur Docker. J'utilise Docker dans mes infrastructures pour la reproductibilité, la simplicité des déploiements et la facilité de maintenance. (C'est d'ailleurs pour ces raisons que j'ai décidé de passer sur Kubernetes).

Cette année, j'ai écrit 2 pages permettant respectivement d'apprendre à créer des fichiers .deb et d'héberger un dépôt de paquets Debian avec Aptly. J'ai décidé de reprendre mon tutoriel concernant Aptly pour en faire un programme tout-en-un : Simple Debian-Repository.

Qu'est-ce que Simple Debian-Repository ?

Simple Debian-Repository est un projet en Bash qui va packager un-par-un les applicatifs dans src et les déposer dans repo-list sous forme de .deb avant de les publier sur un serveur web. Celui-ci gère également la signature des paquets en générant une clé GPG.

Comment lancer le projet ?

Le projet est plug and play, il suffit de lancer le Docker pour lancer la création du dépôt (et de la clé GPG). Une fois le dépôt créé, vous pouvez ajouter vos paquets dans le dossier src en respectant la nomenclature des fichiers .deb.

Exemple de lancement à partir d'un dossier vide :

asciicast

Par défaut, 2 programmes 'exemples' seront installés dans le dépôt : hello-world et goodbye-world. Vous pourrez les supprimer en effaçant les dossiers et .deb dans src et repo-list.

rm -r src/stable/hello-world src/unstable/goodbye-world/
rm repo-list/stable/hello-world_1.0.0-1_all.deb repo-list/unstable/goodbye-world_1.0.0-1_all.deb

Comment ajouter un paquet ?

Le projet propose 2 dépôts par défaut : stable et unstable. Vous pouvez ajouter autant de dépôts que vous le souhaitez en créant un dossier dans src et repo-list:

# Création du dépôt 'testing'
mkdir -p src/testing repo-list/testing

Vous pourrez trouver ma documentation à propos de la création de paquets Debian ici.

Le script va chercher les dossiers à packager dans src/NOM_APP/NOM_APP_VERSION-REV_ARCH.

C.-à-d. que si vous voulez packager l'application foo, vous devez d'abord créer un dossier foo dans src et créer le dossier qui correspondra à la version du paquet. Par exemple, pour packager la version 1.0.0 de l'application foo à la révision 1, il vous fraudra créer le dossier foo/foo_1.0.0-1_all dans src et y placer les fichiers nécessaires à la création du paquet.

Exemple de création de paquet foo :

mkdir -p src/stable/foo/foo_1.0.0-1_all
cd src/stable/foo/foo_1.0.0-1_all
mkdir DEBIAN
touch DEBIAN/control # Ajouter les informations du paquet
mkdir -p usr/bin
echo -e '#!/bin/bash\necho "bar"' > usr/bin/foo
chmod +x usr/bin/foo
astuce

Il est possible de placer des fichiers .deb dans repo-list/stable si vous voulez importer des paquets déjà packagés.

Customisation

N'étant vraiment pas très bon en programmation web, j'ai utilisé le code de Flexdinesh pour la page d'accueil.

Lors du lancement du projet, celui-ci va créer une page web affichant les étapes permettant d'ajouter le dépôt à votre machine. Il existe 2 variables d'environnements pour personnaliser cette page :

  • REPO_NAME : Nom du dépôt (celui-ci sera affiché dans la page d'accueil et permettra de nommer les fichiers dans les instructions)
  • WEB_URL : URL du dépôt (pour afficher la bonne URL dans les instructions)

Vous pourrez également modifier la couleur du fond de la page d'accueil en modifiant le fichier index.html dans le dossier html.

<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<title>REPO_NAME</title>

<link rel="icon" href="favicon.ico" type="image/png" />

<link href="https://fonts.googleapis.com/css?family=Reem+Kufi|Roboto:300" rel="stylesheet">
<link href="https://use.fontawesome.com/releases/v5.13.1/css/all.css" rel="stylesheet">
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/styles.css">
<!-- <link rel="stylesheet" href="css/themes/indigo-white.css"> -->
<!-- <link rel="stylesheet" href="css/themes/green-white.css"> -->
<!-- <link rel="stylesheet" href="css/themes/red-white.css"> -->
<!-- <link rel="stylesheet" href="css/themes/grey-white.css"> -->
<!-- <link rel="stylesheet" href="css/themes/white-indigo.css"> -->
<link rel="stylesheet" href="css/themes/white-blue.css">
<!-- <link rel="stylesheet" href="css/themes/white-grey.css"> -->
<!-- <link rel="stylesheet" href="css/themes/white-red.css"> -->
<!-- <link rel="stylesheet" href="css/themes/yellow-black.css"> -->
</head>

Vous n'avez qu'à dé-commenter la ligne correspondant au thème de votre choix.

Page d&#39;accueil

Note si vous doutez de mon choix de couleur par défaut : je suis daltonien. ;)

Tout comme le thème, les différents liens disponibles sur la page d'accueil doivent être modifiés dans le fichier index.html.

Conclusion

Simple Debian-Repository est un projet qui permet de déployer rapidement un dépôt de paquets Debian. Il est très simple à utiliser et à personnaliser. Si vous avez des suggestions d'amélioration, n'hésitez pas à me contacter sur Twitter ou à ouvrir une issue sur le projet. Celui-ci n'est pas adapté à un usage en production, mais il me suffit pour mes besoins personnels.

En créant ce projet, j'ai également créé un second dépôt Github, src-packages-deb pour stocker certaines applications avant de les packager en .deb. N'hésitez pas à y jeter un œil si vous souhaitez contribuer ou peupler votre dépôt avec quelques applications.

· 9 minutes de lecture
TheBidouilleur

Dagger.io est un projet qui a été annoncé il y a quelque temps par Solomon Hykes, la philosophie de Dagger a attiré mon attention.

C'est un service de CI/CD qui permet de lancer des jobs dans des conteneurs Docker. La plus-value de Dagger est qu'il ne se limite pas à du Yaml (Comme Gitlab-CI, Github Action, Drone.io) ou à un DSL maison (Comme Jenkins), il permet de lancer des jobs en utilisant du code Python, du Go, du Java.Typescript ou encore du GraphQL.

Il est un peu comme Pulumi mais pour les jobs de CI/CD. (Là où son concurrent Terraform utilise un DSL, Pulumi utilise le Typescript, Python, Java, etc)

Étant donné que j'utilise Github pour mes projets publics, Gitea pour mes projets privés (couplé à Drone) et Gitlab pour les projets professionnels, je me suis dit que c'était l'occasion de tester Dagger.io et de me débarrasser de mes fichiers Yaml ayant une syntaxe différente en fonction de la plateforme.

Mon idée derrière la conversion de mes jobs de CI/CD en code est également d'avoir les mêmes résultats entre les différentes plateformes et ma machine locale.

On va donc faire le point sur ce qu'est Dagger.io, comment l'installer et comment l'utiliser. Comme je suis habitué au langage Python, j'utiliserai alors le SDK Python de Dagger.io !

Installation de Dagger.io

Il sera nécessaire d'avoir un Python 3.10 ou supérieur pour utiliser Dagger.io (il est aussi possible d'utiliser un venv).

Pour installer Dagger.io, il n'y a rien de bien compliqué, il suffit d'installer le package via pip.

pip install dagger-io

Et c'est terminé pour l'installation.

ERROR: Could not find a version that satisfies the requirement dagger-io (from versions: none)

Si vous avez une erreur de ce type :

➜  ~ python3 -m pip install dagger-io 
Defaulting to user installation because normal site-packages is not writeable
Collecting dagger-io
Using cached dagger_io-0.4.2-py3-none-any.whl (52 kB)
Collecting cattrs>=22.2.0
[...]
Using cached mdurl-0.1.2-py3-none-any.whl (10.0 kB)
Collecting multidict>=4.0
Using cached multidict-6.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (114 kB)
ERROR: Exception:
Traceback (most recent call last):
File "/usr/lib/python3/dist-packages/pip/_internal/cli/base_command.py", line 165, in exc_logging_wrapper
status = run_func(*args)
File "/usr/lib/python3/dist-packages/pip/_internal/cli/req_command.py", line 205, in wrapper
return func(self, options, args)
File "/usr/lib/python3/dist-packages/pip/_internal/commands/install.py", line 389, in run
to_install = resolver.get_installation_order(requirement_set)
File "/usr/lib/python3/dist-packages/pip/_internal/resolution/resolvelib/resolver.py", line 188, in get_installation_order
weights = get_topological_weights(
File "/usr/lib/python3/dist-packages/pip/_internal/resolution/resolvelib/resolver.py", line 276, in get_topological_weights
assert len(weights) == expected_node_count
AssertionError

Il se peut que vous ayez une version trop ancienne de pip et setuptools. La solution est de mettre à jour pip et setuptools via la commande suivante :

pip install --upgrade pip setuptools

Si vous ne souhaitez pas travailler avec l'utilisateur root, il vous faudra configurer le mode Rootless de Docker. (C'est ce que j'ai fait) Pour cela, il suffit de suivre la documentation officielle.

Premier job

Pour commencer, nous allons créer un fichier hello-world.py et y ajouter le code suivant :

"""Execute a command."""
import sys
import anyio
import dagger

async def test():
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
python = (
client.container()
.from_("python:3.11-slim-buster")
.with_exec(["python", "-V"])
)
version = await python.stdout()
print(f"Hello from Dagger and {version}")

if __name__ == "__main__":
anyio.run(test)

Il s'agit d'un simple job qui va lancer un conteneur Docker avec l'image python:3.11-slim-buster et exécuter la commande python -V.

Pour lancer le job, il suffit de lancer avec python : python3 hello-world.py.

➜  python3 hello-world.py    
#1 resolve image config for docker.io/library/python:3.11-slim-buster
#1 DONE 1.7s
#2 importing cache manifest from dagger:10686922502337221602
#2 DONE 0.0s
#3 DONE 0.0s
#4 from python:3.11-slim-buster
#4 resolve docker.io/library/python:3.11-slim-buster
#4 resolve docker.io/library/python:3.11-slim-buster 0.2s done
#4 sha256:f0712d0bdb159c54d5bdce952fbb72c5a5d2a4399654d7f55b004d9fc01e189e 0B / 3.37MB 0.2s
#4 sha256:f0712d0bdb159c54d5bdce952fbb72c5a5d2a4399654d7f55b004d9fc01e189e 3.37MB / 3.37MB 0.3s done
#4 extracting sha256:80384e04044fa9b6493f2c9012fd1aa7035ab741147248930b5a2b72136198b1
#4 extracting sha256:80384e04044fa9b6493f2c9012fd1aa7035ab741147248930b5a2b72136198b1 0.3s done
#4 extracting sha256:f0712d0bdb159c54d5bdce952fbb72c5a5d2a4399654d7f55b004d9fc01e189e
#4 extracting sha256:f0712d0bdb159c54d5bdce952fbb72c5a5d2a4399654d7f55b004d9fc01e189e 0.2s done
#4 ...
#3
#3 0.224 Python 3.11.2
#3 DONE 0.3s

#4 from python:3.11-slim-buster
Hello from Dagger and Python 3.11.2

Félicitations, vous avez lancé votre premier job avec Dagger.io !

Maintenant, nous allons voir comment créer un script un peu plus complexe !

Dagger, Python et Docker

Jusque-là, nous n'avons pas beaucoup profité de la puissance de Python, ou même des fonctionnalités de Docker. Nous allons donc voir comment utiliser les deux ensemble.

Vous n'êtes pas sans savoir que j'utilise Docusaurus pour générer le code HTML que vous visionnez en ce moment même. Docusaurus me permet d'écrire mes articles en Markdown et de les transformer en site.

N'étant pas très regardant sur la qualité de mes Markdown, j'ai décidé de créer un job qui va vérifier la syntaxe de mes fichiers Markdown et me renvoyer une erreur s'il y a un problème sur l'un d'entre eux.

Pour cela, je vais utiliser pymarkdownlnt, un Linter assez strict et performant.

Son installation se fait via pip :

pip install pymarkdownlnt

Ainsi, notre job va devoir effectuer ces étapes de manière séquentielle :

  • Démarrer à partir d'une image Python (FROM python:3.10-slim-buster)
  • Installer pymarkdownlnt (RUN pip install pymarkdownlnt)
  • Récupérer les fichiers du projet (COPY . .)
  • Lancer le linter sur les fichiers Markdown de chaque dossier blog/ docs/ i18n/ (RUN pymarkdownlnt scan blog/-r)

Nous pouvons traduire les 3 premières étapes en code Python :

lint = (
client.container().from_("python:3.10-slim-buster")
.with_exec("pip install pymarkdownlnt".split(" "))
.with_mounted_directory("/data", src)
.with_workdir("/data")
)

Et ensuite… je souhaite faire une boucle itérant sur les dossiers blog/ docs/ i18n/ et lancer le linter sur chacun d'entre eux. C'est à ce moment précis que nous allons utiliser du Python et plus uniquement des instructions Dagger.

Un détail que je ne vous ai pas encore mentionné, c'est que nous pouvons agir sur notre job tant qu'il n'est pas lancé, c'est-à-dire avant le await qui va attendre la fin de l'exécution du job.

Donc… gardons la définition du conteneur ci-dessus, et ajoutons 3 tâches à notre job :

for i in ["blog", "docs", "i18n"]:
lint = lint.with_exec(["pymarkdownlnt", "scan", i, "-r"])

Plutôt simple, non ?

Si je lance mon job, j'ai de nombreuses erreurs à propos de règles que je n'ai pas respectées. Mais c'est normal, la syntaxe de Docusaurus cause des erreurs dans le linter que je ne peux pas corriger.

Je vais donc noter les règles qui ne s'appliquent pas à mes fichiers, et les ignorer :

lint_rules_to_ignore = ["MD013","MD003","MD041","MD022","MD023","MD033","MD019"]
# Format accepté par pymarkdownlint : "MD013,MD003,MD041,MD022,MD023,MD033,MD019"
for i in ["blog", "docs", "i18n"]:
lint = lint.with_exec(["pymarkdownlnt", "-d", str(','.join(lint_rules_to_ignore)), "scan", i, "-r"])

Voici notre script complet :

"""Markdown linting script."""
import sys
import anyio
import dagger
import threading

async def markdown_lint():
lint_rules_to_ignore = ["MD013","MD003","MD041","MD022","MD023","MD033","MD019"]

async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
src = client.host().directory("./")

lint = (
client.container().from_("python:3.10-slim-buster")
.with_exec("pip install pymarkdownlnt".split(" "))
.with_mounted_directory("/data", src)
.with_workdir("/data")
)

for i in ["blog", "docs", "i18n"]:
lint = lint.with_exec(["pymarkdownlnt", "-d", str(','.join(lint_rules_to_ignore)), "scan", i, "-r"])
# execute
await lint.stdout()
print(f"Markdown lint is FINISHED!")

if __name__ == "__main__":
try:
anyio.run(markdown_lint)
except:
print("Error in Linting")

Après cette modification, mon job fonctionne sans problème !

python3 .ci/markdown_lint.py

Récapitulons ce que nous savons faire :

  • Lancer une image Docker
  • Exécuter des commandes dans un conteneur
  • Copier des fichiers depuis l'hôte vers le conteneur

Je pense que ça suffira dans la plupart de mes CI. Néanmoins, il reste une fonctionnalité qui me manque : la possibilité de construire une image Docker et de l'envoyer sur un registre.

Build & push d'une image Docker

Il est possible de s'authentifier sur un registre directement via Dagger. Dans mon cas, je considère que l'hôte sur lequel je lance mon job est déjà authentifié.

Dans le cadre de cette démonstration, je vais utiliser le registre ttl.sh, un registre public et anonyme permettant justement de stocker des images Docker pendant une durée maximale de 24h.

async def docker_image_build():
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
src = client.host().directory("./")
build = (
client.container()
.build(
context = src,
dockerfile = "Dockerfile",
build_args=[
dagger.BuildArg("APP", os.environ.get("APP", "TheBidouilleurxyz"))
]
)
)
image = await blog.build(address="ttl.sh/thebidouilleur:1h")

Le code ci-dessus va donc construire mon image Docker à partir du fichier Dockerfile présent dans le dossier courant, et l'envoyer sur le registre ttl.sh/thebidouilleur:1h.

Une petite particularité de ce code est l'usage de Build Args. J'utilise la variable d'environnement APP, si cette variable n'est pas définie, je vais récupérer la valeur par défaut TheBidouilleurxyz.

Maintenant, je souhaite créer un job similaire qui va construire une image Docker multiarchitecture ARM et AMD64 (l'un de mes clusters Kubernetes est composé de Raspberry Pi).

Build & push d'une image Docker multiarchitecture

Il faudra déjà mettre au point le build multiarchitecture sur votre machine avant de pouvoir l'intégrer à notre job Dagger.

Si vous souhaitez savoir comment créer une image Docker multiarchitecture, je vous invite à lire ma documentation Création image Docker pour en connaitre la procédure.

On va utiliser un objet à mettre en paramètre à Dagger, celui-ci est dagger.Platform et permet de spécifier la plateforme sur laquelle on veut construire notre image Docker.

Nous créons une boucle qui va itérer sur les différentes architectures avec lesquelles on veut construire notre image, et lors du Publish, nous enverrons les différentes images construites.

async def docker_image_build():
platforms = ["linux/amd64", "linux/arm64"]
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
src = client.host().directory(".")
variants = []
for platform in platforms:
print(f"Building for {platform}")
platform = dagger.Platform(platform)
build = (
client.container(platform=platform)
.build(
context = src,
dockerfile = "Dockerfile"
)
)
variants.append(build)
await client.container().publish("ttl.sh/dagger_test:1h", platform_variants=variants)

Docker avec plusieurs architectures

Créer un lanceur

Maintenant que nous avons vu comment utiliser Dagger, nous allons créer un lanceur qui va nous permettre de lancer nos jobs un-par-un.

Pour lancer nos taches en asynchrone, nous utilisons la librairie anyio sur chacun de nos scripts.

import anyio

import markdown_lint
import docusaurus_build
import multi_arch_build as docker_build

if __name__ == "__main__":

print("Running tests in parallel using anyio")
anyio.run(markdown_lint.markdown_lint)
anyio.run(docusaurus_build.docusaurus_build)
anyio.run(docker_build.docker_build)

Ce lanceur va importer les méthodes des fonctions markdown_lint, docusaurus_build et docker_build des fichiers markdown_lint.py, docusaurus_build.py et multi_arch_build.py avant d'exécuter chacune de ces fonctions.

L'unique intérêt de ce lanceur est de pouvoir lancer nos jobs à partir d'une seule commande.

Conclusion

Dagger est un produit très prometteur ! Celui-ci n'arrivera surement pas à remplacer les solutions actuelles telles que Github Actions ou Gitlab CI, mais il répond à un besoin spécifique : celui d'avoir le même CI peu importe la plateforme.

Bref, Dagger est un produit qui mérite d'être testé et je pense que je vais l'utiliser pour la plupart de mes projets personnels.

J'espère que cet article vous aura plu, n'hésitez pas à me faire part de vos retours.

· 3 minutes de lecture
TheBidouilleur

Dès lors que nous exposons un service en ligne, celui-ci se fait harceler de bots et d'attaques en tout genre. Ces bots ont pour objectif de trouver la moindre petite faille pour obtenir un accès à votre serveur et en tirer quelque chose de lucratif (Minages, Botnet, Ransomware).

La bonne pratique est donc de ne pas pas exposer les services sensibles (et mettre en place un VPN/Tunnel SSH), mais certains cas nous obligent à bafouer cette règle.

Par exemple, si vous hébergez des WordPress, les pages administrateurs seront cibles d'attaques, les clients les plus exigeants voudront un Proxmox accessible depuis Internet, ou votre bastion est un simple serveur SSH.

Vous connaissez déjà les risques d'exposer ces accès sur Internet et je ne vais pas non-plus vous présenter de solutions universelles pour protéger vos services.

Le message que je souhaite vous faire passer est de dénoncer vos attaquants.

Et évidemment : je ne vous parle pas d'éplucher vos logs ligne-par-ligne pour récupérer les IPs suspectes.

Une solution simple et polyvalente est : fail2ban.

Fail2Ban

Fail2Ban est un programme très simple en Python qui va lire vos fichiers de log, extraire les tentatives de connection échouées via une regex, et agir en conséquence.

Par exemple, lire les tentatives d'authentification en SSH et bloquer temporairement les IPs via des règles IPTables. Ou envoyer un mail lorsqu'un utilisateur se trompe de mot de passe 3 fois sur votre Drupal.

Nativement, Fail2Ban peut surveiller Apache2, Postfix, proftpd et bien d'autres...

Mais créer vos règles n'est pas bien compliqué, on va donc créer 2 règles nous-même.

Fail2Ban avec Proxmox

Créez le filtre avec la regex identifiant les erreurs d'authentifications dans le fichier /etc/fail2ban/filter.d/proxmox.conf.

[Definition]
failregex = pvedaemon\[.*authentication failure; rhost=<HOST> user=.* msg=.*
ignoreregex =

Et enfin le fichier /etc/fail2ban/jail.d/proxmox.conf qui va définir les ports qui seront bloqués à l'IP suspecte et les fichiers de log à surveiller.

[proxmox]
enabled = true
port = https,http,8006
filter = proxmox
logpath = /var/log/daemon.log
maxretry = 3
bantime = 3600
action = %(action_)s

Vous pouvez vérifier la syntaxe et redémarrer fail2ban avec fail2ban-client reload.

Simple, non ? Maintenant, place à la délation !

Dénoncer les IPs suspectes

Lorsqu'un numéro suspect m'appelle, j'ai souvent le réflexe (inutile ?) de chercher le numéro sur Google et de voir si le numéro a déjà été signalé.

C'est pareil avec les adresses IP !

Une IP de Chine vient visiter votre blog ? 👀 C'est peut-être un Français habitant à l'étranger, ou un vilain robot qui cherche des adresses mails pour vous envoyer des spams/phishing.

Et vérifier si l'IP a une mauvaise réputation est la première chose à faire. C'est l'intérêt du site AbuseIPDB.

En créant un compte, vous pourrez signaler des IPs sur le site via l'IHM ou l'API.

Et c'est justement cette API qui va nous permettre de signaler automatiquement les adresses IP louches.

Et en plus, vous pourrez créer un super widget sur votre site pour montrer le nombre d'IP que vous avez signalé.

"AbuseIPDB Badge"

Et pour faire ce signalement automatique, il suffit de modifier vos jails sur Fail2Ban en ajoutant une action qui va faire un reporting sur AbuseIPDB:

action = %(action_)s
%(action_abuseipdb)s[abuseipdb_apikey="VOTRE_API_ABUSEIPDB", abuseipdb_category="18,21"]

... sachant que la catégorie 18 correspond aux attaques par brute-force, et 21 aux attaques sur pages WEB.


Ce genre de configuration ne va pas directement augmenter la sécurité de vos services, il faut garder en tête que c'est une action qui a simplement pour but de rendre la vie dure aux attaquants/méchants robots. Rendons le web plus sûr, sans se rajouter une charge de travail supplémentaire.

· 4 minutes de lecture
TheBidouilleur

Le déploiement, la virtualisation, le maintien d'une infrastructure Homelab sont des pratiques qui demandent du temps et également de l'argent. C'est pourquoi j'ai décidé de créer un petit billet que je peux ressortir pour expliquer le matériel que j'utilise.

Je vais donc partager les machines que j'utilise chez moi ou en cloud.

"Trop", n'est pas "bien"

Je ne souhaite pas décourager les gens qui débutent dans ce domaine. Il n'est nullement nécéssaire d'avoir autant de matériel, je suis un passionné (un chouia trop, je sais) qui n'hésite pas à mettre les moyens.

Vous pouvez obtenir de meilleurs résultats avec moins.

Mon infrastructure à la maison

J'ai emménagé dans mon appartement en 2022 à Lyon après 7 mois à Toulouse. Dans ma tête, cet emménagement est une liberté. La liberté de pouvoir reprendre l'informatique chez moi et plus uniquement en cloud.

Par ordre chronologique.. J'ai d'abord utilisé un vieux portable (Dont je n'ai plus la photo) sur lequel j'ai installé un Proxmox pour commencer à m'installer un cluster k3s avec des machines virtuelles.

Puis… j'ai re-découvert un vieux joujou : ma Raspberry Pi B+ !

J'ai donc eu l'idée de créer mon cluster k3s à la maison avec juste du matériel ARM. L'avantage premier est d'avoir une consommation basse et de travailler sur de vraies machines (Plus fun que d'avoir des VMs)

Cluster ARM

Je me suis alors procuré 4 machines, j'aurais aimé 4 Raspberry Pi 4, mais faute de moyens : je n'en ai obtenu que 2. Les deux autres machines sont des Rock64, des équivalents de Raspberry Pi 3 avec plus de RAM.

Interieur du cluster Exterieur du cluster

J'ai récupéré un vieil ampli Marshall pour l'aspect design. (Puis ça plait un peu à ma compagne, qui fait très attention à la déco de notre appartement) Je suis presque déçu de la quantité de câbles (alimentation du Switch, 4 câbles pour alimenter les nœuds, 1 câble Ethernet pour le Switch) mais je m'en contenterai pour le moment. à l'avenir, je bricolerai une alimentation pour me débarrasser de la multiprise et n'avoir qu'une unique prise.

Ce cluster héberge à la fois ce site ainsi que d'autres applications privées (Bitwarden, FreshRSS, un MQTT). J'aimerais beaucoup ajouter un serveur mail, mais ça sera l'occasion après un changement de FAI puisque Orange bloque le port 25 sur les connections domiciles.

Shuttle Proxmox

Shuttle

Mais même si mon attention se porte principalement sur le cluster, je voulais également avoir une machine pour héberger des applications plus "classiques" et sans conteneurs. J'ai donc acheté un mini-ordinateur Shuttle (ds57u3) sur lequel j'ai mis un SSD Samsung de 1To ainsi que 16Go de RAM.

Interface Proxmox

Sur cette machine, j'ai des VMs avec Home-Assistant, mon VPN (Wireguard), mon netboot et mon DHCP. Je voulais absolument avoir un hyperviseur pour continuer à manipuler Terraform.

Infrastructure Cloud

Proxmox OVH

Mes principales applications sont sur un serveur dédié OVH que je loue. (Pour ceux qui aiment les specs, j'ai un Xeon CPU E5-1620 de 8 cœurs avec 32Go de RAM) C'est un Proxmox (aussi) sur lequel j'ai mon serveur mail, un cluster de 4 noeuds K3S, mon serveur multimédia et pleins d'autres trucs.

Cette infrastructure est ce que je considère comme de la production à l'échelle de mon Homelab. Il doit être accessible 24/7 ! J'héberge différentes applications qui me sont indispensables à moi et/ou mes proches. (Par exemple, mes notes, le cloud de ma remarkable, mon kanboard)

Je stocke aussi des sauvegardes de mon infra HomeLab.

Oracle - FreeTier

Comme dernier serveur, j'utilise le cloud FreeTier avec une seule et unique machine avec 24Go de RAM. Sachant que je ne paye pas ce serveur, je considère qu'il peut être arrêté à tout moment. Celui-ci m'est utile comme machine de test. (Comme pour mon homelab, j'aurais voulu y héberger un serveur mail, mais le port 25 est également bloqué)

En plus des différents tests que j'exécute sur cette machine, j'y ai installé un conteneur avec un Guacamole relié à un XFCE pour être libre lorsque je suis en déplacement.

Conclusion ?

Je pense sincèrement que je n'ai pas besoin de toutes ces machines. J'essaye tant bien que mal de me limiter et de faire le plus attention à ma consommation électrique ainsi qu'à mon empreinte carbone. Je n'ai pas non-plus parlé de mon NAS qui pourrait faire l'objet d'un billet entier à lui-seul.

· 11 minutes de lecture
TheBidouilleur

Bonne année, Bonne santé ! Que la réussite et la santé soient avec vous ! J'espère que cette année sera riche en découvertes techniques.

Mais avant d'être trop heureux, attaquons avec une mauvaise nouvelle :

K8S@HOME est mort

Qu'est-ce que K8S@HOME ?

K8S-at-home est le nom d'une communauté promouvant l'usage de Kubernetes comme Homelab. La communauté possédait un énorme dépôt Helm maintenu par quelques membres. Sur ce dépôt Helm, on pouvait avoir la plupart des applications selfhosts utilisées dans les communautés Reddit/Discord. (Plex, Firefly, Bitwarden etc…)

K8S@HOME permettait donc de déployer de nombreuses applications via Helm, sans s'embêter à écrire des charts.

La fin de K8S@HOME

Si toute bonne chose a une fin : voici celle de K8S@HOME. Suite au manque de contributeurs, le dépôt est archivé et les charts ne seront plus maintenus.

Pour l'instant, ça ne veut pas dire que les Helm déjà déployés à partir de K8S@HOME doivent être arrêtés : les images Docker sont choisies dans les fichiers values.yaml, et s'il y a faille → ce seront les images Docker qu'il faudra mettre à jour, pas le chart.

En revanche, avec le temps : nous auront de plus en plus d'instructions obsolètes et nous ne bénéficierons pas des nouvelles fonctionnalités prévues par Helm.

La Solution : Créer notre dépôt Helm

Mais oui ! Tout comme nous prenons l'habitude de créer un registre Docker avec nos images. Nous pouvons créer un dépôt Helm avec nos charts.

Fonctionnement d'un chart Helm

De base, un chart Helm se compose de différents fichiers YAML qui seront appliqués via kubectl après un traitement de "templating". Ce traitement permet de remplacer des valeurs dans les fichiers (Les utilisateurs de Jinja2 ne seront pas perdus) via le fichier values.yaml (qui contient les valeurs de remplacement) et les fichiers _helpers.tpl (qui contiennent des fonctions pour traiter les valeurs).

Une fois que les valeurs de remplacements sont appliquées sur la template, on envoie les modifications au cluster. (via kubectl ou en communiquant avec l'API).

Exemple rapide :

#service.yml
apiVersion: v1
kind: Service
metadata:
name: baikal
labels:
{{- include "baikal.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "baikal.selectorLabels" . | nindent 4 }}
#values.yml
service:
type: ClusterIP
port: 80

Les fonctions sont appelées par le mot clé "include", et les valeurs du fichier values.yml sont appelées via le préfixe ".Values".

Vous pourrez apprendre à faire des charts Helm en suivant l'excellente documentation de Stéphane Robert : ici

Fonctionnement d'un dépôt Helm

Lorsque l'on ajoute un dépôt, on peut directement voir les charts disponibles dans ce dépôt :

helm repo add qjoly https://qjoly.github.io/helm-charts\
helm search repo qjoly

C'est grâce au dépôt qui contient un fichier index.yaml qui va répertorier les charts disponibles et les URLs permettant de les télécharger.

Schéma résumant le fonctionnement d&#39;un depot

Ainsi, lorsque l'on veut ajouter le chart "Joplin" dépôt "qjoly", notre client va aller chercher dans notre fichier index.yaml l'url de téléchargement (en tar.gz) du chart. Notre client Helm va ensuite faire le remplacement des valeurs avant d'envoyer le manifest dans notre cluster.

Création d'un dépôt Helm

Pour créer un dépôt helm, voici les différentes solutions :

  • Utiliser ChartMuseum
  • Utiliser l'image GitHub Action Chart-Releaser
  • à la main (en créant le fichier index.yaml manuellement)

Nous, nous passerons par l'image GitHub Action (je réserve une page sur ChartMuseum).

Usage de GitHub Action pour générer les releases

       - name: Run chart-releaser
uses: helm/chart-releaser-action@v1.5.0
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
Ne pas créer de secret

Il n'est pas nécessaire de créer un secret. (celui-ci est automatiquement généré durant le CI)

Grâce à cette image, CR va chercher dans le dossier charts/ pour générer l'index.yaml et le stocker directement dans la branche gh-pages. (pensez à activer GitHub-pages pour que le site soit accessible à https://votre-username.github.io/votre-projet)

L'avantage de Chart-Releaser est qu'il va automatiquement créer des releases pour chaque chart présent dans notre dépôt GitHub. Ce sont d'ailleurs ces mêmes releases qui seront accessibles depuis le fichier index.yaml. En revanche, CR ne pourra pas mettre à jour une version déjà existante. Si nous voulons refaire la version 1.5 du chart "Baikal", il n'en fera rien. Il faudra manuellement supprimer la release/tag avant de relancer le CI.

curl https://qjoly.github.io/helm-charts/index.yaml

Nous avons bien un fichier renvoyant les charts disponibles, une description, ainsi que l'URL où l'archive du chart est accessible.

Il est très bien possible de se contenter de ça, mais puisque nous le pouvons : allons plus-loin !

Tester les charts avant de générer la release. (CI)

Pour être sûr de ne pas envoyer des charts non fonctionnels, j'ai voulu m'appuyer sur du CI pour vérifier le bon-fonctionnement de mon code.

La première chose simple que nous pouvons faire... c'est d'utiliser le linter de Helm.

Vérification de la syntaxe

Selon Wikipedia:

Un linter est un outil qui analyse le code source pour signaler les erreurs de programmation, les bogues, les erreurs stylistiques et les constructions suspectes.

L'objectif est donc de vérifier (avant d'exécuter un code) que sa syntaxe est correcte et qu'il n'y a pas d'erreur évidente. Nous pouvons directement taper la commande helm lint ..

Exemple :

➜  baikal git:(main) helm lint .
==> Linting .
[INFO] Chart.yaml: icon is recommended

1 chart(s) linted, 0 chart(s) failed
➜ baikal git:(main)

Helm lint est CI-Friendly, il renvoie un exit-code différent de 0 lorsque le lintage (c'est mon article, j'invente les mots que je veux) n'est pas correct.

Pour tester l'intégralité de mes charts, j'ai écrit un petit script helm_lint.sh qui va effectuer la commande helm lint . dans chaque sous-dossier de charts/.

cd ../../charts
for d in *
do
echo "Testing $d "
(cd "$d" && helm lint )
if [ $? -ne 0 ]; then
echo "Error"
exit 1
fi
done

Ainsi, à la moindre erreur dans le script (si le lintage est mauvais), celui-ci s'arrête et renvoie l'exitcode à 1. (Ce qui va stopper le CI et générer une erreur)


Pour lancer ce script via GitHub Action, j'ai installé Helm via l'action "azure/setup-helm". Ce qui nous donne ces instructions à rajouter devant notre chart-releaser :

       - uses: azure/setup-helm@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}

- name: Helm Lint
run: |
cd .github/workflows/
./helm_lint.sh

Vérification du fonctionnement des charts

Si vérifier le lint se fait en quelques secondes, il est également possible de lancer le chart directement depuis le CI Github.

Pour cela, il faut au préalablement créer un cluster Kubernetes depuis Github Action. Et si cela est possible, c'est grâce à KIND (Kubernetes INside Docker) qui permet de faire un cluster virtuel dans des conteneurs Docker en seulement quelques commandes.

À rajouter dans notre CI:

       - name: Create k8s Kind Cluster
uses: helm/kind-action@v1.5.0

Maintenant que nos charts ont une syntaxe correcte, que nous avons un cluster fonctionnel, il faut y installer nos programmes un-par-un et les tester individuellement.

Vous rappelez-vous de helm_lint.sh ? Voici son grand-frère : helm_deploy.sh.

cd ../../charts
for d in *
do
echo "Deploying $d to kind"
(
set -x
cd "$d"
if [ -f ".no_ci" ]; then
echo "No CI for this chart."
else
helm install $d . --wait --timeout 120s
helm test $d
fi
)
if [ $? -ne 0 ]; then
echo "Error during deployment"
exit 1
else
echo "Success ! "
helm delete $d || true
fi
done

L'exécution de ce script va déployer chaque chart individuellement en lançant la commande helm test, permettant de lancer des tests (vérifier un port, vérifier le status d'une page web etc..). Si le test échoue, helm test renverra un exitcode à 1, et le script créera une erreur.

J'ai également la vérification de la présence d'un fichier .no_ci qui, comme son nom l'indique, permet de "skip" un chart. Cela permet de ne pas déployer certains charts dans le cluster de test. (Par exemple : mon chart plex-nfs qui ne peut pas fonctionner dans Github Action, ou un chart OpenLDAP).

Ressources ?

GitHub Action est limitée à 2000 minutes de CI mensuels. Avec une petite dizaine de charts, mes tests durent environ 5min. (soit 400 tests par mois)

Je suis conscient qu'à notre échelle : c'est suffisant. Mais à garder en tête si on commence à avoir un dépôt similaire à k8s-at-home.

Un README dynamique

Et pour rendre votre dépôt GitHub agréable pour vos utilisateurs, nous pouvons faire un README évoluant au fur et à mesure que vous créez vos charts.

L'idée est donc de créer un tableau comme celui-ci :

NameDescriptionChart VersionApp Version
baikalBaïkal is a lightweight CalDAV+CardDAV server0.1.60.9.2

Ces informations (nom, description, version) sont toutes accessibles depuis les fichiers Chart.yaml présents dans nos charts.

apiVersion: v2
name: baikal
description: Baïkal is a lightweight CalDAV+CardDAV server
type: application
version: 0.1.6
appVersion: "0.9.2"
keywords:
- baikal
home: https://sabre.io/baikal/
maintainers:
- email: github@thoughtless.eu
name: QJOLY
url: https://thebidouilleur.xyz
sources:
- https://github.com/sabre-io/Baikal
- https://github.com/QJoly/helm-charts

Du coup, vous rappelez-vous des scripts helm_lint.sh et helm_deploy.sh ? Eh bien voici le tonton : get_readme.py.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sys, logging, os, yaml
from pathlib import Path
from glob import glob
from yaml.loader import SafeLoader
from jinja2 import Template

def main():
files = glob('../../**/Chart.yaml', recursive=True)
charts = []
for chart in files:
with open(chart) as f:
data = yaml.load(f, Loader=SafeLoader)
print(f"nom : {data['name']} \ndescription: {data['description']}\nversion chart: {data['version']}\nversion app: {data['appVersion']}")
charts.append([data['name'],data['description'],data['version'], data['appVersion']])
print(f"Nombre de charts: {len(charts)}")
table_template=Path('table.j2').read_text()
tm = Template(table_template)
tableValue = tm.render({'charts':charts})
print("----")
readme_template=Path('./README.md.tmpl').read_text().replace("CHARTS_TABLE",tableValue).replace(""", '"')
print(readme_template)
Path("../../README.md").write_text(readme_template)

if __name__ == "__main__":
main()

Ce script Python va récupérer les différentes balises contenues dans les Charts.yaml, puis va générer un tableau Markdown à partir du fichier table.j2 (en jinja2, tout comme Helm), et va créer un README à partir du tableau ainsi que du fichier README.md.tmpl (contenant de la mise en page, et des informations supplémentaires).

Voici le résultat actuel :

Visuel actuel

à ajouter sur notre CI :

       - name: Modifying the readme on main
continue-on-error: true
run: |
git pull
git checkout main
cd .github/workflows/
python -m pip install -r requirements.txt
python3 get_readme.py
cd ../..
git add README.md
git commit -m ":lock: Auto-Update README with Charts versions"
git push

Créer une page d'accueil

Dans cet état, lorsque nous donnons l'URL du dépôt à ajouter aux clients helm de nos utilisateurs : ceux-ci accèderont sur une 404 (logique, le seul fichier créé est l'index.yaml). Mon idée est de reprendre les mêmes informations du README pour l'afficher sous forme de page web.

Si à la base, je voulais créer un système similaire au README (mais en HTML), j'ai opté pour la conversion du Markdown en HTML. Et un outil très utilisé est : pandoc

Nous allons donc utiliser Pandoc pour convertir notre readme en HTML, l'instruction est simple :

       - name: Setup Pandoc
uses: nikeee/setup-pandoc@v1

- name: Modifying index.html
continue-on-error: true
run: |
index=$(pandoc --from markdown_github README.md --to html5 --standalone --toc --citeproc --no-highlight)
git checkout gh-pages
echo $index > index.html
git add index.html
git commit -m "[AUTO] Update index.html of gh-pages"
git push

On ne peut pas dire que du grand art, mais le résultat est plutôt propre (dieu merci, Pandoc intègre du CSS).

Rendu du site

Dans mon cas, je rajoute même mon script JS de compteur de vues à cette étape.

Conclusion

Créer un dépôt Helm avec les petites modifications que je vous propose ne fera pas de vous un grand développeur (?) de charts. Mais ces outils vous permettrons de vous faciliter le travail, et de proposer une expérience agréable pour les personnes utilisant vos codes.

Il est toujours possible d'aller de plus en plus loin. Je pense notamment à RenovateBot qui peut vous proposer des modifications (ex: mettre à jour une image par défaut).

N'hésitez pas à me faire parvenir vos retours (mail/Twitter) ou vos propositions d'améliorations.

PS: Pour obtenir le CI complet (en reprenant chaque étape de cette page), vous pouvez visionner mon dépot ici.

· 42 minutes de lecture
TheBidouilleur

Introduction

On parle beaucoup de Terraform comme étant "la télécommande" du DevOps, celui-ci possède des chiffres assez conséquents : 2626 providers et 11397 modules.

Un providers ?

Un module est une intégration de Terraform avec un outil externe. On peut lancer un playbook, créer une instance sous AWS, ou même envoyer un message sur Slack.

J'utilise activement Terraform dans mes déploiements (création et/ou peuplement d'une VM sous Proxmox/LibVirt), mais depuis que mon infrastructure est basée sous Kubernetes, je me demande de la place que Terraform occupe dans mes déploiements. (Hors première installation du cluster)

Je me suis donc intéressé à Terraform et Kubernetes ensembles.

Les avantages de Kubernetes et Terraform ?

Kubernetes fonctionne bien sans Terraform, pourquoi commencer à rajouter des outils dans l'équation ?

Kubernetes souffre d'un grand mal : le YAML. Et même si j'adore le YAML (Vraiment, je veux pas retourner sur du JSON…) : celui-ci reste un simple format et non un réel langage de programmation.

C'est pourquoi le HCL peut potentiellement nous ouvrir des portes en proposant des intégrations à d'autres providers.

Un petit exemple en amuse-bouche

Une configmap en YAML

Si jamais je souhaite créer une configmap contenant un YAML pour une application. Voici le fichier que je souhaite stocker :

twitter=thebidouilleur
jobs=developper
favorite.meal=rougail
vehicule=electricunicycle

On peut créer notre fichier YAML avec la bonne entête, et indenter le contenu de notre fichier pour que YAML le reconnaisse comme un block de texte.

apiVersion: v1
kind: ConfigMap
metadata:
name: data-user
namespace: hcl
data:
data.ini: |
twitter=thebidouilleur
jobs=developper
favorite.meal=rougail
vehicule=electricunicycle

Facile, non?

Et maintenant on tente la même chose avec ce fichier ?
{
"pokemon": [{
"id": 1,
"num": "001",
"name": "Bulbasaur",
"img": "http://www.serebii.net/pokemongo/pokemon/001.png",
"type": [
"Grass",
"Poison"
],
"height": "0.71 m",
"weight": "6.9 kg",
"candy": "Bulbasaur Candy",
"candy_count": 25,
"egg": "2 km",
"spawn_chance": 0.69,
"avg_spawns": 69,
"spawn_time": "20:00",
"multipliers": [1.58],
"weaknesses": [
"Fire",
"Ice",
"Flying",
"Psychic"
],
"next_evolution": [{
"num": "002",
"name": "Ivysaur"
}, {
"num": "003",
"name": "Venusaur"
}]
}, {
"id": 2,
"num": "002",
"name": "Ivysaur",
"img": "http://www.serebii.net/pokemongo/pokemon/002.png",
"type": [
"Grass",
"Poison"
],
"height": "0.99 m",
"weight": "13.0 kg",
"candy": "Bulbasaur Candy",
"candy_count": 100,
"egg": "Not in Eggs",
"spawn_chance": 0.042,
"avg_spawns": 4.2,
"spawn_time": "07:00",
"multipliers": [
1.2,
1.6
],
"weaknesses": [
"Fire",
"Ice",
"Flying",
"Psychic"
],
"prev_evolution": [{
"num": "001",
"name": "Bulbasaur"
}],
"next_evolution": [{
"num": "003",
"name": "Venusaur"
}]
}, {
"id": 3,
"num": "003",
"name": "Venusaur",
"img": "http://www.serebii.net/pokemongo/pokemon/003.png",
"type": [
"Grass",
"Poison"
],
"height": "2.01 m",
"weight": "100.0 kg",
"candy": "Bulbasaur Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.017,
"avg_spawns": 1.7,
"spawn_time": "11:30",
"multipliers": null,
"weaknesses": [
"Fire",
"Ice",
"Flying",
"Psychic"
],
"prev_evolution": [{
"num": "001",
"name": "Bulbasaur"
}, {
"num": "002",
"name": "Ivysaur"
}]
}, {
"id": 4,
"num": "004",
"name": "Charmander",
"img": "http://www.serebii.net/pokemongo/pokemon/004.png",
"type": [
"Fire"
],
"height": "0.61 m",
"weight": "8.5 kg",
"candy": "Charmander Candy",
"candy_count": 25,
"egg": "2 km",
"spawn_chance": 0.253,
"avg_spawns": 25.3,
"spawn_time": "08:45",
"multipliers": [1.65],
"weaknesses": [
"Water",
"Ground",
"Rock"
],
"next_evolution": [{
"num": "005",
"name": "Charmeleon"
}, {
"num": "006",
"name": "Charizard"
}]
}, {
"id": 5,
"num": "005",
"name": "Charmeleon",
"img": "http://www.serebii.net/pokemongo/pokemon/005.png",
"type": [
"Fire"
],
"height": "1.09 m",
"weight": "19.0 kg",
"candy": "Charmander Candy",
"candy_count": 100,
"egg": "Not in Eggs",
"spawn_chance": 0.012,
"avg_spawns": 1.2,
"spawn_time": "19:00",
"multipliers": [1.79],
"weaknesses": [
"Water",
"Ground",
"Rock"
],
"prev_evolution": [{
"num": "004",
"name": "Charmander"
}],
"next_evolution": [{
"num": "006",
"name": "Charizard"
}]
}, {
"id": 6,
"num": "006",
"name": "Charizard",
"img": "http://www.serebii.net/pokemongo/pokemon/006.png",
"type": [
"Fire",
"Flying"
],
"height": "1.70 m",
"weight": "90.5 kg",
"candy": "Charmander Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.0031,
"avg_spawns": 0.31,
"spawn_time": "13:34",
"multipliers": null,
"weaknesses": [
"Water",
"Electric",
"Rock"
],
"prev_evolution": [{
"num": "004",
"name": "Charmander"
}, {
"num": "005",
"name": "Charmeleon"
}]
}, {
"id": 7,
"num": "007",
"name": "Squirtle",
"img": "http://www.serebii.net/pokemongo/pokemon/007.png",
"type": [
"Water"
],
"height": "0.51 m",
"weight": "9.0 kg",
"candy": "Squirtle Candy",
"candy_count": 25,
"egg": "2 km",
"spawn_chance": 0.58,
"avg_spawns": 58,
"spawn_time": "04:25",
"multipliers": [2.1],
"weaknesses": [
"Electric",
"Grass"
],
"next_evolution": [{
"num": "008",
"name": "Wartortle"
}, {
"num": "009",
"name": "Blastoise"
}]
}, {
"id": 8,
"num": "008",
"name": "Wartortle",
"img": "http://www.serebii.net/pokemongo/pokemon/008.png",
"type": [
"Water"
],
"height": "0.99 m",
"weight": "22.5 kg",
"candy": "Squirtle Candy",
"candy_count": 100,
"egg": "Not in Eggs",
"spawn_chance": 0.034,
"avg_spawns": 3.4,
"spawn_time": "07:02",
"multipliers": [1.4],
"weaknesses": [
"Electric",
"Grass"
],
"prev_evolution": [{
"num": "007",
"name": "Squirtle"
}],
"next_evolution": [{
"num": "009",
"name": "Blastoise"
}]
}, {
"id": 9,
"num": "009",
"name": "Blastoise",
"img": "http://www.serebii.net/pokemongo/pokemon/009.png",
"type": [
"Water"
],
"height": "1.60 m",
"weight": "85.5 kg",
"candy": "Squirtle Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.0067,
"avg_spawns": 0.67,
"spawn_time": "00:06",
"multipliers": null,
"weaknesses": [
"Electric",
"Grass"
],
"prev_evolution": [{
"num": "007",
"name": "Squirtle"
}, {
"num": "008",
"name": "Wartortle"
}]
}, {
"id": 10,
"num": "010",
"name": "Caterpie",
"img": "http://www.serebii.net/pokemongo/pokemon/010.png",
"type": [
"Bug"
],
"height": "0.30 m",
"weight": "2.9 kg",
"candy": "Caterpie Candy",
"candy_count": 12,
"egg": "2 km",
"spawn_chance": 3.032,
"avg_spawns": 303.2,
"spawn_time": "16:35",
"multipliers": [1.05],
"weaknesses": [
"Fire",
"Flying",
"Rock"
],
"next_evolution": [{
"num": "011",
"name": "Metapod"
}, {
"num": "012",
"name": "Butterfree"
}]
}, {
"id": 11,
"num": "011",
"name": "Metapod",
"img": "http://www.serebii.net/pokemongo/pokemon/011.png",
"type": [
"Bug"
],
"height": "0.71 m",
"weight": "9.9 kg",
"candy": "Caterpie Candy",
"candy_count": 50,
"egg": "Not in Eggs",
"spawn_chance": 0.187,
"avg_spawns": 18.7,
"spawn_time": "02:11",
"multipliers": [
3.55,
3.79
],
"weaknesses": [
"Fire",
"Flying",
"Rock"
],
"prev_evolution": [{
"num": "010",
"name": "Caterpie"
}],
"next_evolution": [{
"num": "012",
"name": "Butterfree"
}]
}, {
"id": 12,
"num": "012",
"name": "Butterfree",
"img": "http://www.serebii.net/pokemongo/pokemon/012.png",
"type": [
"Bug",
"Flying"
],
"height": "1.09 m",
"weight": "32.0 kg",
"candy": "Caterpie Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.022,
"avg_spawns": 2.2,
"spawn_time": "05:23",
"multipliers": null,
"weaknesses": [
"Fire",
"Electric",
"Ice",
"Flying",
"Rock"
],
"prev_evolution": [{
"num": "010",
"name": "Caterpie"
}, {
"num": "011",
"name": "Metapod"
}]
}, {
"id": 13,
"num": "013",
"name": "Weedle",
"img": "http://www.serebii.net/pokemongo/pokemon/013.png",
"type": [
"Bug",
"Poison"
],
"height": "0.30 m",
"weight": "3.2 kg",
"candy": "Weedle Candy",
"candy_count": 12,
"egg": "2 km",
"spawn_chance": 7.12,
"avg_spawns": 712,
"spawn_time": "02:21",
"multipliers": [
1.01,
1.09
],
"weaknesses": [
"Fire",
"Flying",
"Psychic",
"Rock"
],
"next_evolution": [{
"num": "014",
"name": "Kakuna"
}, {
"num": "015",
"name": "Beedrill"
}]
}, {
"id": 14,
"num": "014",
"name": "Kakuna",
"img": "http://www.serebii.net/pokemongo/pokemon/014.png",
"type": [
"Bug",
"Poison"
],
"height": "0.61 m",
"weight": "10.0 kg",
"candy": "Weedle Candy",
"candy_count": 50,
"egg": "Not in Eggs",
"spawn_chance": 0.44,
"avg_spawns": 44,
"spawn_time": "02:30",
"multipliers": [
3.01,
3.41
],
"weaknesses": [
"Fire",
"Flying",
"Psychic",
"Rock"
],
"prev_evolution": [{
"num": "013",
"name": "Weedle"
}],
"next_evolution": [{
"num": "015",
"name": "Beedrill"
}]
}, {
"id": 15,
"num": "015",
"name": "Beedrill",
"img": "http://www.serebii.net/pokemongo/pokemon/015.png",
"type": [
"Bug",
"Poison"
],
"height": "0.99 m",
"weight": "29.5 kg",
"candy": "Weedle Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.051,
"avg_spawns": 5.1,
"spawn_time": "04:50",
"multipliers": null,
"weaknesses": [
"Fire",
"Flying",
"Psychic",
"Rock"
],
"prev_evolution": [{
"num": "013",
"name": "Weedle"
}, {
"num": "014",
"name": "Kakuna"
}]
}, {
"id": 16,
"num": "016",
"name": "Pidgey",
"img": "http://www.serebii.net/pokemongo/pokemon/016.png",
"type": [
"Normal",
"Flying"
],
"height": "0.30 m",
"weight": "1.8 kg",
"candy": "Pidgey Candy",
"candy_count": 12,
"egg": "2 km",
"spawn_chance": 15.98,
"avg_spawns": 1.598,
"spawn_time": "01:34",
"multipliers": [
1.71,
1.92
],
"weaknesses": [
"Electric",
"Rock"
],
"next_evolution": [{
"num": "017",
"name": "Pidgeotto"
}, {
"num": "018",
"name": "Pidgeot"
}]
}, {
"id": 17,
"num": "017",
"name": "Pidgeotto",
"img": "http://www.serebii.net/pokemongo/pokemon/017.png",
"type": [
"Normal",
"Flying"
],
"height": "1.09 m",
"weight": "30.0 kg",
"candy": "Pidgey Candy",
"candy_count": 50,
"egg": "Not in Eggs",
"spawn_chance": 1.02,
"avg_spawns": 102,
"spawn_time": "01:30",
"multipliers": [1.79],
"weaknesses": [
"Electric",
"Rock"
],
"prev_evolution": [{
"num": "016",
"name": "Pidgey"
}],
"next_evolution": [{
"num": "018",
"name": "Pidgeot"
}]
}, {
"id": 18,
"num": "018",
"name": "Pidgeot",
"img": "http://www.serebii.net/pokemongo/pokemon/018.png",
"type": [
"Normal",
"Flying"
],
"height": "1.50 m",
"weight": "39.5 kg",
"candy": "Pidgey Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.13,
"avg_spawns": 13,
"spawn_time": "01:50",
"multipliers": null,
"weaknesses": [
"Electric",
"Rock"
],
"prev_evolution": [{
"num": "016",
"name": "Pidgey"
}, {
"num": "017",
"name": "Pidgeotto"
}]
}, {
"id": 19,
"num": "019",
"name": "Rattata",
"img": "http://www.serebii.net/pokemongo/pokemon/019.png",
"type": [
"Normal"
],
"height": "0.30 m",
"weight": "3.5 kg",
"candy": "Rattata Candy",
"candy_count": 25,
"egg": "2 km",
"spawn_chance": 13.05,
"avg_spawns": 1.305,
"spawn_time": "01:55",
"multipliers": [
2.55,
2.73
],
"weaknesses": [
"Fighting"
],
"next_evolution": [{
"num": "020",
"name": "Raticate"
}]
}, {
"id": 20,
"num": "020",
"name": "Raticate",
"img": "http://www.serebii.net/pokemongo/pokemon/020.png",
"type": [
"Normal"
],
"height": "0.71 m",
"weight": "18.5 kg",
"candy": "Rattata Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.41,
"avg_spawns": 41,
"spawn_time": "01:56",
"multipliers": null,
"weaknesses": [
"Fighting"
],
"prev_evolution": [{
"num": "019",
"name": "Rattata"
}]
}, {
"id": 21,
"num": "021",
"name": "Spearow",
"img": "http://www.serebii.net/pokemongo/pokemon/021.png",
"type": [
"Normal",
"Flying"
],
"height": "0.30 m",
"weight": "2.0 kg",
"candy": "Spearow Candy",
"candy_count": 50,
"egg": "2 km",
"spawn_chance": 4.73,
"avg_spawns": 473,
"spawn_time": "12:25",
"multipliers": [
2.66,
2.68
],
"weaknesses": [
"Electric",
"Rock"
],
"next_evolution": [{
"num": "022",
"name": "Fearow"
}]
}, {
"id": 22,
"num": "022",
"name": "Fearow",
"img": "http://www.serebii.net/pokemongo/pokemon/022.png",
"type": [
"Normal",
"Flying"
],
"height": "1.19 m",
"weight": "38.0 kg",
"candy": "Spearow Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.15,
"avg_spawns": 15,
"spawn_time": "01:11",
"multipliers": null,
"weaknesses": [
"Electric",
"Rock"
],
"prev_evolution": [{
"num": "021",
"name": "Spearow"
}]
}, {
"id": 23,
"num": "023",
"name": "Ekans",
"img": "http://www.serebii.net/pokemongo/pokemon/023.png",
"type": [
"Poison"
],
"height": "2.01 m",
"weight": "6.9 kg",
"candy": "Ekans Candy",
"candy_count": 50,
"egg": "5 km",
"spawn_chance": 2.27,
"avg_spawns": 227,
"spawn_time": "12:20",
"multipliers": [
2.21,
2.27
],
"weaknesses": [
"Ground",
"Psychic"
],
"next_evolution": [{
"num": "024",
"name": "Arbok"
}]
}, {
"id": 24,
"num": "024",
"name": "Arbok",
"img": "http://www.serebii.net/pokemongo/pokemon/024.png",
"type": [
"Poison"
],
"height": "3.51 m",
"weight": "65.0 kg",
"candy": "Ekans Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.072,
"avg_spawns": 7.2,
"spawn_time": "01:50",
"multipliers": null,
"weaknesses": [
"Ground",
"Psychic"
],
"prev_evolution": [{
"num": "023",
"name": "Ekans"
}]
}, {
"id": 25,
"num": "025",
"name": "Pikachu",
"img": "http://www.serebii.net/pokemongo/pokemon/025.png",
"type": [
"Electric"
],
"height": "0.41 m",
"weight": "6.0 kg",
"candy": "Pikachu Candy",
"candy_count": 50,
"egg": "2 km",
"spawn_chance": 0.21,
"avg_spawns": 21,
"spawn_time": "04:00",
"multipliers": [2.34],
"weaknesses": [
"Ground"
],
"next_evolution": [{
"num": "026",
"name": "Raichu"
}]
}, {
"id": 26,
"num": "026",
"name": "Raichu",
"img": "http://www.serebii.net/pokemongo/pokemon/026.png",
"type": [
"Electric"
],
"height": "0.79 m",
"weight": "30.0 kg",
"candy": "Pikachu Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.0076,
"avg_spawns": 0.76,
"spawn_time": "23:58",
"multipliers": null,
"weaknesses": [
"Ground"
],
"prev_evolution": [{
"num": "025",
"name": "Pikachu"
}]
}, {
"id": 27,
"num": "027",
"name": "Sandshrew",
"img": "http://www.serebii.net/pokemongo/pokemon/027.png",
"type": [
"Ground"
],
"height": "0.61 m",
"weight": "12.0 kg",
"candy": "Sandshrew Candy",
"candy_count": 50,
"egg": "5 km",
"spawn_chance": 1.11,
"avg_spawns": 111,
"spawn_time": "01:58",
"multipliers": [2.45],
"weaknesses": [
"Water",
"Grass",
"Ice"
],
"next_evolution": [{
"num": "028",
"name": "Sandslash"
}]
}, {
"id": 28,
"num": "028",
"name": "Sandslash",
"img": "http://www.serebii.net/pokemongo/pokemon/028.png",
"type": [
"Ground"
],
"height": "0.99 m",
"weight": "29.5 kg",
"candy": "Sandshrew Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.037,
"avg_spawns": 3.7,
"spawn_time": "12:34",
"multipliers": null,
"weaknesses": [
"Water",
"Grass",
"Ice"
],
"prev_evolution": [{
"num": "027",
"name": "Sandshrew"
}]
}, {
"id": 29,
"num": "029",
"name": "Nidoran ♀ (Female)",
"img": "http://www.serebii.net/pokemongo/pokemon/029.png",
"type": [
"Poison"
],
"height": "0.41 m",
"weight": "7.0 kg",
"candy": "Nidoran ♀ (Female) Candy",
"candy_count": 25,
"egg": "5 km",
"spawn_chance": 1.38,
"avg_spawns": 138,
"spawn_time": "01:51",
"multipliers": [
1.63,
2.48
],
"weaknesses": [
"Ground",
"Psychic"
],
"next_evolution": [{
"num": "030",
"name": "Nidorina"
}, {
"num": "031",
"name": "Nidoqueen"
}]
}, {
"id": 30,
"num": "030",
"name": "Nidorina",
"img": "http://www.serebii.net/pokemongo/pokemon/030.png",
"type": [
"Poison"
],
"height": "0.79 m",
"weight": "20.0 kg",
"candy": "Nidoran ♀ (Female) Candy",
"candy_count": 100,
"egg": "Not in Eggs",
"spawn_chance": 0.088,
"avg_spawns": 8.8,
"spawn_time": "07:22",
"multipliers": [
1.83,
2.48
],
"weaknesses": [
"Ground",
"Psychic"
],
"prev_evolution": [{
"num": "029",
"name": "Nidoran(Female)"
}],
"next_evolution": [{
"num": "031",
"name": "Nidoqueen"
}]
}, {
"id": 31,
"num": "031",
"name": "Nidoqueen",
"img": "http://www.serebii.net/pokemongo/pokemon/031.png",
"type": [
"Poison",
"Ground"
],
"height": "1.30 m",
"weight": "60.0 kg",
"candy": "Nidoran ♀ (Female) Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.012,
"avg_spawns": 1.2,
"spawn_time": "12:35",
"multipliers": null,
"weaknesses": [
"Water",
"Ice",
"Ground",
"Psychic"
],
"prev_evolution": [{
"num": "029",
"name": "Nidoran(Female)"
}, {
"num": "030",
"name": "Nidorina"
}]
}, {
"id": 32,
"num": "032",
"name": "Nidoran ♂ (Male)",
"img": "http://www.serebii.net/pokemongo/pokemon/032.png",
"type": [
"Poison"
],
"height": "0.51 m",
"weight": "9.0 kg",
"candy": "Nidoran ♂ (Male) Candy",
"candy_count": 25,
"egg": "5 km",
"spawn_chance": 1.31,
"avg_spawns": 131,
"spawn_time": "01:12",
"multipliers": [
1.64,
1.7
],
"weaknesses": [
"Ground",
"Psychic"
],
"next_evolution": [{
"num": "033",
"name": "Nidorino"
}, {
"num": "034",
"name": "Nidoking"
}]
}, {
"id": 33,
"num": "033",
"name": "Nidorino",
"img": "http://www.serebii.net/pokemongo/pokemon/033.png",
"type": [
"Poison"
],
"height": "0.89 m",
"weight": "19.5 kg",
"candy": "Nidoran ♂ (Male) Candy",
"candy_count": 100,
"egg": "Not in Eggs",
"spawn_chance": 0.083,
"avg_spawns": 8.3,
"spawn_time": "09:02",
"multipliers": [1.83],
"weaknesses": [
"Ground",
"Psychic"
],
"prev_evolution": [{
"num": "032",
"name": "Nidoran(Male)"
}],
"next_evolution": [{
"num": "034",
"name": "Nidoking"
}]
}, {
"id": 34,
"num": "034",
"name": "Nidoking",
"img": "http://www.serebii.net/pokemongo/pokemon/034.png",
"type": [
"Poison",
"Ground"
],
"height": "1.40 m",
"weight": "62.0 kg",
"candy": "Nidoran ♂ (Male) Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.017,
"avg_spawns": 1.7,
"spawn_time": "12:16",
"multipliers": null,
"weaknesses": [
"Water",
"Ice",
"Ground",
"Psychic"
],
"prev_evolution": [{
"num": "032",
"name": "Nidoran(Male)"
}, {
"num": "033",
"name": "Nidorino"
}]
}, {
"id": 35,
"num": "035",
"name": "Clefairy",
"img": "http://www.serebii.net/pokemongo/pokemon/035.png",
"type": [
"Normal"
],
"height": "0.61 m",
"weight": "7.5 kg",
"candy": "Clefairy Candy",
"candy_count": 50,
"egg": "2 km",
"spawn_chance": 0.92,
"avg_spawns": 92,
"spawn_time": "03:30",
"multipliers": [
2.03,
2.14
],
"weaknesses": [
"Fighting"
],
"next_evolution": [{
"num": "036",
"name": "Clefable"
}]
}, {
"id": 36,
"num": "036",
"name": "Clefable",
"img": "http://www.serebii.net/pokemongo/pokemon/036.png",
"type": [
"Normal"
],
"height": "1.30 m",
"weight": "40.0 kg",
"candy": "Clefairy Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.012,
"avg_spawns": 1.2,
"spawn_time": "03:29",
"multipliers": null,
"weaknesses": [
"Fighting"
],
"prev_evolution": [{
"num": "035",
"name": "Clefairy"
}]
}, {
"id": 37,
"num": "037",
"name": "Vulpix",
"img": "http://www.serebii.net/pokemongo/pokemon/037.png",
"type": [
"Fire"
],
"height": "0.61 m",
"weight": "9.9 kg",
"candy": "Vulpix Candy",
"candy_count": 50,
"egg": "5 km",
"spawn_chance": 0.22,
"avg_spawns": 22,
"spawn_time": "13:43",
"multipliers": [
2.74,
2.81
],
"weaknesses": [
"Water",
"Ground",
"Rock"
],
"next_evolution": [{
"num": "038",
"name": "Ninetales"
}]
}, {
"id": 38,
"num": "038",
"name": "Ninetales",
"img": "http://www.serebii.net/pokemongo/pokemon/038.png",
"type": [
"Fire"
],
"height": "1.09 m",
"weight": "19.9 kg",
"candy": "Vulpix Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.0077,
"avg_spawns": 0.77,
"spawn_time": "01:32",
"multipliers": null,
"weaknesses": [
"Water",
"Ground",
"Rock"
],
"prev_evolution": [{
"num": "037",
"name": "Vulpix"
}]
}, {
"id": 39,
"num": "039",
"name": "Jigglypuff",
"img": "http://www.serebii.net/pokemongo/pokemon/039.png",
"type": [
"Normal"
],
"height": "0.51 m",
"weight": "5.5 kg",
"candy": "Jigglypuff Candy",
"candy_count": 50,
"egg": "2 km",
"spawn_chance": 0.39,
"avg_spawns": 39,
"spawn_time": "08:46",
"multipliers": [1.85],
"weaknesses": [
"Fighting"
],
"next_evolution": [{
"num": "040",
"name": "Wigglytuff"
}]
}, {
"id": 40,
"num": "040",
"name": "Wigglytuff",
"img": "http://www.serebii.net/pokemongo/pokemon/040.png",
"type": [
"Normal"
],
"height": "0.99 m",
"weight": "12.0 kg",
"candy": "Jigglypuff Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.018,
"avg_spawns": 1.8,
"spawn_time": "12:28",
"multipliers": null,
"weaknesses": [
"Fighting"
],
"prev_evolution": [{
"num": "039",
"name": "Jigglypuff"
}]
}, {
"id": 41,
"num": "041",
"name": "Zubat",
"img": "http://www.serebii.net/pokemongo/pokemon/041.png",
"type": [
"Poison",
"Flying"
],
"height": "0.79 m",
"weight": "7.5 kg",
"candy": "Zubat Candy",
"candy_count": 50,
"egg": "2 km",
"spawn_chance": 6.52,
"avg_spawns": 652,
"spawn_time": "12:28",
"multipliers": [
2.6,
3.67
],
"weaknesses": [
"Electric",
"Ice",
"Psychic",
"Rock"
],
"next_evolution": [{
"num": "042",
"name": "Golbat"
}]
}, {
"id": 42,
"num": "042",
"name": "Golbat",
"img": "http://www.serebii.net/pokemongo/pokemon/042.png",
"type": [
"Poison",
"Flying"
],
"height": "1.60 m",
"weight": "55.0 kg",
"candy": "Zubat Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.42,
"avg_spawns": 42,
"spawn_time": "02:15",
"multipliers": null,
"weaknesses": [
"Electric",
"Ice",
"Psychic",
"Rock"
],
"prev_evolution": [{
"num": "041",
"name": "Zubat"
}]
}, {
"id": 43,
"num": "043",
"name": "Oddish",
"img": "http://www.serebii.net/pokemongo/pokemon/043.png",
"type": [
"Grass",
"Poison"
],
"height": "0.51 m",
"weight": "5.4 kg",
"candy": "Oddish Candy",
"candy_count": 25,
"egg": "5 km",
"spawn_chance": 1.02,
"avg_spawns": 102,
"spawn_time": "03:58",
"multipliers": [1.5],
"weaknesses": [
"Fire",
"Ice",
"Flying",
"Psychic"
],
"next_evolution": [{
"num": "044",
"name": "Gloom"
}, {
"num": "045",
"name": "Vileplume"
}]
}, {
"id": 44,
"num": "044",
"name": "Gloom",
"img": "http://www.serebii.net/pokemongo/pokemon/044.png",
"type": [
"Grass",
"Poison"
],
"height": "0.79 m",
"weight": "8.6 kg",
"candy": "Oddish Candy",
"candy_count": 100,
"egg": "Not in Eggs",
"spawn_chance": 0.064,
"avg_spawns": 6.4,
"spawn_time": "11:33",
"multipliers": [1.49],
"weaknesses": [
"Fire",
"Ice",
"Flying",
"Psychic"
],
"prev_evolution": [{
"num": "043",
"name": "Oddish"
}],
"next_evolution": [{
"num": "045",
"name": "Vileplume"
}]
}, {
"id": 45,
"num": "045",
"name": "Vileplume",
"img": "http://www.serebii.net/pokemongo/pokemon/045.png",
"type": [
"Grass",
"Poison"
],
"height": "1.19 m",
"weight": "18.6 kg",
"candy": "Oddish Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.0097,
"avg_spawns": 0.97,
"spawn_time": "23:58",
"multipliers": null,
"weaknesses": [
"Fire",
"Ice",
"Flying",
"Psychic"
],
"prev_evolution": [{
"num": "043",
"name": "Oddish"
}, {
"num": "044",
"name": "Gloom"
}]
}, {
"id": 46,
"num": "046",
"name": "Paras",
"img": "http://www.serebii.net/pokemongo/pokemon/046.png",
"type": [
"Bug",
"Grass"
],
"height": "0.30 m",
"weight": "5.4 kg",
"candy": "Paras Candy",
"candy_count": 50,
"egg": "5 km",
"spawn_chance": 2.36,
"avg_spawns": 236,
"spawn_time": "01:42",
"multipliers": [2.02],
"weaknesses": [
"Fire",
"Ice",
"Poison",
"Flying",
"Bug",
"Rock"
],
"next_evolution": [{
"num": "047",
"name": "Parasect"
}]
}, {
"id": 47,
"num": "047",
"name": "Parasect",
"img": "http://www.serebii.net/pokemongo/pokemon/047.png",
"type": [
"Bug",
"Grass"
],
"height": "0.99 m",
"weight": "29.5 kg",
"candy": "Paras Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.074,
"avg_spawns": 7.4,
"spawn_time": "01:22",
"multipliers": null,
"weaknesses": [
"Fire",
"Ice",
"Poison",
"Flying",
"Bug",
"Rock"
],
"prev_evolution": [{
"num": "046",
"name": "Paras"
}]
}, {
"id": 48,
"num": "048",
"name": "Venonat",
"img": "http://www.serebii.net/pokemongo/pokemon/048.png",
"type": [
"Bug",
"Poison"
],
"height": "0.99 m",
"weight": "30.0 kg",
"candy": "Venonat Candy",
"candy_count": 50,
"egg": "5 km",
"spawn_chance": 2.28,
"avg_spawns": 228,
"spawn_time": "02:31",
"multipliers": [
1.86,
1.9
],
"weaknesses": [
"Fire",
"Flying",
"Psychic",
"Rock"
],
"next_evolution": [{
"num": "049",
"name": "Venomoth"
}]
}, {
"id": 49,
"num": "049",
"name": "Venomoth",
"img": "http://www.serebii.net/pokemongo/pokemon/049.png",
"type": [
"Bug",
"Poison"
],
"height": "1.50 m",
"weight": "12.5 kg",
"candy": "Venonat Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.072,
"avg_spawns": 7.2,
"spawn_time": "23:40",
"multipliers": null,
"weaknesses": [
"Fire",
"Flying",
"Psychic",
"Rock"
],
"prev_evolution": [{
"num": "048",
"name": "Venonat"
}]
}, {
"id": 50,
"num": "050",
"name": "Diglett",
"img": "http://www.serebii.net/pokemongo/pokemon/050.png",
"type": [
"Ground"
],
"height": "0.20 m",
"weight": "0.8 kg",
"candy": "Diglett Candy",
"candy_count": 50,
"egg": "5 km",
"spawn_chance": 0.40,
"avg_spawns": 40,
"spawn_time": "02:22",
"multipliers": [2.69],
"weaknesses": [
"Water",
"Grass",
"Ice"
],
"next_evolution": [{
"num": "051",
"name": "Dugtrio"
}]
}, {
"id": 51,
"num": "051",
"name": "Dugtrio",
"img": "http://www.serebii.net/pokemongo/pokemon/051.png",
"type": [
"Ground"
],
"height": "0.71 m",
"weight": "33.3 kg",
"candy": "Dugtrio",
"egg": "Not in Eggs",
"spawn_chance": 0.014,
"avg_spawns": 1.4,
"spawn_time": "12:37",
"multipliers": null,
"weaknesses": [
"Water",
"Grass",
"Ice"
],
"prev_evolution": [{
"num": "050",
"name": "Diglett"
}]
}, {
"id": 52,
"num": "052",
"name": "Meowth",
"img": "http://www.serebii.net/pokemongo/pokemon/052.png",
"type": [
"Normal"
],
"height": "0.41 m",
"weight": "4.2 kg",
"candy": "Meowth Candy",
"candy_count": 50,
"egg": "5 km",
"spawn_chance": 0.86,
"avg_spawns": 86,
"spawn_time": "02:54",
"multipliers": [1.98],
"weaknesses": [
"Fighting"
],
"next_evolution": [{
"num": "053",
"name": "Persian"
}]
}, {
"id": 53,
"num": "053",
"name": "Persian",
"img": "http://www.serebii.net/pokemongo/pokemon/053.png",
"type": [
"Normal"
],
"height": "0.99 m",
"weight": "32.0 kg",
"candy": "Meowth Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.022,
"avg_spawns": 2.2,
"spawn_time": "02:44",
"multipliers": null,
"weaknesses": [
"Fighting"
],
"prev_evolution": [{
"num": "052",
"name": "Meowth"
}]
}, {
"id": 54,
"num": "054",
"name": "Psyduck",
"img": "http://www.serebii.net/pokemongo/pokemon/054.png",
"type": [
"Water"
],
"height": "0.79 m",
"weight": "19.6 kg",
"candy": "Psyduck Candy",
"candy_count": 50,
"egg": "5 km",
"spawn_chance": 2.54,
"avg_spawns": 254,
"spawn_time": "03:41",
"multipliers": [2.27],
"weaknesses": [
"Electric",
"Grass"
],
"next_evolution": [{
"num": "055",
"name": "Golduck"
}]
}, {
"id": 55,
"num": "055",
"name": "Golduck",
"img": "http://www.serebii.net/pokemongo/pokemon/055.png",
"type": [
"Water"
],
"height": "1.70 m",
"weight": "76.6 kg",
"candy": "Psyduck Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.087,
"avg_spawns": 8.7,
"spawn_time": "23:06",
"multipliers": null,
"weaknesses": [
"Electric",
"Grass"
],
"prev_evolution": [{
"num": "054",
"name": "Psyduck"
}]
}, {
"id": 56,
"num": "056",
"name": "Mankey",
"img": "http://www.serebii.net/pokemongo/pokemon/056.png",
"type": [
"Fighting"
],
"height": "0.51 m",
"weight": "28.0 kg",
"candy": "Mankey Candy",
"candy_count": 50,
"egg": "5 km",
"spawn_chance": 0.92,
"avg_spawns": 92,
"spawn_time": "12:52",
"multipliers": [
2.17,
2.28
],
"weaknesses": [
"Flying",
"Psychic",
"Fairy"
],
"next_evolution": [{
"num": "057",
"name": "Primeape"
}]
}, {
"id": 57,
"num": "057",
"name": "Primeape",
"img": "http://www.serebii.net/pokemongo/pokemon/057.png",
"type": [
"Fighting"
],
"height": "0.99 m",
"weight": "32.0 kg",
"candy": "Mankey Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.031,
"avg_spawns": 3.1,
"spawn_time": "12:33",
"multipliers": null,
"weaknesses": [
"Flying",
"Psychic",
"Fairy"
],
"prev_evolution": [{
"num": "056",
"name": "Mankey"
}]
}, {
"id": 58,
"num": "058",
"name": "Growlithe",
"img": "http://www.serebii.net/pokemongo/pokemon/058.png",
"type": [
"Fire"
],
"height": "0.71 m",
"weight": "19.0 kg",
"candy": "Growlithe Candy",
"candy_count": 50,
"egg": "5 km",
"spawn_chance": 0.92,
"avg_spawns": 92,
"spawn_time": "03:57",
"multipliers": [
2.31,
2.36
],
"weaknesses": [
"Water",
"Ground",
"Rock"
],
"next_evolution": [{
"num": "059",
"name": "Arcanine"
}]
}, {
"id": 59,
"num": "059",
"name": "Arcanine",
"img": "http://www.serebii.net/pokemongo/pokemon/059.png",
"type": [
"Fire"
],
"height": "1.91 m",
"weight": "155.0 kg",
"candy": "Growlithe Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.017,
"avg_spawns": 1.7,
"spawn_time": "03:11",
"multipliers": null,
"weaknesses": [
"Water",
"Ground",
"Rock"
],
"prev_evolution": [{
"num": "058",
"name": "Growlithe"
}]
}, {
"id": 60,
"num": "060",
"name": "Poliwag",
"img": "http://www.serebii.net/pokemongo/pokemon/060.png",
"type": [
"Water"
],
"height": "0.61 m",
"weight": "12.4 kg",
"candy": "Poliwag Candy",
"candy_count": 25,
"egg": "5 km",
"spawn_chance": 2.19,
"avg_spawns": 219,
"spawn_time": "03:40",
"multipliers": [
1.72,
1.73
],
"weaknesses": [
"Electric",
"Grass"
],
"next_evolution": [{
"num": "061",
"name": "Poliwhirl"
}, {
"num": "062",
"name": "Poliwrath"
}]
}, {
"id": 61,
"num": "061",
"name": "Poliwhirl",
"img": "http://www.serebii.net/pokemongo/pokemon/061.png",
"type": [
"Water"
],
"height": "0.99 m",
"weight": "20.0 kg",
"candy": "Poliwag Candy",
"candy_count": 100,
"egg": "Not in Eggs",
"spawn_chance": 0.13,
"avg_spawns": 13,
"spawn_time": "09:14",
"multipliers": [1.95],
"weaknesses": [
"Electric",
"Grass"
],
"prev_evolution": [{
"num": "060",
"name": "Poliwag"
}],
"next_evolution": [{
"num": "062",
"name": "Poliwrath"
}]
}, {
"id": 62,
"num": "062",
"name": "Poliwrath",
"img": "http://www.serebii.net/pokemongo/pokemon/062.png",
"type": [
"Water",
"Fighting"
],
"height": "1.30 m",
"weight": "54.0 kg",
"candy": "Poliwag Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.011,
"avg_spawns": 1.1,
"spawn_time": "01:32",
"multipliers": null,
"weaknesses": [
"Electric",
"Grass",
"Flying",
"Psychic",
"Fairy"
],
"prev_evolution": [{
"num": "060",
"name": "Poliwag"
}, {
"num": "061",
"name": "Poliwhirl"
}]
}, {
"id": 63,
"num": "063",
"name": "Abra",
"img": "http://www.serebii.net/pokemongo/pokemon/063.png",
"type": [
"Psychic"
],
"height": "0.89 m",
"weight": "19.5 kg",
"candy": "Abra Candy",
"candy_count": 25,
"egg": "5 km",
"spawn_chance": 0.42,
"avg_spawns": 42,
"spawn_time": "04:30",
"multipliers": [
1.36,
1.95
],
"weaknesses": [
"Bug",
"Ghost",
"Dark"
],
"next_evolution": [{
"num": "064",
"name": "Kadabra"
}, {
"num": "065",
"name": "Alakazam"
}]
}, {
"id": 64,
"num": "064",
"name": "Kadabra",
"img": "http://www.serebii.net/pokemongo/pokemon/064.png",
"type": [
"Psychic"
],
"height": "1.30 m",
"weight": "56.5 kg",
"candy": "Abra Candy",
"candy_count": 100,
"egg": "Not in Eggs",
"spawn_chance": 0.027,
"avg_spawns": 2.7,
"spawn_time": "11:25",
"multipliers": [1.4],
"weaknesses": [
"Bug",
"Ghost",
"Dark"
],
"prev_evolution": [{
"num": "063",
"name": "Abra"
}],
"next_evolution": [{
"num": "065",
"name": "Alakazam"
}]
}, {
"id": 65,
"num": "065",
"name": "Alakazam",
"img": "http://www.serebii.net/pokemongo/pokemon/065.png",
"type": [
"Psychic"
],
"height": "1.50 m",
"weight": "48.0 kg",
"candy": "Abra Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.0073,
"avg_spawns": 0.73,
"spawn_time": "12:33",
"multipliers": null,
"weaknesses": [
"Bug",
"Ghost",
"Dark"
],
"prev_evolution": [{
"num": "063",
"name": "Abra"
}, {
"num": "064",
"name": "Kadabra"
}]
}, {
"id": 66,
"num": "066",
"name": "Machop",
"img": "http://www.serebii.net/pokemongo/pokemon/066.png",
"type": [
"Fighting"
],
"height": "0.79 m",
"weight": "19.5 kg",
"candy": "Machop Candy",
"candy_count": 25,
"egg": "5 km",
"spawn_chance": 0.49,
"avg_spawns": 49,
"spawn_time": "01:55",
"multipliers": [
1.64,
1.65
],
"weaknesses": [
"Flying",
"Psychic",
"Fairy"
],
"next_evolution": [{
"num": "067",
"name": "Machoke"
}, {
"num": "068",
"name": "Machamp"
}]
}, {
"id": 67,
"num": "067",
"name": "Machoke",
"img": "http://www.serebii.net/pokemongo/pokemon/067.png",
"type": [
"Fighting"
],
"height": "1.50 m",
"weight": "70.5 kg",
"candy": "Machop Candy",
"candy_count": 100,
"egg": "Not in Eggs",
"spawn_chance": 0.034,
"avg_spawns": 3.4,
"spawn_time": "10:32",
"multipliers": [1.7],
"weaknesses": [
"Flying",
"Psychic",
"Fairy"
],
"prev_evolution": [{
"num": "066",
"name": "Machop"
}],
"next_evolution": [{
"num": "068",
"name": "Machamp"
}]
}, {
"id": 68,
"num": "068",
"name": "Machamp",
"img": "http://www.serebii.net/pokemongo/pokemon/068.png",
"type": [
"Fighting"
],
"height": "1.60 m",
"weight": "130.0 kg",
"candy": "Machop Candy",
"egg": "Not in Eggs",
"spawn_chance": 0.0068,
"avg_spawns": 0.68,
"spawn_time": "02:55",
"multipliers": null,
"weaknesses": [
"Flying",
"Psychic",
"Fairy"
],
"prev_evolution": [{
"num": "066",
"name": "Machop"
}, {
"num": "067",
"name": "Machoke"
}]
}, {
"id": 69,
"num": "069",
"name": "Bellsprout",
"img": "http://www.serebii.net/pokemongo/pokemon/069.png",
"type": [
"Grass",
"Poison"
],
"height": "0.71 m",
"weight": "4.0 kg",
"candy": "Bellsprout Candy",
"candy_count": 25,
"egg": "5 km",
"spawn_chance": 1.15,
"avg_spawns": 115,
"spawn_time": "04:10",
"multipliers": [1.57],
"weaknesses": [
"Fire",
"Ice",
"Flying",
"Psychic"
],
"next_evolution": [{
"num": "070",
"name": "Weepinbell"
}, {
"num": "071",
"name": "Victreebel"
}]
}, {
"id": 70,
"num": "070",
"name": "Weepinbell",
"img": "http://www.serebii.net/pokemongo/pokemon/070.png",
"type": [
"Grass",
"Poison"
],
"height": "0.99 m",
"weight": "6.4 kg",
"candy": "Bellsprout Candy",
"candy_count": 100,
"egg": "Not in Eggs",
"spawn_chance": 0.072,
"avg_spawns": 7.2,
"spawn_time": "09:45",
"multipliers": [1.59],
"weaknesses": [
"Fire",
"Ice",
"Flying",
"Psychic"
],
"prev_evolution": [{
"num": "069",
"name": "Bellsprout"
}],
"next_evolution": [{
"num": "071",
"name": "Victreebel"
}]
}, {
"id": 71,
"num": "071",
"name": "Victreebel",
"img": "http://www.serebii.net/pokemongo/pokemon/071.png",
"type": [
"Grass",
"Poison"
],
"height": "1.70 m",
"weight": "15.5 kg",