swoole的平滑重启问题

年轻时总以为能遇上许许多多的人。而后你就明白,所谓机缘,其实也不过那么几次。

守护进程(daemon)就是一种长期生存的进程,它不受终端的控制,可以在后台运行。其实我们之前也有了解,比如说nginx,fpm等一般都是作为守护进程在后台提供服务。

熟悉linux的同学可能知道,我们可以利用nohup命令让程序在后台跑。swoole官方也为我们提供了配置选项daemonize,默认不启用守护进程,若要开启守护进程,daemonize设置为true即可。

守护进程有优点,必然也存在缺点。我们启用守护进程后,server内所有的标准输出都会被丢弃,这样的话我们也就无法跟踪进程在运行过程中是否异常之类的错误信息了。为方便起见,swoole为我们提供了另一个配置选项log_file,我们可以指定日志路径,这样swoole在运行时就会把所有的标准输出统统记载到该文件内。

信号

我们了解到,swoole是常驻内存的,若想让修改后的代码生效,就必须Ctrl+C,然后再重启server。对于守护进程化的server呢?了解过kill命令的同学要说了,我直接把它干掉,然后终端下再重启,就可以了。

事实上,对于线上繁忙的server,如果你直接把它干掉了,可能某个进程刚好就只处理了一半的数据,对于天天来回倒腾的你来说,面对错乱的数据你不头疼,DBA也想long死你!

这个时候我们就需要考虑如何平滑重启server的问题了。所谓的平滑重启,也叫“热重启”,就是在不影响用户的情况下重启服务,更新内存中已经加载的php程序代码,从而达到对业务逻辑的更新。

swoole为我们提供了平滑重启机制,我们只需要向swoole_server的主进程发送特定的信号,即可完成对server的重启。

那什么是信号呢?

信号是软件中断,每一个信号都有一个名字。通常,信号的名字都以“SIG”开头,比如我们最熟悉的Ctrl+C就是一个名字叫“SIGINT”的信号,意味着“终端中断”。

平滑重启

在swoole中,我们可以向主进程发送各种不同的信号,主进程根据接收到的信号类型做出不同的处理。比如下面这几个

  • SIGTERM,一种优雅的终止信号,会待进程执行完当前程序之后中断,而不是直接干掉进程
  • SIGUSR1,将平稳的重启所有的Worker进程
  • SIGUSR2,将平稳的重启所有的Task进程

如果我们要实现重启server,只需要向主进程发送SIGUSR1信号就好了。

平滑重启的原理是当主进程收到SIGUSR1信号时,主进程就会向一个子进程发送安全退出的信号,所谓的安全退出的意思是主进程并不会直接把Worker进程杀死,而是等这个子进程处理完手上的工作之后,再让其光荣的“退休”,最后再拉起新的子进程(重新载入新的PHP程序代码)。然后再向其他子进程发送“退休”命令,就这样一个接一个的重启所有的子进程。

我们注意到,平滑重启实际上就是让旧的子进程逐个退出并重新创建新的进程。为了在平滑重启时不影响到用户,这就要求进程中不要保存用户相关的状态信息,即业务进程最好是无状态的,避免由于进程退出导致信息丢失。

在swoole中,重启只能针对Worker进程启动之后载入的文件才有效!什么意思呢,就是说只有在onWorkerStart回调之后加载的文件,重启才有意义。在Worker进程启动之前就已经加载到内存中的文件,如果想让它重新生效,还是只能乖乖的关闭server再重启。

我们看个例子看看到底怎么样向主进程发送SIGUSR1信号以便有效重启Worker进程。

1
2
3
4
5
6
7
8
9
<?php

class Test
{
public function run($data)
{
echo $data;
}
}

在Test::run方法中,我们第一步仅仅是echo输出swoole_server接收到的数据。

当前目录下我们创建一个swoole_server的类NoReload.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php

require_once("Test.php");

class NoReload
{
private $_serv;
private $_test;

/**
* init
*/
public function __construct()
{
$this->_serv = new Swoole\Server("127.0.0.1", 9501);
$this->_serv->set([
'worker_num' => 1,
]);
$this->_serv->on('Receive', [$this, 'onReceive']);

$this->_test = new Test;
}
/**
* start server
*/
public function start()
{
$this->_serv->start();
}
public function onReceive($serv, $fd, $fromId, $data)
{
$this->_test->run($data);
}
}

$noReload = new NoReload;
$noReload->start();

特别提醒:我们在初始化swoole_server的时候的写法是命名空间的写法

1
new Swoole\Server

该种风格的写法等同于下划线写法 ,swoole对这两种风格的写法都支持

1
new swoole_server

此外我们看下server的代码逻辑:类定义之前require_once了Test.php,初始化的时候设置了一个Worker进程,注册了NoReload::onReceive方法为swoole_server的onReceive回调,在onReceive回调内接收到的数据传递给了Test::run方法处理。

接下来我们写一个client脚本测试下运行结果

1
2
3
4
5
6
7
8
<?php

$client = new swoole_client(SWOOLE_SOCK_TCP, SWOOLE_SOCK_SYNC);
$client->connect('127.0.0.1', 9501) || exit("connect failed. Error: {$client->errCode}\n");

// 向服务端发送数据
$client -> send("Just a test.\n");
$client->close();

客户端的测试代码也很简单,连接server并向server发一个字符串信息

在server不动的情况下我们修改下Test.php,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

class Test
{
public function run($data)
{
// echo $data;
$data = json_decode($data, true);
if (!is_array($data)) {
echo "server receive \$data format error.\n";
return ;
}
var_dump($data);
}
}

原先echo直接输出,现在我们改了下Test的代码,如果接收到的数据经过json_decode处理后不是数组,就返回一段内容并结束,否则打印receive到的数据

如果这个时候我们不对server进行重启,运行client的结果肯定还是一样的

server端新的代码未生效,如果Test.php新的代码生效了,会在server所在终端输出“server receive $data format error.”,这符合我们的认知。

下面我们通过ps命令查看下左侧server的主进程的pid,然后通过kill命令向该进程发送SIGUSR1信号,看看结果如何

结果发现即使向主进程发送了SIGUSR1信号,但是左侧server终端显示的依然是未生效的php代码,这也是对的,因为我们说过新的程序代码只针对在onWorkerStart回调之后才加载进来的php文件才能生效,我们事例中Test.php是在class定义之前就加载进来了,所以肯定不生效。

我们新建一个Reload.php文件,把server的代码修改如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?php

class Reload
{
private $_serv;
private $_test;

/**
* init
*/
public function __construct()
{
$this->_serv = new Swoole\Server("127.0.0.1", 9501);
$this->_serv->set([
'worker_num' => 1,
]);
$this->_serv->on('Receive', [$this, 'onReceive']);
$this->_serv->on('WorkerStart', [$this, 'onWorkerStart']);
}
/**
* start server
*/
public function start()
{
$this->_serv->start();
}
public function onWorkerStart($serv, $workerId)
{
require_once("Test.php");
$this->_test = new Test;
}
public function onReceive($serv, $fd, $fromId, $data)
{
$this->_test->run($data);
}
}

$reload = new Reload;
$reload->start();

仔细观察,我们仅仅移除了在类定义之前引入Test.php以及在__construct中new Test的操作。

而是在__construct方法中增加了onWorkerStart回调,并在该回调内引入Test.php并初始化Test类。

Test.php的代码,我们仍然先后用上面的两处代码为例,运行client看下结果

结果我们发现,在给主进程发送SIGUSR1信号之后,Test.php的新代码生效了。这也便实现了热重启的效果。

如此,我们在Test.php中不论如何更新代码,只需要找到主进程的PID,向它发送SIGUSR1信号即可。同理,SIGUSR2信号是只针对Task进程的,后面可以自行测试下。

热重启的效果实现了,现在针对Reload.php的server,让该server进程守护化看看。

1
2
3
4
5
$this->_serv->set([   
'worker_num' => 1,
'daemonize' => true,
'log_file' => __DIR__ . '/server.log',
]);

具体操作

设置进程名

1
2
3
$http->on('start', function ($server) {
swoole_set_process_name('yangzie-test');
});

查看

1
netstat -npl|grep 9501

shell脚本

1
2
3
4
5
echo "loading..."
pid=`pidof yangzie-test`
echo $pid
kill -USR1 $pid
echo "loading success"

https://wiki.swoole.com/wiki/page/p-server/reload.html