Пользователь
0,0
рейтинг
28 августа 2014 в 14:09

Разработка → Оптимизация образов Docker из песочницы

Образы Docker могуть быть очень большими. Многие превышают 1 Гб в размере. Как они становятся такими? Должны ли они быть такими? Можем ли мы сделать их меньше, не жертвуя функциональностью?

В CenturyLink Lab мы много работали над сборкой различных docker-образов в последнее время. Когда мы начали экспериментировать с их созданием, мы обнаружили, что наши сборки очень быстро раздуваются в объеме (обычным делом было собрать образ, который весит 1 Гб или больше). Размер, конечно, не столь важен, если мы говорим про образы по два гига, лежащие на локальной машине. Но это становится проблемой, когда вы начинаете постоянно скачивать/отправлять эти образы через интернет.

Я решил, что стоит копнуть поглубже и разобраться с тем, как работает процесс создания docker-образов, чтобы понять, что можно сделать для уменьшения размера наших сборок.

В качестве небольшого отступления, Адриан де Йонг (Adriaan de Jonge) недавно опубликовал статью «Создание самого маленького возможного контейнера Docker», в которой он описал как собрать образ, не содержащий ничего, кроме статически слинкованного бинарника Go, который запускается вместе с контейнером. Его образ поразительно мал — 3.6 Мб. Здесь я не буду рассматривать подобные крайности. Как человек, привыкший работать с языками вроде Python и Ruby мне нужен чуть больший уровень поддержки со стороны ОС, и я с радостью принесу в жертву сотню мегабайт свободного места, чтобы иметь возможность запускать Debian и apt-get install-ить свои зависимости. Поэтому, хоть я и завидую крошечному образу Адриана, но мне нужна поддержка более широкого круга приложений, что делает его подход непрактичным.

Слои


Прежде, чем мы перейдем к теме уменьшения ваших образов, нужно поговорить о слоях. Концепция слоев затрагивает различные низкоуровневые технические детали о вещах вроде корневой файловой системы (rootfs), механизма копирования при записи (copy-on-write) и каскадно-объединенного монтирования (union mount). К счастью, эта тема достаточно хорошо раскрыта в другом месте, поэтому я не буду пересказывать ее здесь. Для наших целей важным является понимание того, что каждая инструкция в Dockerfile приводит к созданию нового слоя образа.

Давайте взглянем на пример Dockerfile, чтобы увидеть это в действии:

FROM debian:wheezy
RUN mkdir /tmp/foo
RUN fallocate -l 1G /tmp/foo/bar

Совершенно бесполезный образ, но он поможет нам продемонстрировать сказанное. Здесь мы используем debian:wheezy в качестве базового образа, создаем директорию /tmp/foo, а в ней выделяем 1 Гб места под файл bar.

Соберем этот образ:

$ docker build -t sample .
Sending build context to Docker daemon  2.56 kB
Sending build context to Docker daemon 
Step 0 : FROM debian:wheezy
 ---> e8d37d9e3476
Step 1 : RUN mkdir /tmp/foo
 ---> Running in 3d5d8b288cc2
 ---> 9876aa270471
Removing intermediate container 3d5d8b288cc2
Step 2 : RUN fallocate -l 1G /tmp/foo/bar
 ---> Running in 6c797329ee43
 ---> 3ebe08b36733
Removing intermediate container 6c797329ee43
Successfully built 3ebe08b36733

Если вы посмотрите на результат выполнения команды docker build, вы сможете увидеть что именно делает Docker, чтобы построить наш образ:

  1. Используя значение инструкции FROM, Docker запускает контейнер на базе debian:wheezy образа (ID контейнера: 3d5d8b288cc2)
  2. Внутри этого контейнера Docker выполняет команду mkdir /tmp/foo
  3. Контейнер остановлен, закоммичен (в результате создан новый образ с ID 9876aa270471) и затем удален
  4. Docker запускает другой контейнер, на этот раз из образа, сохраненного на предыдущем шаге (у этого контейнера ID 6c797329ee43)
  5. Внутри запущенного контейнера Docker выполняет команду fallocate -l 1G /tmp/foo/bar
  6. Контейнер остановлен, закоммичен (в результате создан новый образ с ID 3ebe08b36733) и затем удален

Мы можем увидеть конечный результат запустив команду docker images --tree (к сожалению, флаг --tree устарел и, скорее всего, будет удален в будущих релизах):

$ docker images --tree
Warning: '--tree' is deprecated, it will be removed soon. See usage.
└─511136ea3c5a Virtual Size: 0 B Tags: scratch:latest
  └─59e359cb35ef Virtual Size: 85.18 MB
    └─e8d37d9e3476 Virtual Size: 85.18 MB Tags: debian:wheezy
      └─9876aa270471 Virtual Size: 85.18 MB
        └─3ebe08b36733 Virtual Size: 1.159 GB Tags: sample:latest

Тут вы можете увидеть образ помеченный, как debian:wheezy, после которого идут два контейнера, о которых говорилось ранее (по одному на каждую инструкцию в Dockerfile).

Мы часто говорим о слоях и образах так, словно это разные вещи. Но, на самом деле, каждый слой — это уже образ, а слой образа — это просто коллекция других образов.

Так же, как мы выполним:
docker run -it sample:latest /bin/bash
Мы легко можем запустить один из неименованных слоев:
docker run -it 9876aa270471 /bin/bash

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

Размер образа


Зная, что образ — это не что иное, как коллекция других образов, можно прийти к очевидному выводу: размер образа равен сумме размеров образов, его составляющих.

Посмотрим на вывод команды docker history:

$ docker history sample
IMAGE         CREATED        CREATED BY                               SIZE
3ebe08b36733  3 minutes ago  /bin/sh -c fallocate -l 1G /tmp/foo/bar  1.074 GB
9876aa270471  3 minutes ago  /bin/sh -c mkdir /tmp/foo                0 B
e8d37d9e3476  4 days ago     /bin/sh -c #(nop) CMD [/bin/bash]        0 B
59e359cb35ef  4 days ago     /bin/sh -c #(nop) ADD file:1e2ba3d9379f  85.18 MB
511136ea3c5a  13 months ago                                           0 B

Мы можем увидеть все слои образа sample вместе с командами, которые привели к их созданию, и их размером (заметьте, что порядок слоев в docker history обратен порядку, отображаемому в docker images --tree).

Здесь только две инструкции, которые делают что-то значимое для нашего образа: инструкция ADD (наследуемая из debian:wheezy) и наша fallocate команда.

Давайте сохраним наш образ в tar-архив и посмотрим каков будет вес:

$ docker save sample > sample.tar
$ ls -lh sample.tar 
-rw-r--r-- 1 core core 1.1G Jul 26 02:35 sample.tar

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

Добавим еще одну инструкцию в Dockerfile:

FROM debian:wheezy
RUN mkdir /tmp/foo
RUN fallocate -l 1G /tmp/foo/bar
RUN rm /tmp/foo/bar

Новая инструкция удалит файл сразу же после его создания fallocate.

Если мы выполним docker build для обновленного Dockerfile и посмотрим на историю снова, мы увидим следующее:

$ docker history sample
IMAGE         CREATED         CREATED BY                               SIZE
9d9bdb929b00  8 seconds ago   /bin/sh -c rm /tmp/foo/bar               0 B
3ebe08b36733  24 minutes ago  /bin/sh -c fallocate -l 1G /tmp/foo/bar  1.074 GB
9876aa270471  24 minutes ago  /bin/sh -c mkdir /tmp/foo                0 B
e8d37d9e3476  4 days ago      /bin/sh -c #(nop) CMD [/bin/bash]        0 B
59e359cb35ef  4 days ago      /bin/sh -c #(nop) ADD file:1e2ba3d9379f  85.18 MB
511136ea3c5a  13 months ago                                            0 B

Заметьте, что вызов rm добавил новый слой (в 0 байт), но все остальное осталось по-прежнему. Если мы сохраним наш обновленный образ, то должны увидеть, что размер практически не изменился (будет небольшая разница из-за метаданных добавленного слоя):

$ docker save sample > sample.tar
$ ls -lh sample.tar
-rw-r--r-- 1 core core 1.1G Jul 26 02:55 sample.tar

Если бы мы вызвали docker run для этого образа и заглянули в директорию /tmp/foo, то обнаружили бы ее пустой (в конечном счете, файл был удален). Однако, так как наш Dockerfile сгенерировал слой, содержащий файл весом 1 Гб, тот стал неотъемлимой частью образа.

Каждая дополнительная инструкция в вашем Dockerfile будет только увеличивать общий размер образа.

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

Выберите вашу основу


Довольно очевидный совет. Однако, выбор основы может существенно повлиять на конечный размер образа. Вот, например, список популярных базовых образов и их размеры:

$ docker images
REPOSITORY   TAG      IMAGE ID       CREATED         VIRTUAL SIZE
scratch      latest   511136ea3c5a   13 months ago   0 B
busybox      latest   a9eb17255234   7 weeks ago     2.433 MB
debian       latest   e8d37d9e3476   4 days ago      85.18 MB
ubuntu       latest   ba5877dc9bec   4 days ago      192.7 MB
centos       latest   1a7dc42f78ba   2 weeks ago     236.4 MB
fedora       latest   88b42ffd1f7c   10 days ago     373.7 MB

Мы в команде раньше использовали ubuntu в качестве основы — в основном, потому что большинство из нас уже были с ней знакомы. Однако, немного поиграв с debian, мы пришли к выводу, что он полностью удовлетворяет нашим потребностям и сохраняет при этом 100+ Мб места.

Список полезных баз может быть разным и зависит от ваших нужд, но вам точно стоит его проверить. Если вы используете Ubuntu, когда хватило бы и BusyBox, то вы напрасно тратите кучу места.

Хотелось бы, чтобы размер образов отображался в хранилище Docker. Но сейчас, к сожалению, чтобы узнать размер, образ нужно скачать.

Используйте вашу основу повторно


Одним из преимуществ слойного подхода является возможность повторного использования слоев между разными образами. В примере ниже показаны три образа, использующих debian:wheezy в качестве основы:

$ docker images --tree
Warning: '--tree' is deprecated, it will be removed soon. See usage.
└─511136ea3c5a Virtual Size: 0 B Tags: scratch:latest
    └─e8d37d9e3476 Virtual Size: 85.18 MB Tags: debian:wheezy
      ├─22a0de5ea279 Virtual Size: 85.18 MB
      │ └─057ac524d834 Virtual Size: 85.18 MB
      │   └─bd30825f7522 Virtual Size: 106.2 MB Tags: creeper:latest
      ├─d689af903018 Virtual Size: 85.18 MB
      │ └─bcf6f6a90302 Virtual Size: 85.18 MB
      │   └─ffab3863d257 Virtual Size: 95.67 MB Tags: enderman:latest
      └─9876aa270471 Virtual Size: 85.18 MB
        └─3ebe08b36733 Virtual Size: 1.159 GB
          └─9d9bdb929b00 Virtual Size: 1.159 GB Tags: sample:latest

Каждый из них надстраивается над debian:wheezy, но это не три копии Debian. Вместо копирования каждый образ содержит ссылку на экземпляр Debian-слоя (одна из причин, по которой мне нравится docker images --tree, в том, что она наглядно демонстрирует связи между различными слоями).

Это означает, что один раз скачав debian:wheezy, вам больше не придется тянуть эти слои снова, и каждый его бит, используемый в образах, будет занимать место лишь единожды.

Так что вы можете сохранить немалое количество места и интернет-траффика, используя общую базу для разных образов.

Группируйте ваши команды


В примере выше мы создаем файл, а затем сразу же его удаляем. Ситуация хоть и надуманная, но нечто похожее часто происходит при построении образов. Давайте посмотрим на что-то более реалистичное:

FROM debian:wheezy

WORKDIR /tmp

RUN wget -nv  
RUN tar -xvf someutility-v1.0.0.tar.gz
RUN mv /tmp/someutility-v1.0.0/someutil /usr/bin/someutil
RUN rm -rf /tmp/someutility-v1.0.0 
RUN rm /tmp/someutility-v1.0.0.tar.gz

Мы скачиваем tar-архив, распаковываем его, кое-что перемещаем и подчищаем за собой.

Как мы убедились ранее, каждая из этих инструкций создает отдельный слой. Несмотря на то, что мы удаляем архив и извлеченные файлы, они все равно остаются частью образа.

$ docker history some utility
IMAGE    CREATED         CREATED BY                                     SIZE
33f4a99  16 seconds ago  /bin/sh -c rm /tmp/someutility-v1.0.0.tar.gz   0 B
fec7b5e  17 seconds ago  /bin/sh -c rm -rf /tmp/someutility-v1.0.0      0 B
0851974  18 seconds ago  /bin/sh -c mv /tmp/someutility-v1.0.0/someuti  12.21 MB
5b6b996  19 seconds ago  /bin/sh -c tar -xvf someutility-v1.0.0.tar.gz  99.91 MB
0eebad5  20 seconds ago  /bin/sh -c wget -nv http://centurylinklabs.com  55.34 MB
d6798fc  8 minutes ago   /bin/sh -c #(nop) WORKDIR /tmp                 0 B
e8d37d9  5 days ago      /bin/sh -c #(nop) CMD [/bin/bash]              0 B
59e359c  5 days ago      /bin/sh -c #(nop) ADD file:1e2ba3d9379f7685a1  85.18 MB
511136e  13 months ago                                                  0 B

Запуск wget приводит к появлению слоя размером 55 Мб, а распаковка архива к слою в 99 Мб. Нам не нужны эти файлы, а значит мы просто тратим 150+ Мб впустую.

Мы можем исправить это, проведя небольшой рефакторинг нашего Dockerfile:

FROM debian:wheezy

WORKDIR /tmp

RUN wget -nv  && \
  tar -xvf someutility-v1.0.0.tar.gz && \
  mv /tmp/someutility-v1.0.0/someutil /usr/bin/someutil && \
  rm -rf /tmp/someutility-v1.0.0 && \
  rm /tmp/someutility-v1.0.0.tar.gz

Вместо запуска каждой команды в отдельной инструкции RUN мы сгруппировали их с помощью оператора &&. И хотя Dockerfile становится чуть менее читабельным, это позволяет нам удалить tar-архив и извлеченную директорию прежде, чем слой будет закоммичен.

Вот, что получилось в результате:

$ docker history some utility
IMAGE   CREATED        CREATED BY                                     SIZE
8216b5f 7 seconds ago  /bin/sh -c wget -nv http://centurylinklabs.com  12.21 MB
d6798fc 17 minutes ago /bin/sh -c #(nop) WORKDIR /tmp                 0 B
e8d37d9 5 days ago     /bin/sh -c #(nop) CMD [/bin/bash]              0 B
59e359c 5 days ago     /bin/sh -c #(nop) ADD file:1e2ba3d9379f7685a1  85.18 MB
511136e 13 months ago                                                 0 B


Заметьте, что в итоге мы получили такой же образ, при этом избавившись от нескольких лишних слоев и сохранив 150 Мб свободного пространства.

Я бы не советовал вам срочно пойти и переписать все команды в ваших Dockerfile в одну строчку. Однако, если вы замечаете, что где-то есть похожая ситуация, когда вы создаете, а потом удаляете файлы, то совмещение нескольких инструкций в одну поможет вам держать размер образа минимальным.

«Схлопывайте» ваши образы


Все вышеописаные стратегии исходят из предположения о том, что вы создаете свой собственный образ, или, хотя-бы, имеете доступ к Dockerfile. Однако, возможна ситуация, когда у вас есть образ, созданный кем-то другим, и вы хотите немного облегчить его.

В этом случае мы можем воспользоваться тем фактом, что создание контейнера приводит к слиянию всех слоев в один.

Давайте вернемся к нашему образу sample (тому, который с fallocate и rm) и запустим его:

$ docker run -d sample
7423d238b754e6a2c5294aab7b185f80be2457ee36de22795685b19ff1cf03ec

Так как наш образ, по сути, ничего не делает, он сразу же завершает работу. Это дает нам остановленный контейнер, который представляет из себя результат слияния всех слоев образа (я использовал флаг -d просто, чтобы отобразить ID контейнера).

Если мы экспортируем этот контейнер, перенаправив вывод в команду docker import, мы сможем превратить его обратно в образ:

$ docker export 7423d238b | docker import - sample:flat
3995a1f00b91efb016250ca6acc31aaf5d621c6adaf84664a66b7a4594f695eb

$ docker history sample:flat
IMAGE               CREATED             CREATED BY          SIZE
3995a1f00b91        12 seconds ago                          85.18 MB

Обратите внимание, что история для нашего нового образа sample:flat показывает только один слой весом 85 Мб, — слой, содержащий гигабайтный файл пропал.

И, хотя это довольной ловкий трюк, следует заметить, что у него есть существенные минусы:

  • Сливая все слои вместе, вы теряете описанное ранее преимущество совместного использования слоев разными образами. Наш sample:flat образ сейчас содержит встроенную копию debian:wheezy.
  • Все метаданные, обычно, сохраняемые вместе с образом, теряются в процессе запуска/эскпорта/импорта. Открываемые порты, переменные окружения, команда по умолчанию — все, что может быть объявлено в оригинальном образе, теряется.

Поэтому, я бы точно не советовал вам бросаться «схлопывать» все ваши образы. Но, иногда, это может быть полезным: в случае, если вы пытаетесь оптимизировать чужой образ, или просто хотите узнать насколько можно потеснить свои.

Источник: Optimizing Docker Images
@silentvick
карма
15,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (18)

  • +2
    Что-то концептуально схожее происходит с репозиторием git.
    В docker можно модифицировать промежуточный слой, с применением всех изменений к дочерним?
    • 0
      Действительно, создатели явно поглядывали в сторону git :) Есть даже команды docker pull/push. В тексте есть отсылка к ним — в месте про скачивание/отправку образов. К сожалению, на русском эта отсылка пропадает.

      Что касается изменения промежуточных слоев, то встроенной возможности сделать это нет. Все нижние слои доступны только для чтения. Можно, наверное, поиграться с импортом и экспортом. Но, на мой взгляд, оно того просто не стоит — легче внести изменения «поверх» или пересобрать образ, изменив Dockerfile.
      • 0
        Все нижние слои доступны только для чтения.
        Тогда сходство с git ещё более сильное, в нём тоже история неизменяемая.
  • 0
    Слишком много текста до строчки «docker export 7423d238b | docker import — sample:flat». Надо было в начале статьи в спойлере спрятать. А потом уже рассказывать, чем это плохо, какие возможности есть…

    У меня главный вопрос: так можно или нет выбрать набор слоев, чтобы сжать именно их?
    • 0
      Если вы имеете ввиду «взять готовый слой, сжать, поставить обратно» — то нет.
      • 0
        Нет. Сжать несколько слоев в один.

        Допустим у меня есть контейнер с пятью слоями. Первый — база, а пятый — конкретный конфиг чего-то.
        В бою будет много контейнеров на базе первого и четвертого слоев.
        Внимание вопрос.
        Я могу сказать, возьми со второго по четвертый и сожми? Он мне сделает новый контейнер со слоями: первый, сжатый, пятый? Можно с другими идентификаторами.
        • 0
          Каждый слой хранит лишь изменения относительно предыдущего. Поэтому нельзя взять какой-то слой, не хватая те, которые находятся «под» ним (со второго по четвертый, например). Однако, любой слой можно использовать для создания образа — либо просто запустив его с помощью docker run и сделав коммит, либо «схлопнув» его, как описано в статье. Получившийся образ можно использовать так же, как и любой другой, в том числе и в качестве основы для других образов.
  • 0
    Я поискал в интернете немного. Нашел еще несколько умных мыслей, которых я не увидел в статье, хотя им там самое место.
    В статье есть замечательная мысль, которая вкратце звучит так:
    — надо сделать некий setup.sh, прописать в нем все скачивания, установки, обновления и т.д.
    — использовать только один вызов ADD и один RUN.
    С докером работал мало (но было интересно), поэтому если кто меня поправит — буду рад.

    PS. там же в статье я наткнулся на интересный инструмент. Просто оставлю это здесь. Вдруг кому пригодится.
    • 0
      Если единственная цель setup-файла в том, чтобы установить нужные пакеты, то в нем нет особого смысла. Этого же можно добиться одной инструкцией RUN с группированными командами. Эффект будет тот же. Скрипт удобен для каких-то более сложных действий, где нужно проверять какие-либо условия, вроде значений переменных окружения, например.
      • 0
        Хм. Тогда есть ли разница между:
        RUN apt-get update
        RUN apt-get upgrade
        RUN apt-get clean
        и
        RUN apt-get update && apt-get upgrade && apt-get clean

        Думаю есть и большая. Собственно это из статьи и вытекает.
        Так почему бы просто не ввести себе в привычку использовать setup.sh?
        • +1
          Разница, конечно, будет. Главный минус использования стороннего скрипта для установки зависимостей в том, что это лишает вас возможности использовать кэш docker. В случае дополнения Dockerfile новой инструкцией, docker постарается взять предыдущие слои из кэша, что может значительно ускорить процесс создания нового образа. В случае же с setup.sh весь скрипт выполнится заново.
  • 0
    Ну, пример с «создали файл в 1 Гб, удалили файл в 1 Гб» — это, конечно, интересный эксперимент, а что вы такого делаете в настоящих контейнерах, что они до 1 Гб разрастаются с дебиановского образа в 85 Мб?
    • 0
      Если это вопрос ко мне, то я — не автор статьи. Я лишь перевел.

      А насчет настоящих контейнеров: автор в начале говорит, что они по первой использовали ubuntu, которая сама по себе весит около 200 Мб. Что они делали, чтобы собрать гигабайтный образ я не знаю :) Возможно, они не разбивали контейнеры по задачам, т.е. делали «толстые» контейнеры по принципу «все-в-одном»: ruby/python, nginx/apache, mysql/postgre и, бог знает, что еще. Я сначала тоже пользовался ubuntu:trusty, не группировал команды и не чистил кэш apt — получались довольно немаленькие образы.
  • 0
    Если честно, я не могу понять, почему размер контейнера это проблема? Если мы говорим о каком-то реальном deployment pipeline, то у нас будет какой-то Dockerfile примерного такого вида
    FROM debian:wheezy
    RUN apt-get install ...
    
    ADD src/ src/
    ENTRYPOINT binaryfile
    

    так вот, все что до вызова ADD src/ будет меняться крайне редко, соответственно, при следующем деплое будет качаться только тот слой, который содержит исходики, а там выше хоть 2 гига, хоть 3, они меняются достаточно редко, поэтому никуда не качаются. Разве нет? Поясните, кто в теме?
    • 0
      Ну, это для некомпилируемых языков. Для компилируемых будет еще после ADD запуск компиляции, наверное, или что-то в этом роде.
    • 0
      Для меня больший размер означает меньшую мобильность. Это если говорить об образах, лежащих в репозиториях — как в публичных, так и приватных (у команды разработчиков может быть свой). В первую очередь, конечно, это проблема для публично доступных образов. Например, если зайти на страницу mysql, то можно увидеть жалобы пользователей на размер в 2-3 Гб (к счастью, сейчас его уменьшили до ~235 Мб). Т.е. тот, кто хотел тогда поднять у себя простой LAMP-стек был вынужден качать несколько гигабайт просто потому, что разработчики не позаботились о несложной оптимизации. Может также возникнуть ситуация, когда вы хотите передать другому человеку точную копию своего окружения: вы либо заставите его собирать все с нуля из Dockerfile-ов, либо передадите ему готовые образы, что при оптимальном размере может сэкономить кучу времени.
  • +1
    Спасибо за статью! Особо не углублялся в слои, н ов свое время потратил довольно много времени чтобы разобраться с контейнерами и образами чтобы разобраться почему образы так быстро распухают если их не контролировать. В итоге все же запилил для себя универсальный образ с нужным стеком ПО, а все конфиги вынес монтируемую директорию. Теперь в образ вообще ничего не коммичу.

    Кстати, а вы пробовали запускать иксы в Docker? Пробовал образ с подключением по VNC и установленным Firefox. Больше ничего полезного по этой теме не нашел.
    • +1
      Не пробовал запускать ничего подобного, но, натыкался на этот репозиторий: rogaha/docker-desktop. Возможно, он окажется полезен.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.