Apprenez Metaflow en 10 minutes

Metaflow est un framework Python crĂ©Ă© dans Netflix et axĂ© sur le domaine de la science des donnĂ©es. À savoir, il est conçu pour crĂ©er des projets visant Ă  travailler avec des donnĂ©es et pour gĂ©rer de tels projets. RĂ©cemment, la sociĂ©tĂ© l'a transfĂ©rĂ© dans la catĂ©gorie open-source. Le framework Metaflow a Ă©tĂ© largement utilisĂ© au sein de Netflix au cours des 2 derniĂšres annĂ©es. En particulier, il a permis de rĂ©duire considĂ©rablement le temps nĂ©cessaire Ă  la conclusion des projets en production.



Le matériel que nous traduisons aujourd'hui est un guide rapide de Metaflow.

Qu'est-ce que Metaflow?


Vous trouverez ci-dessous un graphique illustrant la mise en Ɠuvre du cadre Metaflow dans Netflix.


Implémentation de Metaflow dans Netflix

En novembre 2018, ce cadre a été utilisé dans 134 projets de l'entreprise.

Metaflow est un cadre pour créer et exécuter des workflows de science des données. Il présente les fonctionnalités suivantes:

  • Gestion des ressources informatiques.
  • Lancement de tĂąche conteneurisĂ©e.
  • Gestion des dĂ©pendances externes.
  • Gestion des versions, rĂ©exĂ©cution des tĂąches, poursuite de l'exĂ©cution des tĂąches suspendues.
  • API client pour examiner les rĂ©sultats des tĂąches qui peuvent ĂȘtre utilisĂ©es dans l'environnement Jupyter Notebook.
  • Prise en charge de l'exĂ©cution de tĂąches locales (par exemple, sur un ordinateur portable) et Ă  distance (dans le cloud). PossibilitĂ© de basculer entre ces modes.

L'utilisateur vtuulos a Ă©crit sur Ycombinator que Metaflow peut automatiquement crĂ©er des instantanĂ©s (instantanĂ©s) de code, de donnĂ©es et de dĂ©pendances. Tout cela est placĂ© dans un rĂ©fĂ©rentiel avec adressage par contenu, qui est gĂ©nĂ©ralement basĂ© sur S3, bien que le systĂšme de fichiers local soit Ă©galement pris en charge. Cela vous permet de continuer Ă  exĂ©cuter des tĂąches arrĂȘtĂ©es, de reproduire les rĂ©sultats prĂ©cĂ©demment obtenus et d'explorer tout ce qui concerne les tĂąches, par exemple dans le bloc-notes Jupyter.

En gĂ©nĂ©ral, nous pouvons dire que Metaflow vise Ă  augmenter la productivitĂ© des scientifiques de donnĂ©es. Cela est dĂ» au fait que le cadre leur permet de s'engager exclusivement dans le travail avec des donnĂ©es, sans ĂȘtre distrait par la rĂ©solution de tĂąches connexes. De plus, Metaflow accĂ©lĂšre le retrait des projets basĂ©s sur celui-ci en production.


Les besoins d'un data scientist liés à ses responsabilités directes et la solution des tùches auxiliaires liées à l'infrastructure sur laquelle les calculs sont effectués

Scénarios de workflow avec Metaflow


Voici quelques scénarios de workflow que vous pouvez organiser à l'aide de Metaflow:

  • Collaboration Un data scientist veut aider un autre Ă  trouver la source de l'erreur. Dans le mĂȘme temps, l'assistant souhaite tĂ©lĂ©charger sur son ordinateur l'ensemble de l'environnement dans lequel la tĂąche qui s'est Ă©crasĂ©e a fonctionnĂ©.
  • Poursuite des tĂąches arrĂȘtĂ©es depuis l'endroit oĂč elles ont Ă©tĂ© arrĂȘtĂ©es. Une tĂąche s'est arrĂȘtĂ©e avec une erreur (ou a Ă©tĂ© arrĂȘtĂ©e intentionnellement). L'erreur a Ă©tĂ© corrigĂ©e (ou le code a Ă©tĂ© modifiĂ©). Il est nĂ©cessaire de redĂ©marrer la tĂąche pour que son travail continue Ă  partir de l'endroit oĂč elle a Ă©chouĂ© (ou a Ă©tĂ© arrĂȘtĂ©e).
  • ExĂ©cution de tĂąches hybrides. Vous devez effectuer une certaine Ă©tape du flux de travail localement (il s'agit peut-ĂȘtre de tĂ©lĂ©charger des donnĂ©es Ă  partir d'un fichier stockĂ© dans un dossier sur l'ordinateur), et une autre Ă©tape qui nĂ©cessite de grandes ressources de calcul (il s'agit peut-ĂȘtre de la formation du modĂšle) doit ĂȘtre effectuĂ©e dans le cloud.
  • Examen des mĂ©tadonnĂ©es obtenues aprĂšs avoir terminĂ© une tĂąche. Trois scientifiques des donnĂ©es sont engagĂ©s dans la sĂ©lection d'hyperparamĂštres du mĂȘme modĂšle, en essayant d'amĂ©liorer la prĂ©cision de ce modĂšle. AprĂšs cela, vous devez analyser les rĂ©sultats de l'exĂ©cution des tĂąches de formation du modĂšle et sĂ©lectionner l'ensemble d'hyperparamĂštres qui s'est avĂ©rĂ© ĂȘtre le meilleur.
  • Utilisation de plusieurs versions du mĂȘme package. Dans le projet, vous devez utiliser diffĂ©rentes versions, par exemple, les bibliothĂšques sklearn. Pendant le prĂ©traitement, sa version 0.20 est requise et pendant la modĂ©lisation, la version 0.22 est requise.

Flux de travail typique de Metaflow


Considérez un flux de travail Metaflow typique d'un point de vue conceptuel et de programmation.

LookAvis conceptuel sur le flux de travail Metaflow


D'un point de vue conceptuel, les workflows Metaflow (chaßnes de tùches) sont représentés par des graphiques acycliques dirigés (DAG). Les illustrations ci-dessous vous aideront à mieux comprendre cette idée.


Graphique acyclique linéaire


Graphique acyclique avec chemins "parallĂšles"

Chaque nƓud du graphique reprĂ©sente une Ă©tape de traitement des donnĂ©es dans le flux de travail.

À chaque Ă©tape de la chaĂźne de tĂąches, Metaflow exĂ©cute du code Python normal sans aucune modification spĂ©ciale. Le code est exĂ©cutĂ© dans des conteneurs sĂ©parĂ©s dans lesquels le code est compressĂ© avec ses dĂ©pendances.

Un aspect clé de l'architecture Metaflow est représenté par le fait qu'il vous permet d'implémenter presque toutes les bibliothÚques externes de l'écosystÚme conda dans des projets basés sur celui-ci sans utiliser de plugins. Cela distingue Metaflow des autres solutions polyvalentes similaires. Par exemple - depuis Airflow.

▍ Flux de travail Metaflow en termes de programmation


Chaque chaĂźne de tĂąches (flux) peut ĂȘtre reprĂ©sentĂ©e comme une classe Python standard (les noms de ces classes ont gĂ©nĂ©ralement le mot Flow ) si elle satisfait aux exigences minimales suivantes:

  • La classe est la descendante de la classe Metaflow FlowSpec .
  • Le @step chaque fonction qui reprĂ©sente une Ă©tape de la chaĂźne de tĂąches.
  • À la fin de chaque fonction @step , il doit y avoir une indication d'une fonction similaire qui la suit. Cela peut ĂȘtre fait en utilisant une construction de ce type: self.next(self.function_name_here) .
  • La classe implĂ©mente les fonctions de start et de end .

Prenons un exemple de chaĂźne minimale de tĂąches composĂ©e de trois nƓuds.

Son schéma ressemble à ceci:

 start → process_message → end 

Voici son code:

 from metaflow import FlowSpec, step class LinearFlow(FlowSpec):         """     ,      Metaflow.    """       #         @step    def start(self):        self.message = 'Thanks for reading.'        self.next(self.process_message)    @step    def process_message(self):        print('the message is: %s' % self.message)        self.next(self.end)    @step    def end(self):        print('the message is still: %s' % self.message) if __name__ == '__main__':    LinearFlow() 

Instructions d'installation de Metaflow


▍Installation et essai


Voici la séquence d'étapes que vous devez effectuer pour installer et lancer d'abord Metaflow:

  • Installer Metaflow (Python 3 recommandĂ©): pip3 install metaflow .
  • Mettez le fragment de code ci-dessus ( ici sur GitHub) dans le fichier linear_flow.py .
  • Pour consulter l'architecture de la chaĂźne de tĂąches implĂ©mentĂ©e par ce code, utilisez la commande python3 linear_flow.py show .
  • Pour dĂ©marrer le flux, exĂ©cutez la python3 linear_flow.py run .

Vous devriez obtenir quelque chose de similaire à celui illustré ci-dessous.


Bilan de santé Metaflow réussi

Ici, il convient de prĂȘter attention Ă  certaines choses. Le framework Metaflow crĂ©e un .metaflow donnĂ©es .metaflow local. LĂ , il stocke toutes les mĂ©tadonnĂ©es liĂ©es Ă  l'exĂ©cution des tĂąches et les instantanĂ©s associĂ©s aux sessions d'exĂ©cution des tĂąches. Si vous avez configurĂ© des paramĂštres Metaflow liĂ©s au stockage de donnĂ©es dans le cloud, les instantanĂ©s seront stockĂ©s dans AWS S3 Bucket et les mĂ©tadonnĂ©es liĂ©es aux lancements de tĂąches iront au service de mĂ©tadonnĂ©es basĂ© sur RDS (Relational Data Store, Relational Data Store). Plus tard, nous parlerons de la façon d'explorer ces mĂ©tadonnĂ©es Ă  l'aide de l'API client. Une autre bagatelle, bien qu'importante, Ă  laquelle il convient de prĂȘter attention, est que les identificateurs de processus (pid, ID de processus) attachĂ©s aux diffĂ©rentes Ă©tapes diffĂšrent. N'oubliez pas - nous avons dit ci-dessus que Metaflow conteneurise indĂ©pendamment chaque Ă©tape de la chaĂźne de tĂąches et effectue chaque Ă©tape dans son propre environnement (en ne transmettant que les donnĂ©es entre les Ă©tapes).

▍Installation et configuration de conda (si vous prĂ©voyez d'implĂ©menter des dĂ©pendances)


Suivez ces Ă©tapes pour installer conda:


Vous ĂȘtes maintenant prĂȘt Ă  intĂ©grer des dĂ©pendances conda dans vos chaĂźnes de tĂąches. Les dĂ©tails de ce processus seront discutĂ©s ci-dessous.

Exemple de workflow réaliste


Ci-dessus, nous avons expliqué comment installer Metaflow et comment vous assurer que le systÚme est opérationnel. De plus, nous avons discuté des bases de l'architecture de workflow et regardé un exemple simple. Nous examinons ici un exemple plus complexe, tout en révélant certains des concepts de Metaflow.

▍Emploi


Créez un workflow à l'aide de Metaflow qui implémente les fonctions suivantes:

  • Chargement de donnĂ©es de film CSV dans une trame de donnĂ©es Pandas.
  • Calcul parallĂšle des quartiles pour les genres.
  • Enregistrement d'un dictionnaire avec les rĂ©sultats des calculs.

▍ Chaüne de tñches


Le squelette de la classe GenreStatsFlow est illustrĂ© GenreStatsFlow - GenreStatsFlow . AprĂšs l'avoir analysĂ©, vous comprendrez l'essence de l'approche mise en Ɠuvre ici pour rĂ©soudre notre problĂšme.

 from metaflow import FlowSpec, step, catch, retry, IncludeFile, Parameter class GenreStatsFlow(FlowSpec):  """    ,  ,   .         :    1)  CSV-   Pandas.    2)     .    3)     .  """   @step  def start(self):    """         :        1)      Pandas.        2)    .        3)        .    """       # TODO:  CSV         self.genres = []    self.next(self.compute_statistics, foreach='genres') #  1     @catch(var='compute_failed') #  2  @retry(times=1) #  3  @step  def compute_statistics(self):    """    .   ."""    self.genre = self.input #  4    # TODO:        self.next(self.join)     @step  def join(self, inputs):    """       ."""    # TODO:      self.next(self.end)     @step  def end(self):      """End the flow."""      pass   if __name__ == '__main__':  GenreStatsFlow() 

Considérez certaines parties importantes de cet exemple. Le code contient des commentaires de la forme # n , auxquels nous ferons référence ci-dessous.

  • Dans le 1 , Ă  l'Ă©tape de start , faites attention au paramĂštre foreach . GrĂące Ă  lui, des copies des Ă©tapes compute_statistics sont compute_statistics en compute_statistics dans une boucle for each entrĂ©e de la liste des genres .
  • Dans le 2 dĂ©corateur @catch(var='compute_failed') interceptera toute exception qui compute_statistics Ă©tape compute_statistics et l'Ă©crira dans la variable compute_failed (il peut ĂȘtre lu Ă  l'Ă©tape suivante).
  • Dans le 3 dĂ©corateur @retry(times=1) fait exactement ce que son nom laisse @retry(times=1) . À savoir, lorsque des erreurs se produisent, il rĂ©pĂšte l'Ă©tape.
  • D'oĂč vient le 4 , dans compute_statistics , self.input ? Le fait est que l' input est une variable de classe fournie par Metaflow. Il contient des donnĂ©es applicables Ă  une instance particuliĂšre de compute_statistics (lorsqu'il existe plusieurs copies d'une fonction exĂ©cutĂ©e en parallĂšle). Cette variable n'est ajoutĂ©e par Metaflow que lorsque les nƓuds sont reprĂ©sentĂ©s par plusieurs processus parallĂšles, ou lorsque plusieurs nƓuds sont combinĂ©s.
  • Voici un exemple d'exĂ©cution de la mĂȘme fonction en parallĂšle - compute_statistics . Mais, si nĂ©cessaire, vous pouvez simultanĂ©ment exĂ©cuter des fonctions complĂštement diffĂ©rentes qui ne sont pas liĂ©es les unes aux autres. Pour ce faire, changez ce qui est montrĂ© dans le 1 en quelque chose comme self.next(self.func1, self.function2, self.function3) . Bien sĂ»r, avec cette approche, il sera Ă©galement nĂ©cessaire de rĂ©Ă©crire l'Ă©tape de join , ce qui permettra de traiter les rĂ©sultats des diffĂ©rentes fonctions sur celle-ci.

Voici comment imaginer la classe squelette ci-dessus.


Représentation visuelle de la classe GenreStatsFlow

▍Lisez le fichier de donnĂ©es et les paramĂštres de transfert


  • TĂ©lĂ©chargez ce fichier CSV de film.
  • Vous devez maintenant Ă©quiper le programme de la prise en charge de la possibilitĂ© de transfĂ©rer dynamiquement le chemin d'accĂšs au fichier movie_data et la valeur max_genres Ă  la max_genres . Le mĂ©canisme des arguments externes nous y aidera. Metaflow vous permet de passer des arguments au programme en utilisant des indicateurs supplĂ©mentaires dans la commande de dĂ©marrage du workflow. Par exemple, cela pourrait ressembler Ă  ceci: python3 tutorial_flow.py run --movie_data=path/to/movies.csv --max_genres=5 .
  • Metaflow fournit au dĂ©veloppeur des objets IncludeFile et Parameter qui vous permettent de lire l'entrĂ©e dans le code de workflow. Nous nous rĂ©fĂ©rons aux arguments passĂ©s en affectant des objets IncludeFile et Parameter aux variables de classe. Cela dĂ©pend de ce que nous voulons lire exactement - le fichier ou la valeur habituelle.

Voici à quoi ressemble le code en lisant les paramÚtres passés au programme lors de son lancement à partir de la ligne de commande:

     movie_data = IncludeFile("movie_data",                             help="The path to a movie metadata file.",                             default = 'movies.csv')                               max_genres = Parameter('max_genres',                help="The max number of genres to return statistics for",                default=5) 

▍Inclusion de conda dans la chaüne de tñches


  • Si vous n'avez pas encore installĂ© conda, reportez-vous Ă  la section sur l'installation et la configuration de conda dans cet article.
  • Ajoutez le dĂ©corateur GenreStatsFlow fourni par Metaflow Ă  la classe GenreStatsFlow. Ce dĂ©corateur s'attend Ă  recevoir la version python. Il peut ĂȘtre soit dĂ©fini dans le code, soit obtenu Ă  l'aide d'une fonction auxiliaire. Vous trouverez ci-dessous le code qui illustre l'utilisation du dĂ©corateur et montre une fonction d'assistance.

     def get_python_version():    """     ,    python,       .         conda        python.    """    import platform    versions = {'2' : '2.7.15',                '3' : '3.7.4'}    return versions[platform.python_version_tuple()[0]] #       python. @conda_base(python=get_python_version()) class GenreStatsFlow(FlowSpec): 
  • Vous pouvez maintenant ajouter le dĂ©corateur @conda Ă  n'importe quelle Ă©tape de la chaĂźne de tĂąches. Il attend un objet avec des dĂ©pendances, qui lui est transmis via le paramĂštre de libraries . Metaflow, avant de commencer l'Ă©tape, se chargera de prĂ©parer le conteneur avec les dĂ©pendances spĂ©cifiĂ©es. Si nĂ©cessaire, vous pouvez utiliser en toute sĂ©curitĂ© diffĂ©rentes versions de packages Ă  diffĂ©rentes Ă©tapes, car Metaflow lance chaque Ă©tape dans un conteneur sĂ©parĂ©.

         @conda(libraries={'pandas' : '0.24.2'})    @step    def start(self): 
  • ExĂ©cutez maintenant la commande suivante: python3 tutorial_flow.py --environment=conda run .

StartDébut de l'implémentation


 @conda(libraries={'pandas' : '0.24.2'})    @step    def start(self):    """         :        1)      Pandas.        2)    .        3)        .    """        import pandas        from io import StringIO        #      Pandas.        self.dataframe = pandas.read_csv(StringIO(self.movie_data))        #   'genres'      .         #   .        self.genres = {genre for genres \                       in self.dataframe['genres'] \                       for genre in genres.split('|')}        self.genres = list(self.genres)        #        .        #  'foreach'             #          self.next(self.compute_statistics, foreach='genres') 

Considérez certaines des fonctionnalités de ce code:

  • Notez que l'expression d'importation pandas se trouve Ă  l'intĂ©rieur de la fonction qui dĂ©crit l'Ă©tape. Le fait est que cette dĂ©pendance n'est introduite par conda que dans le cadre de cette Ă©tape.
  • Mais les variables dĂ©clarĂ©es ici ( dataframe et genres ) sont disponibles mĂȘme dans le code des Ă©tapes effectuĂ©es aprĂšs cette Ă©tape. Le fait est que Metaflow fonctionne sur la base des principes de sĂ©paration des environnements d'exĂ©cution de code, mais permet aux donnĂ©es de se dĂ©placer naturellement entre les Ă©tapes de la chaĂźne de tĂąches.

▍ ImplĂ©mentation de l'Ă©tape compute_statistics


 @catch(var='compute_failed')    @retry    @conda(libraries={'pandas' : '0.25.3'})    @step    def compute_statistics(self):        """            .        """        #             # 'input'.        self.genre = self.input        print("Computing statistics for %s" % self.genre)        #         ,         #        .        selector = self.dataframe['genres'].\                   apply(lambda row: self.genre in row)        self.dataframe = self.dataframe[selector]        self.dataframe = self.dataframe[['movie_title', 'genres', 'gross']]        #     gross   .        points = [.25, .5, .75]        self.quartiles = self.dataframe['gross'].quantile(points).values        #  ,    .        self.next(self.join) 

Veuillez noter que dans cette étape, nous nous référons à la variable de dataframe qui a été déclarée à l'étape de start précédente. Nous modifions cette variable. Lorsque vous passez aux étapes suivantes, cette approche, qui implique l'utilisation d'un nouvel objet de dataframe modifié, vous permet d'organiser un travail efficace avec les données.

▍ Mettre en Ɠuvre l'Ă©tape de jointure


 @conda(libraries={'pandas' : '0.25.3'})    @step    def join(self, inputs):        """               .        """        inputs = inputs[0:self.max_genres]        #   ,    .        self.genre_stats = {inp.genre.lower(): \                            {'quartiles': inp.quartiles,                             'dataframe': inp.dataframe} \                            for inp in inputs}        self.next(self.end) 

Ici, il convient de souligner quelques points:

  • Dans cette Ă©tape, nous utilisons une version complĂštement diffĂ©rente de la bibliothĂšque pandas.
  • Chaque Ă©lĂ©ment du tableau d' inputs est une copie des compute_statistics prĂ©cĂ©demment exĂ©cutĂ©es. Il contient l'Ă©tat de la fonction correspondante exĂ©cutĂ©e, c'est-Ă -dire les valeurs de diverses variables. Ainsi, l' input[0].quartiles peut contenir des quartiles pour le genre comedy , et l' input[1].quartiles input[0].quartiles peut contenir des quartiles pour le genre sci-fi .

EadyProjet prĂȘt


Le code de projet complet que nous venons de revoir se trouve ici .

Pour voir comment fonctionne le flux de travail décrit dans le fichier tutorial_flow.py , vous devez exécuter la commande suivante:

 python3 tutorial_flow.py --environment=conda show 

Utilisez la commande suivante pour démarrer le workflow:

 python3 tutorial_flow.py --environment=conda run --movie_data=path/to/movies.csv --max_genres=7 

Examen des résultats de l'exécution d'un flux de travail à l'aide de l'API client


Afin d'examiner des instantanés de données et l'état des lancements précédents du flux de travail, vous pouvez utiliser l' API client fournie par Metaflow. Cette API est idéale pour explorer les détails des expériences effectuées dans l'environnement Jupyter Notebook.

Voici un exemple simple de la sortie de la variable genre_stats , tirée des données du dernier lancement réussi de GenreStatsFlow .

 from metaflow import Flow, get_metadata #      print("Using metadata provider: %s" % get_metadata()) #     MovieStatsFlow. run = Flow('GenreStatsFlow').latest_successful_run print("Using analysis from '%s'" % str(run)) genre_stats = run.data.genre_stats print(genre_stats) 

Exécution de workflows dans le cloud


AprÚs avoir créé et testé le flux de travail sur un ordinateur ordinaire, il est trÚs probable que vous souhaitiez exécuter le code dans le cloud pour accélérer le travail.

Actuellement, Metaflow prend uniquement en charge l'intégration avec AWS. Dans l'image suivante, vous pouvez voir un mappage des ressources locales et cloud utilisées par Metaflow.


Intégration de Metaflow et AWS

Pour connecter Metaflow Ă  AWS, vous devez effectuer les Ă©tapes suivantes:

  • Vous devez d'abord effectuer une configuration AWS unique en crĂ©ant des ressources avec lesquelles Metaflow peut travailler. Les mĂȘmes ressources peuvent ĂȘtre utilisĂ©es, par exemple, par des membres d'une Ă©quipe de travail qui se dĂ©montrent mutuellement les rĂ©sultats des workflows. Vous pouvez trouver des instructions pertinentes ici. Les paramĂštres sont assez rapides, car Metaflow dispose d'un modĂšle de paramĂštres CloudFormation.
  • Ensuite, sur l'ordinateur local, vous devez exĂ©cuter la metaflow configure aws et entrer les rĂ©ponses aux questions systĂšme. Avec ces donnĂ©es, Metaflow pourra utiliser des entrepĂŽts de donnĂ©es basĂ©s sur le cloud.
  • Maintenant, pour dĂ©marrer des workflows locaux dans le cloud, ajoutez simplement la clĂ© de --with batch Ă  la --with batch dĂ©marrage du workflow. Par exemple, cela pourrait ressembler Ă  ceci: python3 sample_flow.py run --with batch .
  • Pour effectuer un lancement hybride du flux de travail, c'est-Ă -dire pour effectuer certaines Ă©tapes localement et d'autres dans le cloud, vous devez ajouter le dĂ©corateur @batch aux Ă©tapes qui doivent ĂȘtre effectuĂ©es dans le cloud. Par exemple, comme ceci: @batch(cpu=1, memory=500) .

Résumé


Ici, je voudrais noter quelques fonctionnalitĂ©s de Metaflow qui peuvent ĂȘtre considĂ©rĂ©es Ă  la fois comme les avantages et les inconvĂ©nients de ce cadre:

  • Metaflow est Ă©troitement intĂ©grĂ© Ă  AWS. Mais dans les plans de dĂ©veloppement du cadre, il existe un support pour un plus grand nombre de fournisseurs de cloud.
  • Metaflow est un outil qui ne prend en charge que l'interface de ligne de commande. Il ne possĂšde pas d'interface graphique (contrairement Ă  d'autres cadres universels pour organiser les processus de travail, tels que Airflow).

Chers lecteurs! Envisagez-vous d'utiliser Metaflow?

Source: https://habr.com/ru/post/fr482462/


All Articles