2017年2月26日 星期日

下載 RTMP 影音檔

RTMP 的影片連結

在網頁上,使用 flash 播放的影片,有許多都採用 Adobe 的 rtmp 協定傳送影片。在網頁中,可以看到像下面這樣的連結
file: "rtmp://jzks.streamguys1.com/vod/mp4:advret/video1.mp4"
或是
file: '/mp4:advret/video1.mp4'
streamer: 'rtmp://jzks.streamguys1.com/vod'

可以使用 vlc 或 mpv 的播放程式來播放該連結
mpv  rtmp://jzks.streamguys1.com/vod/mp4:advret/video1.mp4

假如要下載的話 (請確認授權是否允許),有下列幾種方式

1. 使用 ffmpeg 

ffmpeg -i 'rtmp://jzks.streamguys1.com/vod/mp4:advret/video1.mp4' -c copy video1.mp4

2. 使用 rtmpdump

rtmpdump -r "rtmp://jzks.streamguys1.com/vod/" -y "mp4:advret/video1.mp4" -o video1.mp4

3. 使用 curl

curl "rtmp://jzks.streamguys1.com/vod playpath=mp4:advret/video1.mp4" -o video1.mp4

2017年2月17日 星期五

Apache 的 override 對效能的影響

最近,有一台伺服器,因為效能太差,CPU 的效能一直被吃光。有同事想要把它換成號稱無敵的 Nginx。 但是在網路上,看到一些討論,在動態網頁上,效能取決於 PHP,Nginx 未必較好。而 Apache 的效能,則受到 override 的影響很大,若將其關閉,則動態網頁可能完勝 Nginx。

測試條件

硬體:CPU *2、4GB 記憶體
軟體:Apache 2.4.25 / PHP 7.0.15
64824 條 rewrite rule,大概長得像這樣
Redirect /981ccp https://ceiba.net/course/6a06f1/index.htm

指令:ab -c 100 -n 1000 http://10.161.91.212/tmp/phpinfo_x.php

Document Length:        78741 bytes
Concurrency Level:      100

AllowOverride none

Time taken for tests:   0.804 seconds
Complete requests:      1000
Failed requests:        97
   (Connect: 0, Receive: 0, Length: 97, Exceptions: 0)
Total transferred:      78925892 bytes
Requests per second:    1243.75 [#/sec] (mean)
Time per request:       80.402 [ms] (mean)
Time per request:       0.804 [ms] (mean, across all concurrent requests)
Transfer rate:          95863.61 [Kbytes/sec] received

CPU 使用率:2 個 CPU 大約 50%
記憶體使用率:523MB

AllowOverride  All

Time taken for tests:   49.415 seconds
Complete requests:      1000
Failed requests:        80
   (Connect: 0, Receive: 0, Length: 80, Exceptions: 0)
Total transferred:      78925914 bytes
Requests per second:    20.24 [#/sec] (mean)
Time per request:       4941.538 [ms] (mean)
Time per request:       49.415 [ms] (mean, across all concurrent requests)
Transfer rate:          1559.76 [Kbytes/sec] received

CPU 使用率:2 個 CPU 皆 100%
記憶體使用率:最高 2GB,大約在 1GB 至 1.5GB 之間

討論

從上面的結果來看,6萬多條的 rewrite rule,在此測試中,並沒有派上用場,但為了處理那些 rule,要多耗 1GB 的記憶體,並且拖垮 CPU。

2017年2月14日 星期二

PHP 透過 xsendfile 下載檔案

使用步驟說明

原本使用 PHP 來做檔案下載控制,包括限制下載速率,同一IP的連線數目。可是,CPU 使用率會飆高,記憶體的使用率也不低。試過 Nginx,Apache 的 event 模式,結果都差不多。

 後來,看到可以設定 X-SendFile header,交由 Apache 控制下載,今天花了一天的時間,終於搞定。

需要安裝的模組,包括
  • mod_limitipconn: 限制個別IP的連線數
  • mod_ratelimit: 限制下載的速度,如 150 為 150KB/s
  • mod_xsendfile: X-Sendfile 模組
單純針對直接的檔案下載,設定如下,其中將 mp4 強制下載存檔,否則支援 mp4 的瀏覽器會直接播放。
<Directory "/var/www/html/vod_dl">
    <Files *.mp4>
        ForceType application/octet-stream
        Header set Content-Disposition attachment
    </Files>

    <IfModule mod_limitipconn.c>
        # limit concurrent connection for 2
        MaxConnPerIP 2
    </IfModule>

    <IfModule mod_ratelimit.c>
       SetOutputFilter RATE_LIMIT
        SetEnv rate-limit 850
    </IfModule>

</Directory>

有可能需要設定 XSendFilePath,例如 "XSendFilePath /home/video_data",不然會出現 "The given path was above the root path .... " 的錯誤。

因為我的下載程式的 url 是長的這樣,
http://10.161.81.118/dl_video.php?fn=105S101.mp4

設定時,需針對 dl_video.php 做設定,因此下面的設定也會套用到像 dl_videx_xxx.php 的程式。
<Files dl_video*>
    <IfModule mod_xsendfile.c>
        # 啟用 xsendfile
        XSendFile On
        SetEnv MOD_X_SENDFILE_ENABLED 1
    </IfModule>

    <IfModule mod_limitipconn.c>
        # 限制每一IP同時連線的數目
        MaxConnPerIP 8
    </IfModule>

    <IfModule mod_ratelimit.c>
       # 限制下載速率 850KB/s
       SetOutputFilter RATE_LIMIT
       SetEnv rate-limit 850
    </IfModule>
</Files>

PHP 的程式長得像下面這樣,只要設定 header,就會轉由 Apache 控制下載。

// 權限檢查 .....
// 檔案檢查 .....
//  .....
header("X-Sendfile: {$fullname}");
header("Content-type: application/octet-stream");
header('Content-Disposition: attachment; filename="' . basename($fname) . '"');

注意,$fullname 是檔案系統中的實際目錄名稱,例如

/var/www/html/mp4_files/105S101.mp4


這樣子,就算開到 1千多個檔,CPU 的使用率不高,記憶體大概也只在 500MB左右。

Nginx 也有類似功能,不過它是接受URI,正如一般的 request,而非目錄檔名,我個人覺得比較不盡理想。

使用 docker 

原先的環境是使用 CentOS,但其已於數年前被宣告死亡,只能再找其他途徑。使用 docker.hub 的 PHP 官方 image -- php:7.4.33-apache-bullseye,bullseye 為 Debian 當前的舊的穩定(oldstable)版。

Debian 官方沒有 mod_limitipconn,必須自己下載編譯安裝。變通的方式,是在 docker 中編譯好,把用到的檔案 copy 出來。

apt install apache2-dev wget
wget https://dominia.org/djao/limit/mod_limitipconn-0.24.tar.bz2
tar -xvf mod_limitipconn-0.24.tar.bz2
cd mod_limitipconn-0.24

make
make install
--
## 需要的檔案是下面兩個
/usr/lib/apache2/modules/mod_limitipconn.so
/etc/apache2/mods-available/limitipconn.load

Dockerfile 如下。

# Debian 當前的舊的穩定(oldstable)版
FROM php:7.4.33-apache-bullseye

# Install required extensions
# libicu-dev -- required by php ext: intl
# libpq-dev -- required by php ext: pgsql pdo_pgsql
# libonig-dev -- required by php ext: mbstring
RUN apt-get update && \
    apt-get -y install libapache2-mod-xsendfile \
        libicu-dev libpq-dev libonig-dev \
    && docker-php-ext-install intl pdo \
		mysqli pdo_mysql  pgsql pdo_pgsql \
        mbstring

COPY conf/ocw_vod_dl.conf /etc/apache2/conf-enabled
COPY conf/mod_limitipconn.so /usr/lib/apache2/modules
COPY conf/limitipconn.load /etc/apache2/mods-available

RUN a2enmod headers limitipconn ratelimit xsendfile

EXPOSE 80
CMD ["apache2-foreground"]

docker-compose_tst.yml 如下。

# version: '2'

services:
  ocw-dl:
    container_name: ocw-dl-server
    build: .
    domainname: ocw.aca
    hostname: ocw-dl-server
    working_dir: /var/www/html
    volumes:
        - ../web:/var/www/html
        - /ext_hd8t/ajax/wk_backup/ocw_bak/vod:/var/www/html/vod_dl
    ports:
        - 8380:80

以上為 2024-01-23 更新,陽明山下雪之日。







2017年2月12日 星期日

Apache + php-fpm in CentOS 7

參考 1: Apache 2.4 mpm_event + php-fpm in CentOS 7
參考 2: Install PHP-FPM to make PHP scripts be fast
參考 3: High-performance PHP on apache httpd 2.4.x using mod_proxy_fcgi and php-fpm

prefork+module_php

依照網上找到的說明,裝好 Apache、PHP、PHP-FPM。
預設的安裝,是使用 prefork+module_php。
phpinfo() 送回的資訊為 "Server API: Apache 2.0 Handler"

prefork+php-fpm

修改 /etc/httpd/conf.d/php.conf,取消原來的 AddHandler,新增 SetHandler
# AddHandler php7-script .php
SetHandler "proxy:fcgi://127.0.0.1:9000"

即可在 prefork 模式下,透過 proxy:fcgi 來呼叫 PHP-FPM 處理 PHP 程式
phpinfo() 送回的資訊為 "Server API:  FPM/FastCGI"

event+php-fpm

更進一步,修改 /etc/httpd/conf.modules.d/00-mpm.conf,取消 mod_mpm_prefork.so,改用 mod_mpm_event.so
# LoadModule mpm_prefork_module modules/mod_mpm_prefork.so
LoadModule mpm_event_module modules/mod_mpm_event.so

以及在 /etc/httpd/conf/httpd.conf 中,加上
<IfModule mpm_event_module>
     ProxyPassMatch ~.php$ fcgi://127.0.0.1:9000
</IfModule>

則可使用 event 模式+PHP-FPM。

測試說明

以下,稍微用 ab 跑一下測試,瞭解一下差異。
$ ab -c 60 -n 2000 http://10.161.91.37:8080/laravel51-test/

取回資料的長度:        1197 bytes

計算 httpd process 的數量,注意,以下的指令會多算一個
# ps ax | grep httpd | wc -l 

另外,使用 htop 觀察系統各項資源的情形。

1. 預設的 prefork+module_php

重新啟動 (systemctl restart httpd) 之後
process: 6
memory: 238M, 其中,1.8% *1 、0.5 * 5
測試時,大約的最大值
process: 132
memory: 950M,其中 1.8% *1、1.2%*1、其餘 1.1%
效能
Requests per second:    150.58 [#/sec] (mean)
Time per request:       398.449 [ms] (mean)

2. prefork+php-fpm

重新啟動之後
process: 7
memory: 245M,1.8% *1、0.5% *6
php-fpm 的 memory:  master: 1.6% *1、其餘: 0.5%
測試時,大約的最大值
process: 72
memory: 609M
效能
Requests per second:    171.73 [#/sec] (mean)
Time per request:       349.376 [ms] (mean)

3. event+php-fpm

重新啟動之後
process: 5
memory: 243M,0.9% *1、其餘 0.6%。
假如 php-fpm 也重新啟動,memory: 214M
測試時,大約的最大值
process: 8,外加一堆 thread
memory: 535M
 效能
Requests per second:    147.16 [#/sec] (mean)
Time per request:       407.722 [ms] (mean)


小結

使用 event 模式,就是省記憶體,效能則一樣是取決於 PHP。70

大家都在傳 Nginx 很快,但也有測試比較的結果,假如 Apache 把 .htaccess 的 override 關掉,效能立刻變好。而且,在 PHP 的網頁,效能取決於 PHP。雖然,有人聲稱 .htaccess是萬惡之源,但想偷懶,就繼續用囉。

下載伺服器測試

prefork+mod_php: 139 processes, 600MB
prefork+php-fpm: 130 processes, 700MB
event+php-fpm: 120 threads, 800MB/ 150 threads, 920MB

先前聽說 Nginx 多強大,決定將原來運作的下載伺服器,改用 Nginx。但在換成 Nginx 之後,發現CPU使用率飆高,外部很難連上來,然後,就開始不穩定,PHP-FPM會當掉。

改回 Apache 之後, 發現不論那一種組合,運作都很穩定,CPU和記憶體的使用率,都沒有太大的差異。所以,真的有點懷疑網路上,對 Nginx 的吹捧。除非,我的設定,真的那裡有問題,而我能力有限,無法進一步改善。

嗯,我的下載伺服器,就只是跑一堆像下面的程式,把下載的速度控制在 800KB/s 左右,避免頻寬被吃光。改用 Nginx,伺服器就一付快掛的樣子,而用 Apache,硬就是游刃有餘。

哦,對了,順帶一提,這程式支援續傳 (resume),有興趣的人,可以參考參考。請多指教!

<?php

$fullname = $doc_root.'/mp4/'.$fname;

if (!is_file($fullname)) {
    // file does not exist
    ob_clean();
   
    header("HTTP/1.0 404 Not Found");
    // header("HTTP/1.0 404 Not Found", true, 404);
    die ('Sorry, file not found!!');
    exit;
}

$file_size = filesize($fullname);
$fp = fopen($fullname, 'rb');


if (!$fp) {
    // file couldn't be opened
    ob_clean();
    flush();
    header("HTTP/1.0 500 Internal Server Error");
    exit;
}

if(isset($_SERVER['HTTP_RANGE'])) {
    list($size_unit, $range_orig) = explode('=', $_SERVER['HTTP_RANGE'], 2);
    if ($size_unit == 'bytes')
    {
        //multiple ranges could be specified at the same time, but for simplicity only serve the first range
        //http://tools.ietf.org/id/draft-ietf-http-range-retrieval-00.txt
        list($range, $extra_ranges) = explode(',', $range_orig, 2);
    } else {
        $range = '';
        header('HTTP/1.1 416 Requested Range Not Satisfiable');
        exit;
    }
} else {
    $range = '';
}

//figure out download piece from range (if set)
list($seek_start, $seek_end) = explode('-', $range, 2);

//set start and end based on range (if set), else set defaults
//also check for invalid ranges.
$seek_end   = (empty($seek_end)) ? ($file_size - 1) : min(abs(intval($seek_end)),($file_size - 1));
$seek_start = (empty($seek_start) || $seek_end < abs(intval($seek_start))) ? 0 : max(abs(intval($seek_start)),0);

ob_clean();

header('Content-type: application/mp4'); //告訴瀏覽器 為下載
header('Content-Transfer-Encoding: Binary'); //編碼方式
header("Content-Disposition:attachment; filename=\"".$fname."\""); //檔名.

//Only send partial content header if downloading a piece of the file (IE workaround)
if ($seek_start > 0 || $seek_end < ($file_size - 1)) {
    header('HTTP/1.1 206 Partial Content');
    header('Content-Range: bytes '.$seek_start.'-'.$seek_end.'/'.$file_size);
    header('Content-Length: '.($seek_end - $seek_start + 1));
} else {
    header("Content-Length: $file_size");
}

header('Accept-Ranges: bytes');

set_time_limit(0);
fseek($fp, $seek_start);

while(!feof($fp)) {
    // limited rate to about 0.5 MB/Sec
    // 10KB * 50 = 500KB
    // 1000,000/50 = 20,000
    print(@fread($fp, 1024*100));
    ob_flush();
    flush();
    // wait for 20,000 micro-seconds
    usleep(1.2*pow(10,5));
    if (connection_status()!=0) {
        @fclose($fp);
        exit;
    }
}

ob_end_flush();
exit;
 




2017年2月7日 星期二

難以理解的 GoLang Closure

GoLang Closures Running as Go routines
http://golang-basic.blogspot.tw/2014/06/golang-closures-running-as-go-routines.html

程式 1,輸出 : c, c, c
package main

import (
    "fmt"
)

func main() {
    done := make(chan bool)

    values := []string{"a", "b", "c"}
    for _, v := range values {
        go func() {
            fmt.Println(v)
            done <- true
        }()
    }

    // wait for all goroutines to complete before exiting
    for _ = range values {
        <-done
    }
}

上面這個程式的輸出是 c, c, c,因為由 go 啟動的 closure,共享外層的變數 v,內層的程式還沒完成啟動,外層的 for 迴圈已經跑完,變數 v 的值為 'c',因此印出的值都是一樣的。

程式2 的 for迴圈,輸出 c, a, b
   values := []string{"a", "b", "c"}
    for _, v := range values {
        go func(u string) {
            fmt.Println(u)
            done <- true
        }(v)
    }

程式3 的 for迴圈,輸出 c, a, b
   values := []string{"a", "b", "c"}
     for _, v := range values {

       v := v // create a new 'v'.
        go func() {
            fmt.Println(v)
            done <- true
        }()
    }

2017年2月2日 星期四

透過 PHP 控制檔案下載

有時,某些檔案下載必需管制對象,此時需透過 PHP 來控制檔案下載。
下面整理了幾個作法,可參考自行試做看看,整合成自己需要的。

在 PHP 的官網上,提供一個最簡單的作法,
 <?php
$file 
'monkey.gif';

if (
file_exists($file)) {
    
header('Content-Description: File Transfer');
    
header('Content-Type: application/octet-stream');
    
header('Content-Disposition: attachment; filename="'.basename($file).'"');
    
header('Expires: 0');
    
header('Cache-Control: must-revalidate');
    
header('Pragma: public');
    
header('Content-Length: ' filesize($file));
    
readfile($file);
    exit;
}


注意,filename 的 「"」不能少掉,因為檔名可能包含空白。

另外,參考下述網頁的討論,
Fastest Way to Serve a File Using PHP

可用 X-SendFile header,交由 Apache 下載,對於大檔案,會比較有效率。

header("X-Sendfile: $file_name");
header("Content-type: application/octet-stream");
header('Content-Disposition: attachment; filename="' . basename($file_name) . '"');
Where $file_name is the full path on the file system.

再來,可以控制下載速率,支援續傳功能
function Download($path, $speed = null, $multipart = true)
{
    while (ob_get_level() > 0)
    {
        ob_end_clean();
    }

    if (is_file($path = realpath($path)) === true)
    {
        $file = @fopen($path, 'rb');
        $size = sprintf('%u', filesize($path));
        $speed = (empty($speed) === true) ? 1024 : floatval($speed);

        if (is_resource($file) === true)
        {
            set_time_limit(0);

            if (strlen(session_id()) > 0)
            {
                session_write_close();
            }

            if ($multipart === true)
            {
                $range = array(0, $size - 1);

                if (array_key_exists('HTTP_RANGE', $_SERVER) === true)
                {
                    $range = array_map('intval', explode('-', preg_replace('~.*=([^,]*).*~', '$1', $_SERVER['HTTP_RANGE'])));

                    if (empty($range[1]) === true)
                    {
                        $range[1] = $size - 1;
                    }

                    foreach ($range as $key => $value)
                    {
                        $range[$key] = max(0, min($value, $size - 1));
                    }

                    if (($range[0] > 0) || ($range[1] < ($size - 1)))
                    {
                        header(sprintf('%s %03u %s', 'HTTP/1.1', 206, 'Partial Content'), true, 206);
                    }
                }

                header('Accept-Ranges: bytes');
                header('Content-Range: bytes ' . sprintf('%u-%u/%u', $range[0], $range[1], $size));
            }

            else
            {
                $range = array(0, $size - 1);
            }

            header('Pragma: public');
            header('Cache-Control: public, no-cache');
            header('Content-Type: application/octet-stream');
            header('Content-Length: ' . sprintf('%u', $range[1] - $range[0] + 1));
            header('Content-Disposition: attachment; filename="' . basename($path) . '"');
            header('Content-Transfer-Encoding: binary');

            if ($range[0] > 0)
            {
                fseek($file, $range[0]);
            }

            while ((feof($file) !== true) && (connection_status() === CONNECTION_NORMAL))
            {
                echo fread($file, round($speed * 1024)); flush(); sleep(1);
            }

            fclose($file);
        }

        exit();
    }

    else
    {
        header(sprintf('%s %03u %s', 'HTTP/1.1', 404, 'Not Found'), true, 404);
    }

    return false;
}
The code is as efficient as it can be, it closes the session handler so that other PHP scripts can run concurrently for the same user / session. It also supports serving downloads in ranges (which is also what Apache does by default I suspect), so that people can pause/resume downloads and also benefit from higher download speeds with download accelerators. It also allows you to specify the maximum speed (in Kbps) at which the download (part) should be served via the $speedargument.

再一個,

A better implementation, with cache support, customized http headers.
serveStaticFile($fn, array(
        'headers'=>array(
            'Content-Type' => 'image/x-icon',
            'Cache-Control' =>  'public, max-age=604800',
            'Expires' => gmdate("D, d M Y H:i:s", time() + 30 * 86400) . " GMT",
        )
    ));

function serveStaticFile($path, $options = array()) {
    $path = realpath($path);
    if (is_file($path)) {
        if(session_id())
            session_write_close();

        header_remove();
        set_time_limit(0);
        $size = filesize($path);
        $lastModifiedTime = filemtime($path);
        $fp = @fopen($path, 'rb');
        $range = array(0, $size - 1);

        header('Last-Modified: ' . gmdate("D, d M Y H:i:s", $lastModifiedTime)." GMT");
        if (( ! empty($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) == $lastModifiedTime ) ) {
            header("HTTP/1.1 304 Not Modified", true, 304);
            return true;
        }

        if (isset($_SERVER['HTTP_RANGE'])) {
            //$valid = preg_match('^bytes=\d*-\d*(,\d*-\d*)*$', $_SERVER['HTTP_RANGE']);
            if(substr($_SERVER['HTTP_RANGE'], 0, 6) != 'bytes=') {
                header('HTTP/1.1 416 Requested Range Not Satisfiable', true, 416);
                header('Content-Range: bytes */' . $size); // Required in 416.
                return false;
            }

            $ranges = explode(',', substr($_SERVER['HTTP_RANGE'], 6));
            $range = explode('-', $ranges[0]); // to do: only support the first range now.

            if ($range[0] === '') $range[0] = 0;
            if ($range[1] === '') $range[1] = $size - 1;

            if (($range[0] >= 0) && ($range[1] <= $size - 1) && ($range[0] <= $range[1])) {
                header('HTTP/1.1 206 Partial Content', true, 206);
                header('Content-Range: bytes ' . sprintf('%u-%u/%u', $range[0], $range[1], $size));
            }
            else {
                header('HTTP/1.1 416 Requested Range Not Satisfiable', true, 416);
                header('Content-Range: bytes */' . $size);
                return false;
            }
        }

        $contentLength = $range[1] - $range[0] + 1;

        //header('Content-Disposition: attachment; filename="xxxxx"');
        $headers = array(
            'Accept-Ranges' => 'bytes',
            'Content-Length' => $contentLength,
            'Content-Type' => 'application/octet-stream',
        );

        if(!empty($options['headers'])) {
            $headers = array_merge($headers, $options['headers']);
        }
        foreach($headers as $k=>$v) {
            header("$k: $v", true);
        }

        if ($range[0] > 0) {
            fseek($fp, $range[0]);
        }
        $sentSize = 0;
        while (!feof($fp) && (connection_status() === CONNECTION_NORMAL)) {
            $readingSize = $contentLength - $sentSize;
            $readingSize = min($readingSize, 512 * 1024);
            if($readingSize <= 0) break;

            $data = fread($fp, $readingSize);
            if(!$data) break;
            $sentSize += strlen($data);
            echo $data;
            flush();
        }

        fclose($fp);
        return true;
    }
    else {
        header('HTTP/1.1 404 Not Found', true, 404);
        return false;
    }
}

結論
If you wish to hide where the file is located and people with specific privilege may download the file then it is a good idea to use PHP as relay, and you have to sacrifice some CPU time to gain more security and control.
 

續傳 (Resume download) 在 Apache 的 X-SendFile 與 FireFox 的問題  

2018-02-20 補記
在續傳 (Resume download) 時,瀏覽器除了會送出 HTTP_RANGE 的 header 外,還會送出其他的 header。如 Firefox 會額外送出 HTTP_IF_MATCH 和 HTTP_IF_UNMODIFIED_SINCE,使得 Apache 傳回 404 的錯誤。而 Chrome 會多送出 HTTP_IF_RANGE,但這不影響正常運作。不論如何,把這些多的 header 都拿掉,在 Apache 的設定檔,或 .htaccess 中,加入下面的設定。

# 將會造成 resume download 的 header 拿掉
SetEnvIf Range .+ HAS_RANGE_HEADER
RequestHeader unset If-Range env=HAS_RANGE_HEADER
RequestHeader unset If-Match env=HAS_RANGE_HEADER
RequestHeader unset If-Unmodified-Since env=HAS_RANGE_HEADER

網誌存檔