Как мне демонизировать произвольный скрипт в unix?

85

Мне нужен демон, который может превратить произвольную, общую script или команду в daemon.

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

  • У меня есть script, который должен работать вечно. Если он когда-либо умирает (или при перезагрузке), перезагрузите его. Не допускайте одновременного запуска двух копий (проверьте, не скопирована ли копия в этом случае).

  • У меня есть простая команда script или командной строки, которую я хотел бы продолжать выполнять повторно навсегда (с небольшой паузой между прогонами). Опять же, не позволяйте запускать сразу две копии script.

Конечно, тривиально писать цикл while (true) вокруг script в случае 2, а затем применить решение для случая 1, но более общее решение будет просто решать случай 2 непосредственно, поскольку это относится к script в случае 1 (вы можете просто захотеть более короткую или не сделать паузу, если script не собирается умирать (конечно, если script действительно никогда не умирает, то пауза на самом деле не имеет значения)).

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

В частности, мне нужна программа "daemonize", которую я могу запустить, например

% daemonize myscript arg1 arg2

или, например,

% daemonize 'echo `date` >> /tmp/times.txt'

который сохранит растущий список дат, добавленных к times.txt. (Обратите внимание, что если аргумент для daemonize - это script, который выполняется вечно, как в случае 1 выше, тогда daemonize все равно пойдет правильно, перезапустив его, когда это необходимо.) Тогда я мог бы добавить команду, как указано выше в моем .login и/или cron это ежечасно или в мельчайших подробностях (в зависимости от того, как я волновался, что я о нем умираю неожиданно).

NB: daemonize script необходимо будет запомнить строку команды, которую он демонизирует, так что, если одна и та же командная строка снова будет демоннизирована, она не запускает вторую копию.

Кроме того, решение должно идеально работать как на OS X, так и на Linux, но решения для одного или другого приветствуются.

РЕДАКТИРОВАТЬ: Это нормально, если вы должны вызвать его с помощью sudo daemonize myscript myargs.

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


PS: В случае, если это полезно, здесь аналогичный вопрос, относящийся к python.

И этот ответ на аналогичный вопрос имеет то, что кажется полезной идиомой для быстрой и грязной демонизации произвольного script:

Теги:
scripting
daemon
sysadmin

12 ответов

84
Лучший ответ

Вы можете демонировать любой исполняемый файл в Unix с помощью nohup и оператора and:

nohup yourScript.sh script args&

Команда nohup позволяет завершить сеанс оболочки, не убивая ваш script, а затем помещает ваш script в фоновый режим, чтобы вы получили приглашение оболочки продолжить сеанс. Единственная незначительная проблема с этим - стандартная, и стандартная ошибка отправляется на. /nohup.out, поэтому, если вы запустите несколько скриптов в этой усадьбе, их выход будет переплетаться. Лучшая команда:

nohup yourScript.sh script args >script.out 2>script.error&

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

nohup yourScript.sh script args >script.out 2>&1 &

2 > & 1 сообщает оболочке перенаправить стандартную ошибку (дескриптор файла 2) в тот же файл, что и стандартный выход (дескриптор файла 1).

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

#!/bin/bash

if [[ $# < 1 ]]; then
    echo "Name of pid file not given."
    exit
fi

# Get the pid file name.
PIDFILE=$1
shift

if [[ $# < 1 ]]; then
    echo "No command given."
    exit
fi

echo "Checking pid in file $PIDFILE."

#Check to see if process running.
PID=$(cat $PIDFILE 2>/dev/null)
if [[ $? = 0 ]]; then
    ps -p $PID >/dev/null 2>&1
    if [[ $? = 0 ]]; then
        echo "Command $1 already running."
        exit
    fi
fi

# Write our pid to file.
echo $$ >$PIDFILE

# Get command.
COMMAND=$1
shift

# Run command until we're killed.
while true; do
    $COMMAND "$@"
    sleep 10 # if command dies immediately, don't go into un-ctrl-c-able loop
done

Первый аргумент - это имя используемого файла pid. Второй аргумент - это команда. И все остальные аргументы являются аргументами команды.

Если вы назовете это script restart.sh, то вы так и будете называть его:

nohup restart.sh pidFileName yourScript.sh script args >script.out 2>&1 &
  • 0
    Потрясающие; благодарю вас. Интересно, должен ли он иметь опцию для задержки перезапуска. Или, может быть, лучше просто использовать его в сочетании с этим: stackoverflow.com/questions/555116/…
  • 4
    Это обрабатывает только SIGHUP, есть другие (обычно) фатальные сигналы, которые должны быть обработаны.
Показать ещё 4 комментария
29

Я прошу прощения за длинный ответ (см. комментарии о том, как мой ответ называет спецификацию). Я стараюсь быть всеобъемлющим, поэтому у вас как можно больше ноги.: -)

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

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

Начальная настройка

  • Следуйте инструкциям в Как установить daemontools. В некоторых дистрибутивах (например, Debian, Ubuntu) уже есть пакеты для него, поэтому просто используйте это.
  • Создайте каталог под названием /service. Установщик должен был уже это сделать, но просто проверить или установить вручную. Если вам не нравится это местоположение, вы можете изменить его в своем svscanboot script, хотя большинство пользователей daemontools используются для использования /service и будут запутаны, если вы его не используете.
  • Если вы используете Ubuntu или другой дистрибутив, который не использует стандартный init (т.е. не использует /etc/inittab), вам нужно будет использовать предварительно установленный inittab в качестве базы для организации svscanboot называть init. Это не сложно, но вам нужно знать, как настроить init, который использует ваша ОС. svscanboot - это script, который вызывает svscan, который выполняет основную работу по поиску сервисов; он вызывается из init, поэтому init организует его перезапуск, если он по какой-либо причине умирает.

Настройка на обслуживание

  • Каждой службе нужен служебный каталог, в котором хранится служебная информация об услуге. Вы также можете создать место для размещения этих сервисных каталогов, чтобы все они находились в одном месте; обычно я использую /var/lib/svscan, но любое новое местоположение будет в порядке.
  • Я обычно использую a script для настройки каталога службы, чтобы сэкономить много ручной повторяющейся работы. например.

    sudo mkservice -d /var/lib/svscan/some-service-name -l -u user -L loguser "command line here"
    

    где some-service-name - это имя, которое вы хотите предоставить своей службе, user - это пользователь, который будет запускать эту службу как, а loguser - это пользователь для запуска регистратора как. (Ведение журнала объясняется чуть-чуть.)

  • Ваш сервис должен запускать на переднем плане. Если ваши фоновые программы по умолчанию, но есть возможность отключить их, то сделайте это. Если ваша программа фоны без возможности ее отключить, прочитайте fghack, хотя это происходит в компромиссе: вы больше не можете управляйте программой с помощью svc.
  • Отредактируйте run script, чтобы убедиться, что он делает то, что вы хотите. Возможно, вам придется разместить вызов sleep вверху, если вы ожидаете, что ваш сервис выйдет часто.
  • Когда все настроено правильно, создайте символическую ссылку в /service, указывая на вашу служебную директорию. (Не ставьте каталоги службы непосредственно в /service, что затрудняет удаление службы из чата svscan.)

Вход

  • Способ ведения журнала в daemontools должен состоять в том, чтобы сообщения журнала записи службы записывались на стандартный вывод (или стандартную ошибку, если вы используете скрипты, сгенерированные с помощью mkservice); svscan выполняет посылку сообщений журнала в службу ведения журнала.
  • Служба ведения журнала принимает сообщения журнала со стандартного ввода. Служба ведения журнала script, сгенерированная с помощью mkservice, создаст автоматически вращающиеся, временные файлы журнала в каталоге log/main. Текущий файл журнала называется current.
  • Служба ведения журнала может быть запущена и остановлена ​​независимо от основной службы.
  • Проводя файлы журналов через tai64nlocal, перевести метки времени в удобочитаемый для человека формат. (TAI64N - это 64-разрядная атомная метка времени с наносекундным счетчиком.)

Управление услугами

  • Используйте svstat, чтобы получить статус службы. Обратите внимание, что служба ведения журнала является независимой и имеет свой собственный статус.
  • Вы управляете своим сервисом (запуск, остановка, перезапуск и т.д.) с помощью svc. Например, чтобы перезапустить службу, используйте svc -t /service/some-service-name; -t означает "отправить SIGTERM".
  • Другие доступные сигналы включают -h (SIGHUP), -a (SIGALRM), -1 (SIGUSR1), -2 (SIGUSR2) и -k (SIGKILL).
  • Чтобы отключить службу, используйте -d. Вы также можете запретить автоматический запуск службы при загрузке, создав файл с именем down в каталоге службы.
  • Чтобы запустить службу, используйте -u. Это необязательно, если вы не сбили его ранее (или настроили его не на автозапуск).
  • Чтобы попросить супервизора выйти, используйте -x; обычно используется с -d для прекращения службы. Это обычный способ разрешить удаление службы, но сначала необходимо отключить службу от /service, иначе svscan перезапустит супервизор. Кроме того, если вы создали свою службу с помощью службы ведения журнала (mkservice -l), не забудьте также выйти из диспетчера протоколирования (например, svc -dx /var/lib/svscan/some-service-name/log) перед удалением каталога службы.

Резюме

Плюсы:

  • daemontools обеспечивает пуленепробиваемый способ создания и управления службами. Я использую его для своих серверов, и я очень рекомендую его.
  • Его система регистрации очень надежная, как и средство автоматического перезапуска службы.
  • Поскольку он запускает службы с оболочкой script, которую вы пишете/настраиваете, вы можете настроить свой сервис, как вам нравится.
  • Мощные инструменты управления сервисом: вы можете отправлять большинство сигналов в службу и надежно обслуживать службы вверх и вниз.
  • Вашим услугам гарантируется чистая среда исполнения: они будут выполняться с той же средой, лимитами процесса и т.д., как это предусмотрено init.

Минусы:

  • Каждая служба занимает немного времени. К счастью, это нужно делать только один раз за сервис.
  • Службы должны быть настроены для работы на переднем плане. Кроме того, для достижения наилучших результатов они должны быть настроены на запись в стандартный вывод/стандартную ошибку, а не в syslog или другие файлы.
  • Крутая кривая обучения, если вы новичок в способе выполнения daemontools. Вам необходимо перезапустить службы, используя svc, и не может запускать сценарии запуска напрямую (так как тогда они не будут находиться под контролем супервизора).
  • Множество файлов домашнего хозяйства и множество домашних хозяйств. Каждой службе нужен свой служебный каталог, и каждая служба использует один процесс диспетчера для автоматического перезапуска службы, если она умирает. (Если у вас много услуг, вы увидите множество процессов supervise в вашей таблице процессов.)

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

  • 0
    Как мой ответ вписывается в спецификацию: 1. Вам нужно настроить сервисы, поэтому, если вы не настроите дубликаты (и пока ваш сервис не работает самостоятельно), дубликатов не будет. 2. supervise , supervisor, заботится о перезапуске любой службы, которая выходит. Он ждет одну секунду между рестартами; если вам не хватает времени, поместите его в верхнюю часть сценария запуска службы.
  • 0
    2а. supervise поддерживается svscan , поэтому, если супервизор умрет, он будет перезапущен. 2b. svscan поддерживается init , который автоматически перезапускает svscan при необходимости. 2с. Если ваш init умирает по какой-либо причине, вы все равно облажались. :-П
Показать ещё 2 комментария
12

Вы должны посмотреть daemonize. Он позволяет обнаруживать вторую копию (но использует механизм блокировки файлов). Также он работает с различными дистрибутивами UNIX и Linux.

Если вам нужно автоматически запустить приложение в качестве демона, вам необходимо создать соответствующий init- script.

Вы можете использовать следующий шаблон:

#!/bin/sh
#
# mydaemon     This shell script takes care of starting and stopping
#               the <mydaemon>
#

# Source function library
. /etc/rc.d/init.d/functions


# Do preliminary checks here, if any
#### START of preliminary checks #########


##### END of preliminary checks #######


# Handle manual control parameters like start, stop, status, restart, etc.

case "$1" in
  start)
    # Start daemons.

    echo -n $"Starting <mydaemon> daemon: "
    echo
    daemon <mydaemon>
    echo
    ;;

  stop)
    # Stop daemons.
    echo -n $"Shutting down <mydaemon>: "
    killproc <mydaemon>
    echo

    # Do clean-up works here like removing pid files from /var/run, etc.
    ;;
  status)
    status <mydaemon>

    ;;
  restart)
    $0 stop
    $0 start
    ;;

  *)
    echo $"Usage: $0 {start|stop|status|restart}"
    exit 1
esac

exit 0
  • 1
    Похоже, кандидат на правильный ответ. Особенно учитывая его «проверку одного экземпляра».
  • 0
    Это может быть наилучшим возможным ответом - я не уверен - но если вы думаете, что это так, можете ли вы также включить объяснение, почему спецификация, которую я дал в этом вопросе, ошибочна?
Показать ещё 3 комментария
11

Я думаю, вы можете попробовать start-stop-daemon(8). Просмотрите сценарии в /etc/init.d в любом дистрибутиве Linux для примеров. Он может найти начатые процессы с помощью командной строки или PID файла, поэтому он соответствует всем вашим требованиям, кроме как сторожевой таймер для вашего script. Но вы всегда можете запустить еще один сторожевой таймер демона script, который при необходимости перезапустит ваш script.

  • 0
    В Fedora пока нет start-stop-daemon, поэтому зависящие от него скрипты не переносимы. Смотрите: fedoraproject.org/wiki/Features/start-stop-daemon
  • 0
    Просто хедз-ап для пользователей OSX: там тоже нет start-stop-daemon (по состоянию на 10.9).
Показать ещё 2 комментария
7

В качестве альтернативы уже упомянутым daemonize и daemontools существует команда daemon пакета libslack.

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

5

Daemontools (http://cr.yp.to/daemontools.html) представляет собой набор довольно жестких утилит, используемых для этого, написанный dj bernstein. Я использовал это с некоторым успехом. Досадная часть об этом заключается в том, что ни один из сценариев не возвращает никаких видимых результатов при их запуске - просто невидимые коды возврата. Но как только он запускает его пуленепробиваемым.

  • 0
    Да, я собирался написать запись, которая тоже использует daemontools. Я напишу свой собственный пост, потому что я надеюсь быть более полным с моим ответом, и надеюсь получить награду таким образом. Посмотрим. :-)
5

Если вы используете OS X специально, я предлагаю вам взглянуть на то, как работает startd. Он будет автоматически проверяться, чтобы убедиться, что ваш script запущен и перезапустит его, если это необходимо. Он также включает в себя всевозможные функции планирования и т.д. Он должен удовлетворять требованиям 1 и 2.

Что касается обеспечения выполнения только одной копии вашего script, вам нужно использовать файл PID. Обычно я пишу файл в /var/run/.pid, содержащий PID текущего запущенного экземпляра. если файл существует, когда программа запускается, он проверяет, действительно ли PID в файле запущен (программа может быть повреждена или забыта удалить файл PID). Если это так, отмените. Если нет, запустите и перезапишите PID файл.

3

Сначала получите createDaemon() из http://code.activestate.com/recipes/278731/

Затем основной код:

import subprocess
import time

createDaemon()

while True:
    subprocess.call(" ".join(sys.argv[1:]),shell=True)
    time.sleep(10)
  • 0
    Ооо спасибо Хотите сделать его немного более общим, чтобы вы могли использовать "daemonize foo arg1 arg2", а также "daemonize 'foo arg1 arg2'"?
  • 0
    Хорошо, теперь он присоединится к аргументам - однако вам придется изменить его, если вы хотите, чтобы внутри аргументов были пробелы.
Показать ещё 4 комментария
1

Вы можете попробовать immortal Это кросс-платформенный (OS агностик).

Для быстрой проверки macOS:

brew install immortal

Если вы используете FreeBSD из портов или с помощью pkg:

pkg install immortal

Для Linux, загрузив предварительно скомпилированные исполняемые файлы или из источника: https://immortal.run/source/

Вы можете использовать его так:

immortal -l /var/log/date.log date

Или через конфигурационный файл YAML, который дает вам больше параметров, например:

cmd: date
log:
    file: /var/log/date.log
    age: 86400 # seconds
    num: 7     # int
    size: 1    # MegaBytes
    timestamp: true # will add timesamp to log

Если вы хотите сохранить также стандартный вывод ошибки в отдельном файле, вы можете использовать что-то вроде:

cmd: date
log:
    file: /var/log/date.log
    age: 86400 # seconds
    num: 7     # int
    size: 1    # MegaBytes
stderr:
    file: /var/log/date-error.log
    age: 86400 # seconds
    num: 7     # int
    size: 1    # MegaBytes
    timestamp: true # will add timesamp to log
1

Вы также можете попробовать Monit. Монит - это служба, которая контролирует и сообщает о других услугах. Хотя он в основном используется как способ уведомления (по электронной почте и смс) о проблемах времени выполнения, он также может делать то, что большинство других предложений здесь отстаивали. Он может автоматически запускать и останавливать программы, отправлять электронные письма, запускать другие скрипты и вести журнал вывода, который вы можете получить. Кроме того, я нашел его простым в установке и обслуживании, поскольку там есть документация.

1

Это рабочая версия с примером, который вы можете скопировать в пустой каталог и попробовать (после установки зависимостей CPAN, которые Getopt::Long, File::Spec, File::Pid и IPC::System::Simple - все довольно стандартно и настоятельно рекомендуется для любого хакера: вы можете установить их все сразу с помощью cpan <modulename> <modulename> ...).


keepAlive.pl:

#!/usr/bin/perl

# Usage:
# 1. put this in your crontab, to run every minute:
#     keepAlive.pl --pidfile=<pidfile> --command=<executable> <arguments>
# 2. put this code somewhere near the beginning of your script,
#    where $pidfile is the same value as used in the cron job above:
#     use File::Pid;
#     File::Pid->new({file => $pidfile})->write;

# if you want to stop your program from restarting, you must first disable the
# cron job, then manually stop your script. There is no need to clean up the
# pidfile; it will be cleaned up automatically when you next call
# keepAlive.pl.

use strict;
use warnings;

use Getopt::Long;
use File::Spec;
use File::Pid;
use IPC::System::Simple qw(system);

my ($pid_file, $command);
GetOptions("pidfile=s"   => \$pid_file,
           "command=s"   => \$command)
    or print "Usage: $0 --pidfile=<pidfile> --command=<executable> <arguments>\n", exit;

my @arguments = @ARGV;

# check if process is still running
my $pid_obj = File::Pid->new({file => $pid_file});

if ($pid_obj->running())
{
    # process is still running; nothing to do!
    exit 0;
}

# no? restart it
print "Pid " . $pid_obj->pid . " no longer running; restarting $command @arguments\n";

system($command, @arguments);

example.pl:

#!/usr/bin/perl

use strict;
use warnings;

use File::Pid;
File::Pid->new({file => "pidfile"})->write;

print "$0 got arguments: @ARGV\n";

Теперь вы можете вызвать пример выше: ./keepAlive.pl --pidfile=pidfile --command=./example.pl 1 2 3 и файл pidfile будет создан, и вы увидите вывод:

Pid <random number here> no longer running; restarting ./example.pl 1 2 3
./example.pl got arguments: 1 2 3
  • 0
    Я считаю, что это не совсем спецификация, если я правильно понимаю. В вашем решении (спасибо, кстати!) Необходимо изменить программу, которую вы хотите демонизировать, чтобы записать свой PID в файл PID. Я надеюсь на утилиту, которая может демонизировать произвольный скрипт.
  • 0
    @dreeves: да, но есть два пути решения этой проблемы: 1. сценарий, вызываемый keepAlive.pl (например, example.pl), может быть просто оболочкой для выполнения реальной программы или 2. keepAlive.pl может анализировать таблицу активные системные процессы (с помощью CPAN Proc :: ProcessTable), чтобы попытаться найти соответствующий процесс и его pid).
0

Я сделал ряд улучшений в другом ответе.

  • stdout из этого script состоит исключительно из stdout, исходящего из его дочернего UNLESS, который выходит из-за обнаружения того, что команда уже выполняется
  • очищает после своего pidfile при завершении
  • дополнительный настраиваемый период ожидания (принимает любой положительный числовой аргумент, отправляет на sleep)
  • запрос на использование -h
  • выполнение произвольной команды, а не выполнение одной команды. Последние arg OR, оставшиеся args (если более одного последнего arg) отправляются на eval, поэтому вы можете построить любую оболочку script в качестве строки для отправки на этот script в качестве последнего аргумента arg (или завершающих аргументов ) для его демоннизации
  • сравнение счетчиков аргументов с -lt вместо <

Вот script:

#!/bin/sh

# this script builds a mini-daemon, which isn't a real daemon because it
# should die when the owning terminal dies, but what makes it useful is
# that it will restart the command given to it when it completes, with a
# configurable timeout period elapsing before doing so.

if [ "$1" = '-h' ]; then
    echo "timeout defaults to 1 sec.\nUsage: $(basename "$0") sentinel-pidfile [timeout] command [command arg [more command args...]]"
    exit
fi

if [ $# -lt 2 ]; then
    echo "No command given."
    exit
fi

PIDFILE=$1
shift

TIMEOUT=1
if [[ $1 =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
        TIMEOUT=$1
        [ $# -lt 2 ] && echo "No command given (timeout was given)." && exit
        shift
fi

echo "Checking pid in file ${PIDFILE}." >&2

#Check to see if process running.
if [ -f "$PIDFILE" ]; then
    PID=$(< $PIDFILE)
    if [ $? = 0 ]; then
        ps -p $PID >/dev/null 2>&1
        if [ $? = 0 ]; then
            echo "This script is (probably) already running as PID ${PID}."
            exit
        fi
    fi
fi

# Write our pid to file.
echo $$ >$PIDFILE

cleanup() {
        rm $PIDFILE
}
trap cleanup EXIT

# Run command until we're killed.
while true; do
    eval "$@"
    echo "I am $$ and my child has exited; restart in ${TIMEOUT}s" >&2
    sleep $TIMEOUT
done

Использование:

$ term-daemonize.sh pidfilefortesting 0.5 'echo abcd | sed s/b/zzz/'
Checking pid in file pidfilefortesting.
azzzcd
I am 79281 and my child has exited; restart in 0.5s
azzzcd
I am 79281 and my child has exited; restart in 0.5s
azzzcd
I am 79281 and my child has exited; restart in 0.5s
^C

$ term-daemonize.sh pidfilefortesting 0.5 'echo abcd | sed s/b/zzz/' 2>/dev/null
azzzcd
azzzcd
azzzcd
^C

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

Кроме того, для того, чтобы он функционировал как надлежащий демон, вы должны использовать (по минимуму) nohup, как упоминает другой ответ. Я не прилагал никаких усилий для обеспечения устойчивости к сигналам, которые может получить процесс.

Еще один момент, который следует принять к сведению, заключается в том, что убить этого script (если он был вызван из еще одного script, который убит или с сигналом), возможно, не удастся убить ребенка, особенно если ребенок еще один script. Я не знаю, почему это так, но похоже, что это связано с тем, как работает eval, что для меня таинственно. Поэтому может быть разумным заменить эту строку тем, что принимает только одну команду, как в другом ответе.

Ещё вопросы

Сообщество Overcoder
Наверх
Меню