Тренируем нейронную сеть написанную на TensorFlow в облаке, с помощью Google Cloud ML и Cloud Shell

от автора

В предыдущей статье мы обсудили как натренировать чат-бот на базе рекуррентной нейронной сети на AWS GPU инстансе. Сегодня мы увидим, как легко можно обучить такую же сеть с помощью Google Cloud ML и Google Cloud Shell. Благодаря Google Cloud Shell не нужно будет делать практически ничего на локальном компьютере! Кстати, сеть из прошлой статьи мы взяли лишь для примера, можно спокойно брать любую другую сеть, которая использует TensorFlow.

image

Вместо предисловия

Отдельное спасибо моим патронам, которые сделали эту статью возможной:
Aleksandr Shepeliev, Sergei Ten, Alexey Polietaiev, Никита Пензин, Карнаухов Андрей, Matveev Evgeny, Anton Potemkin.

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

Предварительные требования

Существует только одно требование, которое читатель должен удовлетворить, чтобы пройти все шаги, описанные в статье: иметь учетную запись Google Cloud с включенным биллингом, так как мы будем использовать платную функциональность.

Давайте начнем наш путь с ответов на два главных вопроса:

  • Что такое Google Cloud ML?
  • Что такое Google Cloud Shell?

Что такое Google Cloud ML?

Официальное определение говорит следующее:

Google Cloud Machine Learning brings the power and flexibility of TensorFlow to the cloud. You can use its components to select and extract features from your data, train your machine learning models, and get predictions using the managed resources of Google Cloud Platform.

Не знаю как вам, но мне это определение говорит мало о чем. Попробую объяснить, что Google Cloud ML может вам дать:

  • развернуть ваш код в облаке на машине, на которой есть все, что нужно для обучения TensorFlow модели;
  • обеспечить доступ к Google Cloud Storage бакетам для вашего кода на машине в облаке;
  • запустить ваш код, который отвечает за обучение, на выполнение;
  • разместить на хранение модель в облаке;
  • использовать обученную модель для прогнозирования на будущих данных.

Основное внимание этой статьи будет на первых 3-х пунктах. Позже, в последующих статьях, мы рассмотрим, как развернуть обученную модель в Google Cloud ML и как прогнозировать данные с помощью облачной модели.

Что такое Google Cloud Shell?

И снова официальное определение:

Google Cloud Shell is a shell environment for managing resources hosted on Google Cloud Platform.

И вновь, я добавлю несколько деталей, Google Cloud Shell это:

  • предоставленный вам облачный инстанс (тип?),
  • с Debian ОС на борту,
  • к Shell которого вы можете получить доступ через Web,
  • где есть все необходимое для работы с Google Cloud.

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

image

Но ничего не дается бесплатно, в случае с Cloud Shell есть некоторые обидные ограничения — можно получить доступ к нему только через Web консоль, а не через ssh (лично я не люблю использовать любые другие терминалы, кроме iTerm). Я задал вопрос на StackOverflow, можно ли использовать Cloud Shell через ssh и, походу, нельзя. Зато хотя бы есть способ облегчить себе жизнь установив специальный Chrome плагин, которой, как минимум, позволяет использовать нормальные биндинги клавиш, чтобы терминал работал как терминал, а не как окно браузера (коем эта приблуда и является =) ).

Больше информации о возможностях Cloud Shell можно найти тут.

Шаги, которые нам предстоит пройти:

  • Подготовка Cloud Shell для обучения
  • Подготовка Cloud Storage
  • Подготовка данных для обучения
  • Подготовка обучающего скрипта
  • Тестирование процесса обучения локально
  • Обучение
  • Разговор з ботом

Подготовка Cloud Shell среды для обучения

Самое время открыть Cloud Shell. Если не делали этого ранее, это очень просто, нужно открыть консоль console.cloud.google.com и нажать на иконку Shell в правом верхнем углу:

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

Все последующие примеры будут выполняться в Cloud Shell.

Кроме того, если это ваш первый раз, когда вы собираетесь использовать Cloud ML с Cloud Shell — вам необходимо подготовить все необходимые зависимости. Для этого нужно выполнить всего одну строчку кода прямо в Shell:

curl https://raw.githubusercontent.com/GoogleCloudPlatform/cloudml-samples/master/tools/setup_cloud_shell.sh | bash

Он установит все нужные пакеты. Далее вам придется обновить PATH переменную:

export PATH=${HOME}/.local/bin:${PATH}

Чтобы проверить, успешно ли все установлено, нужно выполнить одну простую команду:

➜ curl https://raw.githubusercontent.com/GoogleCloudPlatform/cloudml-samples/master/tools/check_environment.py | python   ... You are using pip version 8.1.1, however version 9.0.1 is available. You should consider upgrading via the 'pip install --upgrade pip' command. You are using pip version 8.1.1, however version 9.0.1 is available. You should consider upgrading via the 'pip install --upgrade pip' command. Your active configuration is: [cloudshell-12345] Success! Your environment is configured 

Теперь пришло время решить, какой Google Cloud проект вы будете использовать для обучения сети. У меня есть специальный проект для всех моих экспериментов с ML. Во всяком случае, это решать вам, но я покажу вам мои команды, которыми я пользуюсь, чтобы переключаться между проектами:

➜  gprojects PROJECT_ID             NAME            PROJECT_NUMBER ml-lab-123456          ml-lab          123456789012 ... ➜  gproject ml-lab-123456 Updated property [core/project].

Если вы хотите использовать такую же магию, то вам нужно добавить в свой .bashrc/.zshrc/other_rc файл следующее:

function gproject() {   gcloud config set project $1 } function gprojects() {   gcloud projects list }

Ну все, если вы уже тут, то это значит, что мы подготовили Cloud Shell и перешли к нужному проекту. Что дальше? Если это первый раз, когда вы используете Cloud ML с текущим проектом, вам необходимо инициализировать модуль ML. Это можно сделать одной строкой:

➜ gcloud beta ml init-project Cloud ML needs to add its service accounts to your project (ml-lab-123456) as Editors. This will enable Cloud Machine Learning to access resources in your project when running your training and prediction jobs. Do you want to continue (Y/n)?   Added serviceAccount:cloud-ml-service@ml-lab-123456-1234a.iam.gserviceaccount.com as an Editor to project 'ml-lab-123456'.

Вот теперь можно с уверенностью сказать, что Cloud Shell подготовлен и мы с чистой совестью можем перейти к следующему шагу.

Подготовка Cloud Storage

Прежде всего, нужно объяснить, зачем нам вообще нужно облако хранения? Так как мы будем обучать модель в облаке, процесс обучение не будет иметь никакого доступа к локальной файловой системе вашей текущей машины. Это означает, что все необходимые исходные данные должны храниться где-то в облаке. Так же, как и обученную модель тоже нужно будет где-то хранить. Это где-то не может быть машиной, на которой идет обучения, ибо к ней у вас нету доступа; и не может быть ваша машина, ибо к ней нету доступа у процесса обучения. Такой вот порочный круг, который может быть разорван путем введения нового звена — облачное хранилище для данных.

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

➜ PROJECT_NAME=chatbot_generic ➜ TRAIN_BUCKET=gs://${PROJECT_NAME} ➜ gsutil mb ${TRAIN_BUCKET} Creating gs://chatbot_generic/...

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

Warning: You must specify a region (like us-central1) for your bucket, not a multi-region location (like us).

Вольный перевод:

Внимание: Вы должны указать регион (us-central1) для вашего бакета, а не мульт-региональную локацию как страна (например: us).

Тем не менее, если вы воспользуетесь данным советом и создадите регионалный бакет, скрипт не будет в состоянии в него что-либо записать 0_о (молчать гуссары, бага уже зарепорчена).

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

Теперь мы готовы для подготовки входных данных для предстоящего обучения.

Подготовка данных для обучения

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

На этот раз (по сравнению с предыдущей статьей) мы будем использовать немного измененную версию скрипта, который готовит входные данные. Я хочу призвать вас прочитать, как скрипт работает в README файле. Но сейчас, вы можете подготовить входные данные следующим способом (вы можете заменить «td src» c “mkdir src; cd src"):

➜ td src ➜ ~/src$ git clone https://github.com/b0noI/dialog_converter.git Cloning into 'dialog_converter'... remote: Counting objects: 63, done. remote: Compressing objects: 100% (4/4), done. remote: Total 63 (delta 0), reused 0 (delta 0), pack-reused 59 Unpacking objects: 100% (63/63), done. Checking connectivity... done. ➜ ~/src$ cd dialog_converter/ ➜ ~/src/dialog_converter$ git checkout converter_that_produces_test_data_as_well_as_train_data Branch converter_that_produces_test_data_as_well_as_train_data set up to track remote branch converter_that_produces_test_data_as_well_as_train_data from origin. Switched to a new branch 'converter_that_produces_test_data_as_well_as_train_data' ➜ ~/src/dialog_converter$ python converter.py  ➜ ~/src/dialog_converter$ ls converter.py  LICENSE  movie_lines.txt  README.md  test.a  test.b  train.a  train.b

Глядя на код выше, вы возможно спросите, что такое «td»?.. Это просто короткая форма «to dir», и это одна из команд, которую я использую чаще всего. Для того, чтобы и вы могли использовать эту магию, вам необходимо обновить rc файл добавив следующее:

function td() {   mkdir $1   cd $1 }

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

Отлично, у нас наконец то есть данные, давайте загружать их на бакет:

➜ ~/src/dialog_converter$ gsutil cp test.* ${TRAIN_BUCKET}/input Copying file://test.a [Content-Type=application/octet-stream]... Copying file://test.b [Content-Type=chemical/x-molconn-Z]...                     \ [2 files][  2.8 MiB/  2.8 MiB]      0.0 B/s                                    Operation completed over 2 objects/2.8 MiB.                                       ➜ ~/src/dialog_converter$ gsutil cp train.* ${TRAIN_BUCKET}/input Copying file://train.a [Content-Type=application/octet-stream]... Copying file://train.b [Content-Type=chemical/x-molconn-Z]...                   - [2 files][ 11.0 MiB/ 11.0 MiB]                                                 Operation completed over 2 objects/11.0 MiB.                                      ➜ ~/src/dialog_converter$ gsutil ls ${TRAIN_BUCKET} gs://chatbot_generic/input/ ➜ ~/src/dialog_converter$ gsutil ls ${TRAIN_BUCKET}/input gs://chatbot_generic/input/test.a gs://chatbot_generic/input/test.b gs://chatbot_generic/input/train.a gs://chatbot_generic/input/train.b

Подготовка обучающего скрипта

Теперь мы можем подготовить учебный скрипт. Мы будем использовать translate.py. Однако, текущая его реализация не позволяет использовать его с Cloud ML, так что необходимо сделать небольшой рефакторинг. Как обычно, я создал feature request и подготовил бранч со всеми необходимыми изменениями. И так, давайте начнем с того, что склонируем его:

➜ ~/src/dialog_converter$ cd .. ➜ ~/src$ git clone https://github.com/b0noI/models.git Cloning into 'models'... remote: Counting objects: 1813, done. remote: Compressing objects: 100% (39/39), done. remote: Total 1813 (delta 24), reused 0 (delta 0), pack-reused 1774 Receiving objects: 100% (1813/1813), 49.34 MiB | 39.19 MiB/s, done. Resolving deltas: 100% (742/742), done. Checking connectivity... done. ➜ ~/src$ cd models/ ➜ ~/src/models$ git checkout translate_tutorial_supports_google_cloud_ml Branch translate_tutorial_supports_google_cloud_ml set up to track remote branch translate_tutorial_supports_google_cloud_ml from origin. Switched to a new branch 'translate_tutorial_supports_google_cloud_ml' ➜ ~/src/models$ cd tutorials/rnn/translate/

Обратите внимание, что мы используем не master ветку!

Тестирование процесса обучения — локально

Поскольку дистанционное обучение стоит денег, для тестирования можно симитировать процесс обучения — локально. Проблема здесь правда в том, что локальное обучение нашей сети на машинке на которой крутится Cloud Shell, безусловно, втопчет ее в грязь и раздавит. И вам придется перегружать инстанс, не увидев результата. Но не волнуйтесь, даже в этом случае ничего не будет потеряно. К счастью, наш скрипт имеет режим самопроверки, который мы можем использовать. Вот как можно этим воспользоваться:

➜ ~/src/models/tutorials/rnn/translate$ cd .. ➜ ~/src/models/tutorials/rnn$ gcloud beta ml local train \ >   --package-path=translate \ >   --module-name=translate.translate \ >   -- \ >   --self_test Self-test for neural translation model.

Обратите внимание на папку, из которой мы выполняем команду!

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

  • package-path — путь к python пакету, который должен быть развернут на удаленной машине, чтобы выполнить обучение;
  • "—" — все, что следует после, будет отправлено в качестве входных аргументов в ваш модуль;
  • self_test — говорит модулю запускать самопроверку без фактического обучения.

Обучение

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

➜ ~/src/models/tutorials/rnn$ INPUT_TRAIN_DATA_A=${TRAIN_BUCKET}/input/train.a ➜ ~/src/models/tutorials/rnn$ INPUT_TRAIN_DATA_B=${TRAIN_BUCKET}/input/train.b ➜ ~/src/models/tutorials/rnn$ INPUT_TEST_DATA_A=${TRAIN_BUCKET}/input/test.a ➜ ~/src/models/tutorials/rnn$ INPUT_TEST_DATA_B=${TRAIN_BUCKET}/input/test.b ➜ ~/src/models/tutorials/rnn$ JOB_NAME=${PROJECT_NAME}_$(date +%Y%m%d_%H%M%S) ➜ ~/src/models/tutorials/rnn$ echo ${JOB_NAME} chatbot_generic_20161224_203332 ➜ ~/src/models/tutorials/rnn$ TRAIN_PATH=${TRAIN_BUCKET}/${JOB_NAME} ➜ ~/src/models/tutorials/rnn$ echo ${TRAIN_PATH} gs://chatbot_generic/chatbot_generic_20161224_203332

Важно тут обратить внимание, что имя нашей удаленной работы (JOB_NAME) должно быть уникальным каждый раз, когда мы начинаем обучение. Теперь давайте изменим текущую папку для перевода (не спрашивайте =)):

➜ ~/src/models/tutorials/rnn$ cd translate/

Теперь мы готовы начинать обучение. Давайте сначала напишем команду (но не будем ее выполнять) и обсудим ее основные ключи:

gcloud beta ml jobs submit training ${JOB_NAME} \   --package-path=. \   --module-name=translate.translate \   --staging-bucket="${TRAIN_BUCKET}" \   --region=us-central1 \   -- \   --from_train_data=${INPUT_TRAIN_DATA_A} \   --to_train_data=${INPUT_TRAIN_DATA_B} \   --from_dev_data=${INPUT_TEST_DATA_A} \   --to_dev_data=${INPUT_TEST_DATA_B} \   --train_dir="${TRAIN_PATH}" \   --data_dir="${TRAIN_PATH}" \   --steps_per_checkpoint=5 \   --from_vocab_size=45000 \   --to_vocab_size=45000

Сначала обсудим некоторые новые флаги команды обучения:

  • staging-bucket — бакет, который должен использоваться во время развертывания; имеет смысл использовать тот же бакет, что и для обучения;
  • region — регион, где вы хотите начать процесс обучения.

Также давайте затронем новые флаги, которые будут переданы скрипту:

  • from_train_data/to_train_data — это бывшее en_train_data/fr_train_data, детали можно глянуть в прошлой статье;
  • from_dev_data / to_dev_data — то же самое, что и from_train_data/to_train_data, но для тестовых (или «dev», как они называются в скрипте) данных, которые будут использоваться для оценки потерь после обучения;
  • train_dir — папка, в которую будут сохраняться результаты обучения;
  • steps_per_checkpoint — сколько шагов должно быть выполнено перед сохранением временных результатов. 5 — слишком маленькое значение, я поставил его только для проверки того, что учебный процесс идет без каких-либо проблем. Позже я перезапущу процесс с большим значением (200, например);
  • from_vocab_size / to_vocab_size — чтобы понять, что это, вам нужно прочитать предыдущую статью.Там вы выясните, что значение по умолчанию (40k) меньше, чем колличество уникальных слов в диалогах, посему в этот раз мы увеличили размер словаря.

Похоже, все готово для начала обучения, так что начинаем (вам понадобится немного терпения ибо процесс занимает некоторое время) …

➜ ~/src/models/tutorials/rnn/translate$ gcloud beta ml jobs submit training ${JOB_NAME} \ >   --package-path=. \ >   --module-name=translate.translate \ >   --staging-bucket="${TRAIN_BUCKET}" \ >   --region=us-central1 \ >   -- \ >   --from_train_data=${INPUT_TRAIN_DATA_A} \ >   --to_train_data=${INPUT_TRAIN_DATA_B} \ >   --from_dev_data=${INPUT_TEST_DATA_A} \ >   --to_dev_data=${INPUT_TEST_DATA_B} \ >   --train_dir="${TRAIN_PATH}" \ >   --data_dir="${TRAIN_PATH}" \ >   --steps_per_checkpoint=5 \ >   --from_vocab_size=45000 \ >   --to_vocab_size=45000 INFO    2016-12-24 20:49:24 -0800       unknown_task            Validating job requirements... INFO    2016-12-24 20:49:25 -0800       unknown_task            Job creation request has been successfully validated. INFO    2016-12-24 20:49:26 -0800       unknown_task            Job chatbot_generic_20161224_203332 is queued. INFO    2016-12-24 20:49:31 -0800       service         Waiting for job to be provisioned. INFO    2016-12-24 20:49:36 -0800       service         Waiting for job to be provisioned. ... INFO    2016-12-24 20:53:15 -0800       service         Waiting for job to be provisioned. INFO    2016-12-24 20:53:20 -0800       service         Waiting for job to be provisioned. INFO    2016-12-24 20:53:20 -0800       service         Waiting for TensorFlow to start. ... INFO    2016-12-24 20:54:56 -0800       master-replica-0                Successfully installed translate-0.0.0 INFO    2016-12-24 20:54:56 -0800       master-replica-0                Running command: python -m translate.translate --from_train_data=gs://chatbot_generic/input/train.a --to_train_data=gs://chatbot_generic/input/train.b --from_dev_data=gs://chatbot_generic/input/test.a --to_dev_data=gs://chatbot_generic/input/test.b --train_dir=gs://chatbot_generic/chatbot_generic_20161224_203332 --steps_per_checkpoint=5 --from_vocab_size=45000 --to_vocab_size=45000 INFO    2016-12-24 20:56:21 -0800       master-replica-0                Creating vocabulary /tmp/vocab45000 from data gs://chatbot_generic/input/train.b INFO    2016-12-24 20:56:21 -0800       master-replica-0                  processing line 100000 INFO    2016-12-24 20:56:21 -0800       master-replica-0                Tokenizing data in gs://chatbot_generic/input/train.b INFO    2016-12-24 20:56:21 -0800       master-replica-0                  tokenizing line 100000 INFO    2016-12-24 20:56:21 -0800       master-replica-0                Tokenizing data in gs://chatbot_generic/input/train.a INFO    2016-12-24 20:56:21 -0800       master-replica-0                  tokenizing line 100000 INFO    2016-12-24 20:56:21 -0800       master-replica-0                Tokenizing data in gs://chatbot_generic/input/test.b INFO    2016-12-24 20:56:21 -0800       master-replica-0                Tokenizing data in gs://chatbot_generic/input/test.a INFO    2016-12-24 20:56:21 -0800       master-replica-0                Creating 3 layers of 1024 units. INFO    2016-12-24 20:56:21 -0800       master-replica-0                Created model with fresh parameters. INFO    2016-12-24 20:56:21 -0800       master-replica-0                Reading development and training data (limit: 0). INFO    2016-12-24 20:56:21 -0800       master-replica-0                  reading data line 100000

Можно следить за состоянием вашего обучения. Для этого просто откройте другую вкладку в вашем Cloud Shell (или tmux окно), затем создайте нужные переменные и запустите команду:

➜ JOB_NAME=chatbot_generic_20161224_213143 ➜ gcloud beta ml jobs describe ${JOB_NAME} ...

Теперь, если все идет удачно, мы можем остановить работу и перезапустить ее с бОльшим количеством шагов, например 200, это число по умолчанию. Новая команда будет выглядеть следующим образом:

➜ ~/src/models/tutorials/rnn/translate$ gcloud beta ml jobs submit training ${JOB_NAME} \ >   --package-path=. \ >   --module-name=translate.translate \ >   --staging-bucket="${TRAIN_BUCKET}" \ >   --region=us-central1 \ >   -- \ >   --from_train_data=${INPUT_TRAIN_DATA_A} \ >   --to_train_data=${INPUT_TRAIN_DATA_B} \ >   --from_dev_data=${INPUT_TEST_DATA_A} \ >   --to_dev_data=${INPUT_TEST_DATA_B} \ >   --train_dir="${TRAIN_PATH}" \ >   --data_dir="${TRAIN_PATH}" \ >   --from_vocab_size=45000 \ >   --to_vocab_size=45000

Разговор с ботом

Вероятно, самое большое преимущество использования Cloud Storage для сохранения промежуточных состояний модели в процессе обучения — это возможность начать общение без прерывания процесса обучения.

Сейчас, для примера, я покажу как можно начать общение чат с ботом после всего лишь 1600 учебных итераций. Это, кстати, единственный шаг, который нужно выполнить на локальной машине. Думаю причины очевидны =)

Вот как это можно сделать:

mkdir ~/tmp-data gsutil cp gs://chatbot_generic/chatbot_generic_20161224_232158/translate.ckpt-1600.meta ~/tmp-data ... gsutil cp gs://chatbot_generic/chatbot_generic_20161224_232158/translate.ckpt-1600.index ~/tmp-data ... gsutil cp gs://chatbot_generic/chatbot_generic_20161224_232158/translate.ckpt-1600.data-00000-of-00001 ~/tmp-data ... gsutil cp gs://chatbot_generic/chatbot_generic_20161224_232158/checkpoint ~/tmp-data TRAIN_PATH=... python -m translate.translate \   --data_dir="${TRAIN_PATH}" \   --train_dir="${TRAIN_PATH}" \   --from_vocab_size=45000 \   --to_vocab_size=45000 \   --decode Reading model parameters from /Users/b0noi/tmp-data/translate.ckpt-1600 > Hi there you ? . . . . . . . . > What do you want? i . . . . . . . . . > yes, you i ? . . . . . . . . > hi you ? . . . . . . . . > who are you? i . . . . . . . . . > yes you! what ? . . . . . . . . > who are you? i . . . . . . . . . > you ' . . . . . . . .

Переменная TRAIN_PATH должна вести в папку “tmp_data”, а текущий каталог должен быть “models/tutorials/rnn”.

Как можно увидеть, чат-бот еще далек от совершенства после всего 1600 шагов. Если вы хотите увидеть как он умеет общаться после 50 тысяч итераций, то я снова отошлю вас к прошлой статье, так как цель этой не тренировка идеального чат-бота, а изучение того, как можно тренировать любую сеть в облаке с помощью Google Cloud ML.

Post factum

Я надеюсь, что моя статья помогла вам изучить тонкости работы с Cloud ML и Cloud Shell, и вы сможете использовать их для тренировки своих сетей. Я также надеюсь, что вам написанное понравилось и, если да, вы можете поддержать меня на моей страничке patreon и/или путем добавления лайков к статье и помощи в распространении оной =)

Если вы заметили какие-либо проблемы на любом из шагов, пожалуйста дайте мне знать, чтобы я мог оперативно это исправить.
ссылка на оригинал статьи https://habrahabr.ru/post/318922/


Комментарии

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

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