Доброго времени суток, дорогие читатели!
Хочу рассказать вам об интересной задаче, которая стала передо мной в рамках проекта и, естественно, о ее решении.
Исходные данные:
Стандартный набор LAMP (далее СС),
Yii framework (версия здесь не важна),
удаленный сервер (далее УС), на котором установлен демон Sphinx, searchd.
На УС создан пользователь с правами рута (но не сам рут).
На СС установлен модуль ssh2_mod для PHP.
Сразу оговорюсь, в этой статье я не буду расписывать особенности Sphinx, кому интересно, могут почитать официальный мануал sphinxsearch.com/docs/current.html.
Ограничусь только общей информацией.
Итак, Sphinx — поисковый демон, в моем случае работает с MySQL. Основная особенность — он индексирует базу по определенным запросам (описанным в конфиге сфинкса), и результат выборки сохраняет в свои файлы. Чтобы информация была актуальной (в MySQL возможно и добавление и редактирование записей), нужно запускать индексацию сфинкса. Тогда, он сделает повторную выборку и сохранит ее себе.
Задача:
Запускать индексацию сфинкса на УС.
Причина именно удаленного запуска состоит в том, что необходимо запускать команды по крону с конкретными параметрами, определяемыми в коде. Кроны запускаются с СС.
Т.е. на сервере запускается крон, метод которого выполняет индексацию на УС.
Единственное решение, которое нашел — использование ssh2_mod для apache2 (кому интересно, мануал по установке на CentOS можно глянуть здесь www.stableit.ru/2010/12/ssh2-php-centos-55-pecl.html).
Посмотрел мануал по ssh2 (http://www.php.net/manual/en/book.ssh2.php), нашел замечательную функцию ssh2_exec, которая на вход принимает текущую сессию и команду, но, как оказалось, она имеет ряд ограничений.
Например, при попытке выполнения команды indexer --all --rotate для дельта индекса я получал ошибку:
WARNING: failed to open pid_file '/var/run/sphinx/searchd.pid'.
WARNING: indices NOT rotated.
Эта ошибка означает, что моему пользователю не хватает прав для выполнения rotate (а у меня юзер с правами рута, sudo -s), хотя из консоли напрямую я спокойно выполнял эту команду безо всяких ошибок.
Далее я решил поискать еще, и обнаружил, что можно эмулировать ввод команд через терминал (функция ssh2_shell). С помощью стандарного потока и фукнции fwrite можно писать команды в «терминал» и получать на выходе такой же стандарный выходной поток, т.е. результат, выдаваемый терминалом. Происходит путем построчного считывания из выходного потока при помощи fgets.
Все хорошо, проверка выполнения дельта индекса прошла успешно, я обрадовался, но…
«НО» произошло, когда я попытался выполнить индексацию основного индекса (порядка 400к записей, выполняется несколько минут). Оказалось, что выходной поток обрывается при малейшей задержке выполнения команды в терминале. Простым языком, когда вводишь команду, и терминал «задумывается». В итоге у меня оставались «недоиндексированные» файлы.
Решил погуглить, как народ решает проблемы, натолкнулся на кусок кода, прямо в мане по ssh2 на php.net. Автор решения предлагал ставить маркеры начала и окончания команды (echo '[start]'; $command; echo '[end]') и установить max_execution_time для скрипта.
Код приведен ниже.
$ip = 'ip_address';
$user = 'username';
$pass = 'password';
$connection = ssh2_connect($ip);
ssh2_auth_password($connection,$user,$pass);
$shell = ssh2_shell($connection,"bash");
//Trick is in the start and end echos which can be executed in both *nix and windows systems.
//Do add 'cmd /C' to the start of $cmd if on a windows system.
$cmd = "echo '[start]';your commands here;echo '[end]'";
$output = user_exec($shell,$cmd);
fclose($shell);
function user_exec($shell,$cmd) {
fwrite($shell,$cmd . "n");
$output = "";
$start = false;
$start_time = time();
$max_time = 2; //time in seconds
while(((time()-$start_time) < $max_time)) {
$line = fgets($shell);
if(!strstr($line,$cmd)) {
if(preg_match('/[start]/',$line)) {
$start = true;
}elseif(preg_match('/[end]/',$line)) {
return $output;
}elseif($start){
$output[] = $line;
}
}
}
}
Как мне показалось, хорошее решение, но…
Здесь НО заключалось в условии preg_match. При выводе информации в $output пишется все, что дает на выход терминал. Вышеописанная проблема с «задумавшимся терминалом» снова стала актуальной, т.к. при паузе на терминал выводилась команда вывода маркера завершения echo '[end]' (именно сама команда, а не результат выполнения). Все решилось путем добавления ограничения начала и конца строки в preg_match:
preg_match('/^[start]s*$/',$line)
и проверки на is_string для $line.
Оставалось только подрехтовать напильником, и, вуаля, в проекте на Yii был создан компонент, который является своего рода прослойкой для ssh2 функций.
<?php
class SshException extends CException {}
/**
* Class Ssh
* It is a base class for the simplify a ssh connection management
* and related commands execution
*
* @author Ivanenko Vladyslav
*/
class Ssh
{
const EXEC_TYPE_EXEC = 'exec'; // type for ssh2_exec()
const EXEC_TYPE_SHELL = 'shell'; // type for ssh2_shell()
const START_MARK = '__start__';
const FINISH_MARK = '__finish__';
const MAX_EXECUTION_TIME = 1800; // max script execution time in sec
private $user;
private $password;
private $host;
private $port;
private $shellType = 'bash'; // shell type
private $shell = null; //shell identificator
private $ssh = null; //connection
private $execType;
/**
* Construct
*
* @param null $user
* @param null $password
* @param null $host
*/
public function __construct($user = null, $password = null, $host = null, $port = null)
{
$config = Yii::app()->params['ssh'];
$params = array('user', 'password', 'host', 'port');
foreach($params as $param) {
if(isset(${$param}) && !is_null(${$param})) {
$this->{$param} = ${$param};
} else {
$this->{$param} = @$config[$param];
}
}
return true;
}
/**
* Connect to Ssh
*
* @return resource
* @throws SshException
*/
public function connect()
{
$this->ssh = @ssh2_connect($this->host, $this->port);
if(empty($this->ssh)) {
throw new SshException('Cant connect to ssh');
}
if(empty($this->execType)) {
$this->execType = self::EXEC_TYPE_SHELL;
}
return $this->ssh;
}
/**
* Login to ssh
*
* @throws SshException
* @return bool
*/
public function login()
{
if(!@ssh2_auth_password($this->ssh, $this->user, $this->password)) {
throw new SshException('Cant login by ssh');
}
return true;
}
/**
* Exec command by ssh
*
* @param $cmd
* @param $type
*
* @return string
* @throws SshException
*/
public function exec($cmd, $type = self::EXEC_TYPE_SHELL)
{
if(is_null($this->ssh)) {
$this->connect();
$this->login();
}
$this->execType = $type;
switch($this->execType) {
case self::EXEC_TYPE_EXEC: $result = $this->execCommand($cmd); break;
case self::EXEC_TYPE_SHELL: $result = $this->execByShell($cmd); break;
default: throw new SshException('Incorrect exec type'); break;
}
return $result;
}
/**
* Executes command by the direct ssh2_exec
*
* @param $command
*
* @return string
* @throws SshException
*/
private function execCommand($command)
{
if (!($stream = ssh2_exec($this->ssh, $command))) {
throw new SshException('Ssh command failed');
}
stream_set_blocking($stream, true);
$data = "";
while ($buf = fread($stream, 4096)) {
$data .= $buf;
}
fclose($stream);
return $data;
}
/**
* Executes command within the shell opening
*
* @param $command
*
* @return string
*/
private function execByShell($command)
{
$this->openShell();
return $this->writeShell($command);
}
/**
* opens shell
*
* @throws SshException
*/
private function openShell()
{
if(is_null($this->shell)) {
// here is hardcoded width and height, you can change them.
$this->shell = @ssh2_shell($this->ssh, $this->shellType, null, 80, 40, SSH2_TERM_UNIT_CHARS);
}
if( !$this->shell ) {
throw new SshException('SSH shell command failed');
}
}
/**
*
* Write the command to the open shell
*
* @param $cmd
* @param int $maxExecTime in sec
*
* @return string
*/
private function writeShell($cmd, $maxExecTime = self::MAX_EXECUTION_TIME)
{
// write start marker
fwrite($this->shell, $this->getMarker(self::START_MARK));
// write command
fwrite($this->shell, $cmd . PHP_EOL);
// write end marker
fwrite($this->shell, $this->getMarker(self::FINISH_MARK));
stream_set_blocking($this->shell, true);
sleep(1);
$output = "";
$start = false;
// define the time until the script can be executed
$timeUntil = time() + $maxExecTime;
while(true) {
if(time() > $timeUntil) {
break;
}
$line = fgets($this->shell, 4096);
// if any delay is happened while command is processing
if(!is_string($line)) {
sleep(1);
continue;
}
// define the start executed command
if(preg_match('/^' . self::START_MARK . 's*$/', $line)) {
$start = true;
} elseif(preg_match('/^' . self::FINISH_MARK . 's*$/', $line)) { // define the last executed command
break;
} elseif($start) {
// add console output to the script output data
$output .= $line;
}
}
return $output;
}
/**
* Disconnect from ssh
*/
public function disconnect() {
$this->exec('exit');
$this->ssh = null;
if(!is_null($this->shell)) {
fclose($this->shell);
}
}
/**
* Disconnect in destruct
*/
public function __destruct() {
$this->disconnect();
}
/**
* Returns marker command
*
* @param string $type
*
* @return string
*/
private function getMarker($type = self::START_MARK)
{
return 'echo "' . $type . '"' . PHP_EOL;
}
}
П.С. Этот класс можно расширить, ведь ssh2 не ограничивается только двумя функциями по выполнению команд, есть еще и функции для работы с файлами, и другие типы авторизации и т.д. и т.п.
Спасибо за внимание, надеюсь, статья будет полезной.
Буду рад услышать любые отзывы и конструктивную критику!
Автор: Владислав Иваненко, PHP Developer Zfort Group
Автор: alexzfort