Серия «Под капотом»: LVM

—

от автора

В наши дни управлять томами в Linux — совершенно тривиальная задача, которая не вызывает в инженере ни чувства азарта, ни жажды исследования. Наборами команд, инструкциями по созданию томов и снэпшотов — ими же кишит весь интернет. Тем не менее, беспокойный инженерный ум требует разобраться, как же это работает под капотом. А происходит это весьма интересно. Но для начала, как обычно, позвольте небольшую историческую справку (на 40 минут). Приятного чтения:)

Как было раньше?

Если мы отмотаем время назад и посмотрим, как выглядела работа с дисками в ее первозданном виде (когда деревья были высокими, а трава зеленее), то здесь сюрпризов нет: блочное устройство разделяется на партиции, на них создается файловая система (или не создается –– работа с блочными устройствами такая же неотъемлемая часть Linux).

На этом этапе нам интересно освежить несколько моментов:

Момент 1

Если мы выполняем ls для блочных устройств, то там видны пары цифр:

# ls -la /dev/sda* brw-rw----. 1 root disk 8, 0 Apr 18 11:54 /dev/sda brw-rw----. 1 root disk 8, 1 Apr 18 11:54 /dev/sda1 brw-rw----. 1 root disk 8, 2 Apr 18 11:54 /dev/sda2 brw-rw----. 1 root disk 8, 3 Apr 18 11:54 /dev/sda3

Это не что иное как, пара major и minor номера. Major указывает на драйвер, работающий с данным устройством, а minor — порядковый номер устройства:

# grep sd /proc/devices    8 sd  65 sd  66 sd . . . . 

Момент 2

Из этого происходит и полное имя блочного устройства:

sd — имя драйвера;

a — порядковый номер диска;

1, 2, 3, 4… — порядковый номер партиции.

Момент 3

Если мы посмотрим на диски пристальнее, то заметим: на каждое блочное устройство резервируется 15 minor-номеров. Один — на имя всего диска, остальные — под партиции:

[root@cluster-2 ~]# ls -la /dev/sd* brw-rw----. 1 root disk 8,   0 Apr 18 11:54 /dev/sda brw-rw----. 1 root disk 8,   1 Apr 18 11:54 /dev/sda1 brw-rw----. 1 root disk 8,   2 Apr 18 11:54 /dev/sda2 brw-rw----. 1 root disk 8,   3 Apr 18 11:54 /dev/sda3 brw-rw----. 1 root disk 8,  16 Apr 18 11:54 /dev/sdb brw-rw----. 1 root disk 8,  32 Apr 18 11:54 /dev/sdc brw-rw----. 1 root disk 8,  48 Apr 18 11:54 /dev/sdd brw-rw----. 1 root disk 8,  64 Apr 18 11:54 /dev/sde brw-rw----. 1 root disk 8,  80 Apr 18 11:54 /dev/sdf brw-rw----. 1 root disk 8,  96 Apr 18 11:54 /dev/sdg brw-rw----. 1 root disk 8, 112 Apr 18 11:54 /dev/sdh

Почему резервируется столько minor-номеров, если можно создать только четыре primary-партиции? Это связано с extended-партициями. Причем если рассматривать их максимальное количество, то вопрос «Почему так много?» сменяется противоположным «Почему так мало?». Добавим еще один диск и посмотрим, что же можно «выжать» из него:

# ls -la /dev/sd* brw-rw----. 1 root disk 8,   0 Apr 18 11:54 /dev/sda brw-rw----. 1 root disk 8,   1 Apr 18 11:54 /dev/sda1 brw-rw----. 1 root disk 8,   2 Apr 18 11:54 /dev/sda2 brw-rw----. 1 root disk 8,   3 Apr 18 11:54 /dev/sda3 brw-rw----. 1 root disk 8,  16 Apr 18 11:54 /dev/sdb brw-rw----. 1 root disk 8,  32 Apr 18 11:54 /dev/sdc brw-rw----. 1 root disk 8,  48 Apr 18 11:54 /dev/sdd brw-rw----. 1 root disk 8,  64 Apr 18 11:54 /dev/sde brw-rw----. 1 root disk 8,  80 Apr 18 11:54 /dev/sdf brw-rw----. 1 root disk 8,  96 Apr 18 11:54 /dev/sdg brw-rw----. 1 root disk 8, 112 Apr 18 11:54 /dev/sdh brw-rw----. 1 root disk 8, 128 Apr 21 18:38 /dev/sdi

Начинаем делать extended-партиции и видим, что список minor-номеров продлился:

# ls -la /dev/sdi* brw-rw----. 1 root disk   8, 128 Apr 21 18:55 /dev/sdi brw-rw----. 1 root disk   8, 129 Apr 21 18:55 /dev/sdi1 brw-rw----. 1 root disk   8, 138 Apr 21 18:55 /dev/sdi10 brw-rw----. 1 root disk   8, 139 Apr 21 18:55 /dev/sdi11 brw-rw----. 1 root disk   8, 140 Apr 21 18:55 /dev/sdi12 brw-rw----. 1 root disk   8, 141 Apr 21 18:55 /dev/sdi13 brw-rw----. 1 root disk   8, 142 Apr 21 18:55 /dev/sdi14 brw-rw----. 1 root disk   8, 143 Apr 21 18:55 /dev/sdi15 brw-rw----. 1 root disk 259,   4 Apr 21 18:55 /dev/sdi16 brw-rw----. 1 root disk 259,   5 Apr 21 18:55 /dev/sdi17 brw-rw----. 1 root disk 259,   6 Apr 21 18:55 /dev/sdi18 brw-rw----. 1 root disk 259,   7 Apr 21 18:55 /dev/sdi19 brw-rw----. 1 root disk   8, 130 Apr 21 18:55 /dev/sdi2 brw-rw----. 1 root disk   8, 131 Apr 21 18:55 /dev/sdi3 brw-rw----. 1 root disk   8, 132 Apr 21 18:55 /dev/sdi4 brw-rw----. 1 root disk   8, 133 Apr 21 18:55 /dev/sdi5 brw-rw----. 1 root disk   8, 134 Apr 21 18:55 /dev/sdi6 brw-rw----. 1 root disk   8, 135 Apr 21 18:55 /dev/sdi7 brw-rw----. 1 root disk   8, 136 Apr 21 18:55 /dev/sdi8 brw-rw----. 1 root disk   8, 137 Apr 21 18:55 /dev/sdi9

И их максимальное количество может быть 60. В исходном коде драйвера sd есть строки, описывающие механизм резервации:

 Inside a major, we have 16k disks, however mapped non-  * contiguously. The first 16 disks are for major0, the next  * ones with major1, ... Disk 256 is for major0 again, disk 272   * for major1, ...   * As we stay compatible with our numbering scheme, we can reuse   * the well-know SCSI majors 8, 65--71, 136--143.  */

Момент 4

 Раньше мы могли создать достаточно большое количество партиций и решить проблему c делением диска классическим способом.

Примечание: Не забываем про динамическую природу именования устройств в Linux. В зависимости от последовательности, в которой ОС инициализировала устройства, их имена могут изменяться. И так то, что было, например,  /dev/sdi, станет /dev/sdb, а после очередной перезагрузки — наоборот.

Как решили проблему объединения

Но что если нам, наоборот, нужно объединить несколько дисков в одно большое блочное устройство?

Итак, теперь мы решаем задачу объединения. Можно закрыть много вопросов аппаратным RAID-контроллером, но это слишком просто и, относительно 0 рублей, затрачиваемых на Software-RAID, дорого.

Долгое время штатным решением считалось использование модуля md (multiple devices) и создание, например, конкатенации (concatenation).

Примечание автора: иногда stripe и concat относят к RAID0 (например, в Solaris):

# mdadm --create --verbose /dev/md0 --level=linear --raid-devices=2 /dev/sdi1 /dev/sdi2 mdadm: Defaulting to version 1.2 metadata mdadm: array /dev/md0 started.

Посмотрим на получившееся устройство:

# ls -la /dev/md0 brw-rw----. 1 root disk 9, 0 Apr 22 14:03 /dev/md0

А кто же обслуживает major под номером 9?

# grep md /proc/devices    9 md 254 mdp

Вроде эту задача решили, и можно так жить дальше. Расходимся. К нашей радости, ИТ-мир гораздо более сознателен и не останавливается на половине пути, за что мы его и любим.

Logical Volume Manager (LVM)

В «кровавом Enterprise» работа с дисками давно была организована на принципиально новом уровне, но за деньги:

1) Мы могли разметить диски для использования LVM (инициализировать их).

2) Диски объединялись в логические группы на основании, например, приложения, работающего с ними (дисковые группы).

3) В этих логических группах создавались уже сами тома различной логики (RAID, размещение на дисках с учетом местоположения дисков на SCSI-контроллерах).

Решить все перечисленные задачи в Linux стало возможно с помощью карты device-mapper.

Примечание: device-mapper — это модуль ядра, предоставляющий фреймворк для работы с дисковыми устройствами. На основании этих карт LVM создает свои устройства.

Чем же занимается device-mapper? Он может из «кусков» блочных устройств создавать новое виртуальное устройство, которое для ОС выглядит как совершенно обычное блочное устройство. Работает это следующим образом: приложения/команды используют библиотеку libdevmapper, которая при помощи команд ioctl передает данные в устройство /dev/mapper/control

# strace dmsetup ls execve("/usr/sbin/dmsetup", ["dmsetup", "ls"], 0x7fff2bea9258 /* 25 vars */) = 0 brk(NULL)                               = 0x55e3c31ee000 arch_prctl(0x3001 /* ARCH_??? */, 0x7ffef62227b0) = -1 EINVAL (Invalid argument) access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=48415, ...}) = 0 mmap(NULL, 48415, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f3c1e240000 close(3)                                = 0 openat(AT_FDCWD, "/lib64/libdevmapper.so.1.02", O_RDONLY|O_CLOEXEC) = 3  . . . . . . . . . . . . . .  mmap(NULL, 337024, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f3c1e1dd000 close(3)                                = 0 uname({sysname="Linux", nodename="master", ...}) = 0 stat("/dev/mapper/control", {st_mode=S_IFCHR|0600, st_rdev=makedev(0xa, 0xec), ...}) = 0 openat(AT_FDCWD, "/dev/mapper/control", O_RDWR) = 3 openat(AT_FDCWD, "/proc/devices", O_RDONLY) = 4 fstat(4, {st_mode=S_IFREG|0444, st_size=0, ...}) = 0 read(4, "Character devices:\n  1 mem\n  4 /"..., 1024) = 550 close(4)                                = 0

По сути, каждый блок вашего виртуального устройства может быть произвольным блоком физического. Главное — правильно описать его в карте, чтобы избежать повторов и т.д.  

Карты device-mapper

Мы можем создать файл-описание нашего виртуального устройства со следующим содержимым:

0 100000 linear 8:64 0

Это следует читать так: «создаем виртуальное устройство, с нулевого блока этого виртуального устройства используем 100 000 блоков последовательно (линейно) c устройства 8:64 (major:minor) с его начала, без отступа (offset)». Небольшая подсказка про то, какое устройство будет использовано:

# ls -la /dev/sd* brw-rw---- 1 root disk 8,  0 May  8 13:24 /dev/sda brw-rw---- 1 root disk 8,  1 May  8 13:24 /dev/sda1 brw-rw---- 1 root disk 8,  2 May  8 13:24 /dev/sda2 brw-rw---- 1 root disk 8, 16 May  8 13:24 /dev/sdb brw-rw---- 1 root disk 8, 32 May  8 13:24 /dev/sdc brw-rw---- 1 root disk 8, 48 May  8 13:24 /dev/sdd brw-rw---- 1 root disk 8, 64 May 18 22:01 /dev/sde brw-rw---- 1 root disk 8, 80 May 18 22:01 /dev/sdf  echo "0 100000 linear 8:64 0" > /tmp/my-linear-device

Теперь выполняем команду dmsetup:

[root@oel78 ~]# dmsetup create my-linear-device /tmp/my-linear-device

Проверяем, появилось ли новое устройство:

#ls -la /dev/mapper/ total 0 drwxr-xr-x  2 root root     120 May 18 22:21 . drwxr-xr-x 19 root root    3400 May 18 22:21 .. crw-------  1 root root 10, 236 May  8 13:24 control lrwxrwxrwx  1 root root       7 May 18 22:21 my-linear-device -> ../dm-2 lrwxrwxrwx  1 root root       7 May  8 13:24 cl-root -> ../dm-0 lrwxrwxrwx  1 root root       7 May  8 13:24 cl-swap -> ../dm-1

Помните — ядро позволяет задавать произвольные имена, но делает это как symlink на последовательно создаваемые устройства dm-*.

Теперь с этим устройством можно работать как с обычным диском. Как вы видите, 100 000 блоков на месте:

# fdisk /dev/mapper/my-linear-device Welcome to fdisk (util-linux 2.23.2).  Changes will remain in memory only, until you decide to write them. Be careful before using the write command.   Command (m for help): p  Disk /dev/mapper/my-linear-device: 51 MB, 51200000 bytes, 100000 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk label type: dos Disk identifier: 0x4abaf638                         Device Boot      Start         End      Blocks   Id  System  Command (m for help):

Мы также можем сделать файловую систему на нем:

[root@oel78 ~]# mkfs.xfs -f /dev/mapper/my-linear-device  meta-data=/dev/mapper/my-linear-device isize=256    agcount=2, agsize=6250 blks          =                       sectsz=512   attr=2, projid32bit=1          =                       crc=0        finobt=0, sparse=0 data     =                       bsize=4096   blocks=12500, imaxpct=25          =                       sunit=0      swidth=0 blks naming   =version 2              bsize=4096   ascii-ci=0 ftype=1 log      =internal log           bsize=4096   blocks=853, version=2          =                       sectsz=512   sunit=0 blks, lazy-count=1 realtime =none                   extsz=4096   blocks=0, rtextents=0

смонтировать ее:

# mount /dev/mapper/my-linear-device /mnt

Проверить ее размер:

# df -h Filesystem                    Size  Used Avail Use% Mounted on devtmpfs                       12G     0   12G   0% /dev tmpfs                          12G     0   12G   0% /dev/shm tmpfs                          12G   42M   12G   1% /run tmpfs                          12G     0   12G   0% /sys/fs/cgroup /dev/mapper/cl-root            33G   31G  2.0G  94% / /dev/mapper/my-linear-device   46M  2.6M   43M   6% /mnt tmpfs                         2.4G   44K  2.4G   1% /run/user/0 /dev/sr0                      4.6G  4.6G     0 100% /run/media/root/OL-7.9 Server.x86_64 

В списке карт device-mapper есть запись и о нашей карте:

# dmsetup table my-linear-device: 0 100000 linear 8:64 0 cl-swap: 0 2097152 linear 8:2 2048 cl-root: 0 122904576 linear 8:2 2099200 lab-test--vol: 0 4194304 linear 8:112 2048

В системе уже есть linear устройства. Они созданы при инсталляции в VG с именем cl, а также во второй VG с именем lab уже руками.

Главное различие карт device-mapper, созданных вручную и через LVM, заключается в том, что созданное вручную живет до первой перезагрузки, а LVMсохраняет свою конфигурацию:

# ls -la /etc/lvm/ total 132 drwxr-xr-x.   6 root root    100 Jun  6  2024 . drwxr-xr-x. 152 root root  12288 Nov 20 23:17 .. drwx------.   2 root root   4096 Oct 30 11:47 archive drwx------.   2 root root   4096 Oct 30 11:47 backup drwx------.   2 root root      6 Mar 11  2021 cache -rw-r--r--.   1 root root 103867 Mar 11  2021 lvm.conf -rw-r--r--.   1 root root   2301 Mar 11  2021 lvmlocal.conf drwxr-xr-x.   2 root root    245 Jun  6  2024 profile  # ls -la /etc/lvm/backup/ total 56 drwx------. 2 root root 4096 Oct 30 11:47 . drwxr-xr-x. 6 root root  100 Jun  6  2024 .. -rw-------. 1 root root 1736 Jun  6  2024 cl -rw-------  1 root root 1297 Oct 30 11:47 lab

Заглянем внутрь файла /etc/lvm/backup/lab:

Скрытый текст
# Generated by LVM2 version 2.03.11(2)-RHEL8 (2021-01-28): Wed Oct 30 11:47:48 2024  contents = "Text Format Volume Group" version = 1  description = "Created *after* executing 'pvscan --cache --activate ay 8:112'"  creation_host = "master"        # Linux master 4.18.0-348.7.1.el8_5.x86_64 #1 SMP Wed Dec 22 13:25:12 UTC 2021 x86_64 creation_time = 1730278068      # Wed Oct 30 11:47:48 2024  lab {         id = "GIYURR-zK0a-WPp2-Grqh-3OTM-Likw-4hvWEO"         seqno = 4         format = "lvm2"                 # informational         status = ["RESIZEABLE", "READ", "WRITE"]         flags = []         extent_size = 8192              # 4 Megabytes         max_lv = 0         max_pv = 0         metadata_copies = 0          physical_volumes {                  pv0 {                         id = "twNfpj-oa9b-IscO-WNB0-Bhqu-eCQE-y3R1c6"                         device = "/dev/sdh"     # Hint only                          status = ["ALLOCATABLE"]                         flags = []                         dev_size = 46137344     # 22 Gigabytes                         pe_start = 2048                         pe_count = 5631 # 21.9961 Gigabytes                 }         }          logical_volumes {                  test-vol {                         id = "CPAXjT-gLOV-yrF6-cR8D-6X3x-6NyT-zNyihQ"                         status = ["READ", "WRITE", "VISIBLE"]                         flags = []                         creation_time = 1730213672      # 2024-10-29 17:54:32 +0300                         creation_host = "master"                         segment_count = 1   segment1 {                                 start_extent = 0                                 extent_count = 512      # 2 Gigabytes                                  type = "striped"                                 stripe_count = 1        # linear                                  stripes = [                                         "pv0", 0

И теперь для наглядности еще раз конфигурация карты device-mapper:

lab-test--vol: 0 4194304 linear 8:112 2048

Легко заметить аналогии, а если заняться математикой, то их станет еще больше, т.к. 2 GiB (2147483648) / 512 = 4194304

# ls -la /dev/sdh brw-rw---- 1 root disk 8, 112 Nov 20 22:43 /dev/sdh

Т.е. LVM просто хранит конфигурацию VG в файле. Но только ли в файле?

LVM умеет передавать VGмежду хостами (export/import). Логично предположить, что конфигурация где-то записывается на диске. Так и есть:

Скрытый текст
# dd if=/dev/sdh of=/tmp/header count=1 bs=1M 1+0 records in 1+0 records out 1048576 bytes (1.0 MB, 1.0 MiB) copied, 0.0250576 s, 41.8 MB/s   # strings /tmp/header   LABELONE LVM2 001twNfpjoa9bIscOWNB0BhqueCQEy3R1c6 e LVM2 x[5A%r0N*> pt-c lab { id = "GIYURR-zK0a-WPp2-Grqh-3OTM-Likw-4hvWEO" seqno = 1 format = "lvm2" status = ["RESIZEABLE", "READ", "WRITE"] flags = [] extent_size = 8192 max_lv = 0 max_pv = 0 metadata_copies = 0 physical_volumes { pv0 { id = "twNfpj-oa9b-IscO-WNB0-Bhqu-eCQE-y3R1c6" device = "/dev/sdh" status = ["ALLOCATABLE"] flags = [] dev_size = 46137344 pe_start = 2048 pe_count = 5631 # Generated by LVM2 version 2.03.11(2)-RHEL8 (2021-01-28): Tue Oct 29 17:53:27 2024 contents = "Text Format Volume Group" version = 1 description = "Write from vgcreate lab /dev/sdh." creation_host = "master"        # Linux master 4.18.0-348.7.1.el8_5.x86_64 #1 SMP Wed Dec 22 13:25:12 UTC 2021 x86_64 creation_time = 1730213607      # Tue Oct 29 17:53:27 2024 lab { id = "GIYURR-zK0a-WPp2-Grqh-3OTM-Likw-4hvWEO" seqno = 2 format = "lvm2" status = ["RESIZEABLE", "READ", "WRITE"] flags = [] extent_size = 8192 max_lv = 0 max_pv = 0 metadata_copies = 0 physical_volumes { pv0 { id = "twNfpj-oa9b-IscO-WNB0-Bhqu-eCQE-y3R1c6" device = "/dev/sdh" status = ["ALLOCATABLE"] flags = [] dev_size = 46137344 pe_start = 2048 pe_count = 5631 logical_volumes { test-vol { id = "CPAXjT-gLOV-yrF6-cR8D-6X3x-6NyT-zNyihQ" status = ["READ", "WRITE", "VISIBLE"] flags = [] creation_time = 1730213672 creation_host = "master" segment_count = 1 segment1 { start_extent = 0 extent_count = 512 type = "striped" stripe_count = 1 stripes = [ "pv0", 0

и так далее… Т.е. конфигурация хранится еще и на самом диске.

Типы карт device-mapper

Теперь стоит уделить внимание, какими бывают device-mapper. Сейчас карты бывают:

  • linear

  • striped

  • mirror

  • snapshot и snapshot-origin

  • error

  • zero

  • multipath

  • crypt

И если большинство из них интуитивно понятны, то некоторые требуют пояснения. Давайте сначала посмотрим на простую карту, а потом обсудим уже различные типы карт и заглянем за кулисы.

Striped — карта, позволяющая получить stripe-устройство (RAID0).

Mirror — классический RAID1.

snapshot и snapshot-origin — карты, участвующие в создании snapshot какого-либо тома. Например:

# lvcreate -s -L 500M -n test-vol-snap lab/test-vol   Logical volume "test-vol-snap" created.  # lvs   LV            VG  Attr       LSize   Pool Origin   Data%  Meta%  Move Log Cpy%Sync Convert   root          cl  -wi-ao---- <58.61g                                                         swap          cl  -wi-ao----   1.00g                                                         test-vol      lab owi-aos---   2.00g                                                         test-vol-snap lab swi-a-s--- 500.00m      test-vol 0.00 

и если опять обратиться к картам, то увидим нечто не очень привычное:

# dmsetup table . . . . . . . . . . . . . . . . . . . . . . . . . . . .  lab-test--vol: 0 4194304 snapshot-origin 253:4 lab-test--vol--snap-cow: 0 1024000 linear 8:112 4196352 lab-test--vol--snap: 0 4194304 snapshot 253:4 253:5 P 8 lab-test--vol-real: 0 4194304 linear 8:112 2048

RedHat в документации объясняет, откуда все это берется:

https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/7/html/logical_volume_manager_administration/device_mapper#dm-mappings

When you create the first LVM snapshot of a volume, four Device Mapper devices are used:

1. A device with a linear mapping containing the original mapping table of the source volume.

2. A device with a linear mapping used as the copy-on-write (COW) device for the source volume; for each write, the original data is saved in the COW device of each snapshot to keep its visible content unchanged (until the COW device fills up).

3. A device with a snapshot mapping combining #1 and #2, which is the visible snapshot volume

4. The «original» volume (which uses the device number used by the original source volume), whose table is replaced by a «snapshot-origin» mapping from device #1.

Вот почему их четыре. Идем дальше.

Multipath — позволяет создать устройство, доступное по нескольким путям с различными настройками:

# multipath -ll mpathb (360014057cac55ee036b4bb084f42edf0) dm-2 LIO-ORG,test-vol size=2.0G features='0' hwhandler='1 alua' wp=rw |-+- policy='service-time 0' prio=50 status=active | `- 34:0:0:0 sdb 8:16 active ready running `-+- policy='service-time 0' prio=50 status=enabled   `- 33:0:0:0 sdc 8:32 active ready running  # dmsetup table mpathb: 0 4194304 multipath 0 1 alua 2 1 service-time 0 1 2 8:16 1 1 service-time 0 1 2 8:32 1 1

Информацию об остальных типах можно найти здесь.

Могло ли все быть по-другому?

В Linux существует, как всегда, несколько проектов/решений, и развитие может свернуть на какую-то другую дорогу (привет, SystemD). Так и LVM мог проиграть конкуренцию EVMS, разработанную IBM, но победил.

Вывод

Теперь мы с вами еще немного глубже понимаем работу Linux, что не может нас не радовать. Ну и конечно, плюсик в карму нам обеспечен.


ссылка на оригинал статьи https://habr.com/ru/articles/877538/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *