木偶+希拉。 挤压最大


在本文中,我想谈谈我们如何使用PuppetHiera配置铁和虚拟服务器。 基本上,这将与我们发明的体系结构和层次结构有关,这将促进服务器配置并使之系统化。


我被提示写这篇文章的原因是,在Internet上我并没有特别找到关于如何使用hiera及其用途的好的实际示例。 基本上,这些是带有示例的教程,用于输入主题。 但是,这里没有编写hiera的实际应用。 也许我看起来并不好,但是这是一个真实的例子,可能会像我曾经那样帮助您将所有要点放在我身上。


这篇文章对谁有用


如果:


  • 您知道Puppet和Hiera是什么,但是您并没有真正一起使用它们,因为不清楚如何做以及为什么
  • 您公司中有很多团队,您需要以某种方式在命令级别区分服务器配置
  • 您使用pappet,并且节点文件已增长到令人难以置信的大小
  • 您是否想以神圣的Yaml格式阅读服务器配置:)
  • 您基本上对配置管理和系统管理主题感兴趣。

本文适合您。


开始之前


我会立即警告您,这篇文章原来很长,但是希望对您有用。 另外,可以理解的是,您已经将hiera连接到了puppet,并且至少对puppet有所了解。 如果未连接hiera,则并不难。



输入数据


  • SEMrush大约有30个开发团队,每个团队都有自己的服务器
  • 每个团队都使用自己的一套技术(PL,DBMS等)
  • 团队可以并且应该(理想地)为特定项目使用通用配置(代码重用)
  • 团队自己管理将应用程序部署到服务器上(这不是通过pappet完成的)

一点历史


最初,我们在第3版pappet中拥有所有内容,然后我们决定实施第4版pappet,然后将所有新服务器都放置在其中,而旧服务器则慢慢移植到了第4版。


在第三篇文章中,我们使用了经典的节点文件和模块系统。 这些模块是在Gitlab中的一组特殊项目中创建的,将它们克隆到pappet服务器(使用r10k ),然后pappet代理进入向导并收到目录以将其应用于服务器。


然后,他们开始尝试不这样做,不使用本地模块,而是将指向必要模块及其存储库的链接放在Puppetfile中。 怎么了 因为这些模块一直受到社区和开发人员的支持和改进(理想情况下),而我们本地的则不是。 后来,他们引入了hiera并将其完全切换到它,节点文件(例如nodes.pp)已被遗忘。


在第四篇文章中,我们试图完全放弃本地模块,而仅使用远程模块。 不幸的是,应该在这里再次插入保留,因为它“没有完全解决”,有时您仍然必须自己倾斜并完成一些操作。 当然,只有hiera,没有节点文件。


当您有30个团队与一个技术动物园一起使用时,如何保持超过1000台服务器的管理变得尤为棘手。 此外,我将告诉您hiera在这方面如何帮助我们。


层次结构


希拉(事实上,它的名字来源于此)建立了一个层次结构。 与我们一起,看起来像这样:


--- :hierarchy: - "nodes/%{::fqdn}" - "teams/%{::team}_team/nodes/%{::fqdn}" - "teams/%{::team}_team/projects/%{::project}/tiers/%{::tier}" - "teams/%{::team}_team/projects/%{::project}/%{::role}" - "teams/%{::team}_team/projects/%{::project}" - "teams/%{::team}_team/roles/%{::role}" - "teams/%{::team}_team/%{::team}" - "projects/%{::project}/tiers/%{::tier}/%{::role}" - "projects/%{::project}/tiers/%{::tier}" - "projects/%{::project}/%{::role}" - "projects/%{::project}" - "tiers/%{::tier}" - "virtual/%{::virtual}" - "os/%{::operatingsystem}/%{::operatingsystemmajrelease}" - "os/%{::operatingsystem}" - users - common 

首先,让我们处理模糊的变量(事实)。


理想情况下,SEMrush中的每个服务器都应具有4个公开的特殊事实,以描述其从属关系:


  • team事实-它属于哪个团队
  • 事实project -与哪个项目有关
  • role事实-这个项目有什么作用
  • tier事实-它具有什么样的阶段(生产,测试,开发)

如何运作? Pappet代理进入Pappet主服务器,并根据这些事实为自己搜索文件,并根据我们的层次结构遍历文件夹。 无需指出配置文件属于服务器。 相反,服务器本身仅查看它们的路径和事实就知道哪些文件属于它们。


在服务器设置过程中,管理员与开发人员进行交流并指定这些参数(通常,知识渊博的人会与管理员本身联系),以便将来在hiera中构建层次结构,然后基于此层次结构描述服务器配置。 这样的系统有助于重用代码,并在服务器配置方面更加灵活。


例如,我们有一个特殊的项目。 在这个项目中,可能有一些带有nginx的前端服务器,一个带有python的后端服务器,一个带有mysql的数据库集群,一个用于缓存的redis服务器。 所有这些服务器都应该放在一个名为special的项目中,然后为服务器分配角色


在项目文件中,我们描述了整个项目共有的参数。 首先想到的是在用户的所有服务器上创建要部署的用户,并向其发出必要的权限并推出其ssh密钥。


在每台服务器的角色中,通常会描述和定制服务-针对该服务器的用途(nginx,python,mysql等)。在这种情况下,如果我们还需要在开发平台上部署生产环境的副本,则肯定需要该服务,但更改其中的某些内容(例如密码)。 在这种情况下,dev-server和prod-server的区别仅在于将层设置为所需的“位置”(prod或dev)。 然后一点魔术和希拉就可以了。


如果我们需要以同一角色部署两个相同的服务器,但是其中的某些内容应该有所不同(例如,配置中的某些行),那么层次结构的另一部分将可以解决。 我们将名称为{fqdn }.yaml格式的文件放在正确的位置(例如, nodes/myserver.domain.net ),在特定服务器级别上设置变量的必要值,pappet将为该角色的两个服务器应用相同的配置,并且每个角色都唯一从服务器。


示例:两个带有php代码的后端具有相同的角色,并且完全相同。 显然,我们不想同时备份两个服务器-这没有任何意义。 我们可以创建一个角色来描述两个服务器的相同配置,然后创建另一个nodes/backend1.semrush.net文件,在其中放置用于备份的配置。


批处理文件teams/team-name.yaml指示属于该团队的所有服务器的配置。 大多数情况下,它描述了可以与这些服务器进行交互的用户以及他们的访问权限。


基于这些变量,我们建立了这个层次结构 。 在层次结构中找到的文件越高,在其中指定的配置的优先级越高。


因此,可以基于此层次结构覆盖变量。 即,角色文件“ projects/%{::project}/%{::role} ”中的变量优先于项目文件“ projects/%{::project} ”中的变量。 如果您以允许您执行此操作的方式编写模块和/或配置文件/角色,则变量也可以在层次结构的所有级别上合并。 通过为所有项目服务器指定mysql配置的公共部分,您可以将具有该角色权重的特殊部分添加到其他层次结构级别的同一变量中(从服务器的配置中将有一个附加部分)。


事实证明,沿着路径“ hieradata/nodes/%{::fqdn} ”定位的特定节点的文件具有最高优先级。 接下来是节点文件,但是已经在命令级别。 下面的块描述了其他更普遍的事实:


  - "virtual/%{::virtual}" - "os/%{::operatingsystem}/%{::operatingsystemmajrelease}" - "os/%{::operatingsystem}" - users - common 

相应地,在文件common.yaml我们肯定有一个配置应common.yaml 所有服务器,在文件users.yaml所有用户均以os/%{::operatingsystem} users.yaml描述(但并非所有用户均在服务器上创建)具有特定操作系统( ::operatingsystem事实::operatingsystem )的服务器的典型常规配置,依此类推。


我认为,从这个层次结构来看,一切都变得清晰了。 下面,我将考虑使用这种层次结构的示例。 但是首先,您需要谈论配置文件。


个人资料


使用模块配置服务器的重要一点是使用配置文件。 它们位于路径site/profiles ,是模块的入口点。 多亏了他们,您可以在服务器上更好地配置挂起的模块并创建必要的资源。


考虑一个简单的例子。 有一个模块可以安装和配置redis。 而且我们还想在连接此模块时将sysctl参数vm.overcommit_memory为1,因为在这里 。 然后,我们编写一个提供此功能的小型配置文件:


 # standalone redis server class profiles::db::redis ( Hash $config = {}, String $output_buffer_limit_slave = '256mb 64mb 60', ) { # https://redis.io/topics/faq#background-saving-fails-with-a-fork-error-under-linux-even-if-i-have-a-lot-of-free-ram sysctl { 'vm.overcommit_memory': ensure => present, value => '1', } class { '::redis': * => $config, } } 

如上所述,配置文件是允许您更改/改善模块行为以及减少层次结构中配置数量的工具。 如果使用远程模块,则经常会遇到“经批准”的模块通常不具备所需功能或存在一些错误/缺陷的问题。 然后,原则上,您可以克隆此模块并更正/添加功能。 但是,如果可能的话,正确的决定是编写一个良好的配置文件,以所需的方式“准备”模块。 以下是配置文件的一些示例,您可以更好地理解为什么需要它们。


隐藏在希拉的秘密


与裸纸浆相比,hiera的重要优势之一是它能够以加密形式在存储库中将敏感数据存储在配置文件中。 您的密码将是安全的。


简而言之,您可以使用公共密钥来加密所需的信息,并将其以这样的一行放置在hiera文件中。 密钥的私有部分存储在pappet master中,您可以使用它解密此数据。 可以在项目页面上找到更多详细信息。


在客户端(工作计算机)上,只需使用gem install hiera-eyaml即可安装该工具。 此外,使用形式为eyaml encrypt --pkcs7-public-key=/path/to/public_key.pkcs7.pem -s 'hello'您可以加密数据并将其粘贴到扩展名为eyaml或只是yaml的文件中,具体取决于您的方式配置,然后pappet将弄清楚。 您得到类似的东西:


 roles::postrgresql::password: 'ENC[PKCS7,MIIBeQYJKoZIhvcNAQcDoIIBajCCAWYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAbIz1ihQlThMWa9T+Lq194Y6QdElMD1XTev5y+VPSHtkPTu6Al6TJaSrXF+7phJIjue+NF4ZVtJCLkHxUR6nJJqks0fcGS1vF2+6mmM9cy69sIU1A3HqpOHZLuqHAc7jUqljYxpwWSIGOK6I2FygdAp5FfOTewqfcVVmXj97EJdcv3DKrbAlSrIMO2iZRYwQvyv+qnptnZ7pilR2veOCPW2UMm6zagDLutX9Ft5vERbdaiCiEfTOpVa9Qx0GqveNRVJLV/5lfcL5ajdNBJXkvKqDbx8d3ZBtEVAAqeKlw0LqzScgmCbWQx2kUzukX5LSxbTpT0Th984Vp1sl7iPk7UTA8BgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBCp5GcwidcEMA+0wjAMblkKgBCR/f9KGXUgLh3/Ok60OIT5]' 

或多行字符串:


 roles::postgresql::password: > ENC[PKCS7,MIIBeQYJKoZIhvcNAQcDoIIBajCCAWYCAQAxggEhMIIBHQIBADAFMAACAQEw DQYJKoZIhvcNAQEBBQAEggEAbIz1ihQlThMWa9T+Lq194Y6QdElMD1XTev5y +VPSHtkPTu6Al6TJaSrXF+7phJIjue+NF4ZVtJCLkHxUR6nJJqks0fcGS1vF 2+6mmM9cy69sIU1A3HqpOHZLuqHAc7jUqljYxpwWSIGOK6I2FygdAp5FfOTe wqfcVVmXj97EJdcv3DKrbAlSrIMO2iZRYwQvyv+qnptnZ7pilR2veOCPW2UM m6zagDLutX9Ft5vERbdaiCiEfTOpVa9Qx0GqveNRVJLV/5lfcL5ajdNBJXkv KqDbx8d3ZBtEVAAqeKlw0LqzScgmCbWQx2kUzukX5LSxbTpT0Th984Vp1sl7 iPk7UTA8BgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBCp5GcwidcEMA+0wjAM blkKgBCR/f9KGXUgLh3/Ok60OIT5] 

看来我们已经做好了准备,现在我们可以考虑一个例子。


手指示例


Spoiler将会有更多的配置,因此那些对本文仅出于理论上的兴趣的人可以跳过本节,然后继续进行。


现在,让我们看一个如何在puppet4中使用hiera配置服务器的示例。 我不会发布所有配置文件的代码,因为否则该帖子可能会很大。 我将专注于层次结构和层次结构。


任务是这样的:我们需要部署:


  • 在其上部署了postgresql的两个相同的数据库服务器
  • 另外两台服务器-Nginx前端
  • 第五和第六个服务器-Docker中的python后端
  • 除了某些服务器配置外,开发环境上的所有内容都相同

我们将按顺序创建层次结构,并从项目文件开始。


专案


创建项目文件projects/kicker.yaml 。 我们放入了所有服务器共有的东西:我们需要一些存储库和文件夹来进行部署,以及部署用户本人。


 --- classes: - apt::debian::semrush files: "/srv/data": ensure: 'directory' owner: 'deploy' group: 'www-data' mode: '0755' '/srv/data/shared_temp': ensure: 'directory' owner: 'deploy' group: 'www-data' mode: '0775' user_management::present: - deploy 

db角色


projects/kicker/db.yaml数据库服务器创建一个角色文件。 到目前为止,我们不会将服务器划分为多个环境:


 --- classes: - profiles::db::postgresql profiles::db::postgresql::globals: manage_package_repo: true version: '10' profiles::db::postgresql::db_configs: 'listen_addresses': value: '*' profiles::db::postgresql::databases: kicker: {} profiles::db::postgresql::hba_rules: 'local connect to kicker': type: 'local' database: 'kicker' user: 'kicker' auth_method: 'md5' order: '001' 'allow connect from 192.168.1.100': type: 'host' database: 'kicker' user: 'kicker' auth_method: 'md5' address: '192.168.1.100/32' order: '002' 

在这里,我们连接一个供所有希望在其服务器上安装postgres的用户使用的通用配置文件。 我们配置配置文件,并允许您在应用之前灵活配置模块。


最令人好奇的是,在此配置文件的代码下方:


资料:: DB :: PostgreSQL
 class profiles::db::postgresql ( Hash $globals = {}, Hash $params = {}, Hash $recovery = {}, Hash[String, Hash[String, Variant[String, Boolean, Integer]]] $roles = {}, Hash[String, Hash[String, Variant[String, Boolean]]] $db_configs = {}, Hash[String, Hash[String, Variant[String, Boolean]]] $databases = {}, Hash[String, String] $db_grants = {}, Hash[String, Hash[String, String]] $extensions = {}, Hash[String, String] $table_grants = {}, Hash[String, Hash[String, String]] $hba_rules = {}, Hash[String, String] $indent_rules = {}, Optional[String] $role = undef, # 'master', 'slave' Optional[String] $master_host = undef, Optional[String] $replication_password = undef, Integer $master_port = 5432, String $replication_user = 'repl', String $trigger_file = '/tmp/pg_trigger.file', ){ case $role { 'slave': { $_params = { manage_recovery_conf => true, } if $globals['datadir'] { file { "${globals['datadir']}/recovery.done": ensure => absent, } } $_recovery = { 'recovery config' => { standby_mode => 'on', primary_conninfo => "host=${master_host} port=${master_port} user=${replication_user} password=${replication_password}", trigger_file => $trigger_file, } } $_conf = { 'hot_standby' => { value => 'on', }, } file { $trigger_file: ensure => absent, } } 'master': { $_conf = { 'wal_level' => { value => 'replica', }, 'max_wal_senders' => { value => 5, }, 'wal_keep_segments' => { value => 32, }, } file { $trigger_file: ensure => present, } } default: { $_params = {} $_recovery = {} $_conf = {} } } class { '::postgresql::globals': * => $globals, } class { '::postgresql::server': * => deep_merge($_params, $params), } create_resources('::postgresql::server::config_entry', deep_merge($_conf, $db_configs)) create_resources('::postgresql::server::role', $roles) create_resources('::postgresql::server::database', $databases) create_resources('::postgresql::server::database_grant', $db_grants) create_resources('::postgresql::server::extension', $extensions) create_resources('::postgresql::server::table_grant', $table_grants) create_resources('::postgresql::server::pg_hba_rule', $hba_rules) create_resources('::postgresql::server::pg_indent_rule', $indent_rules) create_resources('::postgresql::server::recovery', deep_merge($_recovery, $recovery)) } 

因此,我们Postgresql 10安装了Postgresql 10 ,配置了config( listen ),创建了kicker数据库,并在pg_hba.conf编写了两个访问该数据库的规则。 好酷!


前端角色


我们承担frontend 。 使用以下内容创建projects/kicker/frontend.yaml文件:


 --- classes: - profiles::webserver::nginx profiles::webserver::nginx::servers: 'kicker.semrush.com': use_default_location: false listen_port: 80 server_name: - 'kicker.semrush.com' profiles::webserver::nginx::locations: 'kicker-root': location: '/' server: 'kicker.semrush.com' proxy: 'http://kicker-backend.semrush.com:8080' proxy_set_header: - 'X-Real-IP $remote_addr' - 'X-Forwarded-for $remote_addr' - 'Host kicker.semrush.com' location_cfg_append: 'proxy_next_upstream': 'error timeout invalid_header http_500 http_502 http_503 http_504' proxy_connect_timeout: '5' 

这里的一切都很简单。 我们连接profiles::webserver::nginx概要文件,该概要文件准备进入nginx模块的条目,并定义变量,尤其是该站点的serverlocation


细心的读者会注意到,将站点的描述放在层次结构中更高的位置更合适,因为我们仍然会拥有一个dev环境,并且在那里将使用其他变量( server_nameproxy ),但这并不是太重要。 通过这种方式描述角色,我们可以看到如何仅通过层次结构来重新定义这些变量。


Docker角色


projects/kicker/docker.yaml


 --- classes: - profiles::docker profiles::docker::params: version: '17.05.0~ce-0~debian-stretch' packages: 'python3-pip': provider: apt 'Fabric3': provider: pip3 ensure: 1.12.post1 user_management::users: deploy: groups: - docker 

profiles/docker.pp非常简单优雅。 我将给出他的代码:


个人资料::码头工人
 class profiles::docker ( Hash $params = {}, Boolean $install_kernel = false, ){ class { 'docker': * => $params, } if ($install_kernel) { include profiles::docker::kernel } } 

一切准备就绪。 这足以将我们需要的产品部署到许多服务器上,只需为它们分配特定的项目和角色即可(例如,将文件以所需的格式放在facts.d目录中,该文件的位置取决于安装p的方法)。


现在,我们具有以下文件结构:


 . ├── kicker │ ├── db.yaml │ ├── docker.yaml │ └── frontend.yaml └── kicker.yaml 1 directory, 4 files 

现在,我们将讨论环境和特定站点上角色所独有的配置的定义。


环境与替代


让我们为所有销售创建常规配置。 文件projects/kicker/tiers/prod.yaml包含一个指示,表明我们需要将带有防火墙的类连接到此环境(当然,prod都一样),以及提供更高安全级别的某个类:


 --- classes: - semrush_firewall - strict_security_level 

对于开发环境,如果我们需要描述特定的内容,则会创建相同的文件,并在其中输入必要的配置。


接下来,您仍然需要为开发环境中的frontend角色的nginx配置重新定义变量。 为此,您需要创建文件projects/kicker/tiers/dev/frontend.yaml 。 注意新的层次结构。


 --- profiles::webserver::nginx::servers: 'kicker-dev.semrush.com': use_default_location: false listen_port: 80 server_name: - 'kicker-dev.semrush.com' profiles::webserver::nginx::locations: 'kicker-root': location: '/' server: 'kicker-dev.semrush.com' proxy: 'http://kicker-backend-dev.semrush.com:8080' proxy_set_header: - 'X-Real-IP $remote_addr' - 'X-Forwarded-for $remote_addr' - 'Host kicker-dev.semrush.com' location_cfg_append: 'proxy_next_upstream': 'error timeout invalid_header http_500 http_502 http_503 http_504' proxy_connect_timeout: '5' 

您不再需要指定一个类;它是从层次结构的先前级别继承的。 在这里,我们更改了server_nameproxy_pass 。 具有事实角色= frontend和tier = dev的服务器将首先为其自身找到projects/kicker/frontend.yaml文件,但是此文件中的变量将被优先级更高的projects/kicker/tiers/dev/frontend.yaml文件覆盖。


PostgreSQL的密码隐藏


因此,我们在议程上有最后一项-设置PostgreSQL的密码。


密码在环境中应有所不同。 我们将使用eyaml安全地存储密码。 创建密码:


 eyaml encrypt -s 'verysecretpassword' eyaml encrypt -s 'testpassword' 

我们将结果行分别粘贴到**projects/kicker/tiers/prod/db.yaml****projects/kicker/tiers/dev/db.yaml** (或者您可以使用eyaml扩展名,这是可自定义的)。 这是一个例子:


 --- profiles::db::postgresql::roles: 'kicker': password_hash: > 'ENC[PKCS7,MIIBeQYJKoZIhvcNAQcDoIIBajCCAWYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAsdpb2P0axUJzyWr2duRKAjh0WooGYUmoQ5gw0nO9Ym5ftv6uZXv25DRMKh7vsbzrrOR5/lLesx/pAVmcs2qbhd/y0Vr1oc2ohHlZBBKtCSEYwem5VN+kTMhWPvlt93x/S9ERoBp8LrrsIvicSYZByNfpS2DXCFbogSXCfEPxTTmCOtlOnxdjidIc9Q1vfAXv7FRQanYIspr2UytScm56H/ueeAc/8RYK51/nXDMtdPOiAP5VARioUKyTDSk8FqNvdUZRqA3cl+hA+xD5PiBHn5T09pnH8HyE/39q09gE0pXRe5+mOnU/4qfqFPc/EvAgAq5mVawlCR6c/cCKln5wJTA8BgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBDNKijGHBLPCth0sfwAjfl/gBAaPsfvzZQ/Umgjy1n+im0s]' 

接下来,将提供kicker角色的密码,并将其解密并应用于PostgreSQL中的数据库服务器。


实际上,仅此而已。 是的,这个例子证明是巨大的,但是,我希望它是可行的,不会留下任何问题,既清晰又有用。 在hiera中得到的层次结构是这样的:


 . ├── db.yaml ├── docker.yaml ├── frontend.yaml └── tiers ├── dev │ ├── db.yaml │ └── frontend.yaml ├── prod │ └── db.yaml └── prod.yaml 3 directories, 7 files 

您可以通过克隆专门创建的存储库来实时观看这些文件


结论


Puppet很好,并且易于与hiera一起使用。 我根本不会称它为现代世界中理想的配置工具,但它值得关注。 他很好地处理了一些任务,他的“哲学”保持资源和配置的状态始终相同,可以在确保配置的安全性和统一性方面发挥重要作用。


现代世界正在逐步协同发展。 现在很少有人只使用一个配置系统,通常在devops和admin的人员中,一次有多个系统。 这很好,因为有很多选择。 最主要的是,一切都应该是逻辑且可理解的,如何以及在何处进行配置。


因此,我们作为管理员的目标是自己不配置任何内容。 理想情况下,所有这些都应由团队自己完成。 我们必须给他们提供一种工具或产品,使我们能够安全,轻松且最重要的是获得准确的结果。 好吧,比起“您需要在服务器上安装PostgreSQL并创建用户”,它还可以帮助解决体系结构和更严重的问题。 卡蒙,2018在院子里! 因此,丢下木偶和烦恼,走向无服务器的未来。


随着云,容器化和容器编排系统的发展,配置管理系统正逐渐退回到用户和客户的后台。 您还可以在云中构建容器的故障安全群集,并通过自动滑动,备份,复制,自动发现等将应用程序保存在容器中,而无需为ansible,puppet,chef等编写一行。 您无需照顾任何事情(好了,差不多)。 另一方面,由于云的原因,铁服务器数量较少。 只是您不再需要配置它们,此操作是云提供商的责任。 但是他们不太可能使用与普通凡人相同的系统。


学分


谢谢:


  • Dmitry Tupitsin,Dmitry Loginov,Stepan Fedorov和整个系统管理员团队在编写本文时提供了帮助
  • 弗拉基米尔·莱格科斯托波夫(Vladimir Legkostupov)的照片
  • Yana Tabakova组织了所有这些工作,并帮助完成了所有预发布阶段
  • Nikita Zakharov在许可事宜上提供协助

Source: https://habr.com/ru/post/zh-CN412587/


All Articles