使用 rails、nginx 和 send_file 在 Chrome 中流式传输 mp4

Streaming mp4 in Chrome with rails, nginx and send_file

我这辈子都无法将带有 html5 <video> 标签的 mp4 流式传输到 Chrome。如果我将文件放在 public 中,那么一切都是肉汁并且按预期工作。但是,如果我尝试使用 send_file 来提供服务,几乎所有可以想象的事情都会出错。我正在使用一个由 nginx 代理的 rails 应用程序,其 Video 模型具有 location 属性,该属性是磁盘上的绝对路径。

起初我试过:

def show
  send_file Video.find(params[:id]).location
end

而且我确信我会沉浸在现代网络开发的荣耀中。哈。这在 Chrome 和 Firefox 中都可以播放,但既没有搜索也不知道视频有多长。我查看了响应 headers 并意识到 Content-Type 正在作为 application/octet-stream 发送并且没有设置 Content-Length。嗯...有什么?

好的,我想我可以在 rails:

中设置它们
def show
  video = Video.find(params[:id])
  response.headers['Content-Length'] = File.stat(video.location).size
  send_file(video.location, type: 'video/mp4')
end

此时,Firefox 中的一切都与预期的一样。它知道视频的时长,并按预期寻找作品。 Chrome 似乎知道视频的时长(不显示时间戳,但搜索栏看起来合适)但搜索不起作用。

显然 Chrome 比 Firefox 更挑剔。它要求服务器用 Accept-Ranges header 和值 bytes 响应并用 206 响应后续请求(当用户寻求时发生)和适当的部分文件。

好的,所以我从 here 那里借用了一些代码,然后我得到了这个:

video = Video.find(params[:id])

file_begin = 0
file_size = File.stat(video.location).size
file_end = file_size - 1

if !request.headers["Range"]
  status_code = :ok
else
  status_code = :partial_content
  match = request.headers['Range'].match(/bytes=(\d+)-(\d*)/)
  if match
    file_begin = match[1]
    file_end = match[2] if match[2] && !match[2].empty?
  end
  response.header["Content-Range"] = "bytes " + file_begin.to_s + "-" + file_end.to_s + "/" + file_size.to_s
end
response.header["Content-Length"] = (file_end.to_i - file_begin.to_i + 1).to_s
response.header["Accept-Ranges"]=  "bytes"
response.header["Content-Transfer-Encoding"] = "binary"
send_file(video.location,
:filename => File.basename(video.location),
:type => 'video/mp4',
:disposition => "inline",
:status => status_code,
:stream =>  'true',
:buffer_size  =>  4096)

现在 Chrome 尝试搜索,但是当您搜索时视频停止播放并且在页面重新加载之前不再播放。啊。所以我决定尝试使用 curl 看看发生了什么,我发现了这个:

$ curl --header "Range: bytes=200-400" http://localhost:8080/videos/1/001.mp4 ftypisomisomiso2avc1mp41 �moovlmvhd��@��trak\tkh��

$ curl --header "Range: bytes=1200-1400" http://localhost:8080/videos/1/001.mp4 ftypisomisomiso2avc1mp41 �moovlmvhd��@��trak\tkh��

无论字节范围请求如何,数据总是从文件的开头开始。返回适当数量的字节(在本例中为 201 字节),但它总是从文件的开头开始。显然 nginx 尊重 Content-Length header 但忽略 Content-Range header.

我的 nginx.conf 未更改默认值:

user www-data;
worker_processes 4;
pid /run/nginx.pid;

events {
        worker_connections 768;
}

http {
        sendfile on;
        tcp_nopush on;
        tcp_nodelay on;
        keepalive_timeout 65;
        types_hash_max_size 2048;

        include /etc/nginx/mime.types;
        default_type application/octet-stream;

        ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE
        ssl_prefer_server_ciphers on;

        access_log /var/log/nginx/access.log;
        error_log /var/log/nginx/error.log;

        gzip on;
        gzip_disable "msie6";

        include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/*;
}

我的 app.conf 非常基础:

upstream unicorn {
server unix:/tmp/unicorn.app.sock fail_timeout=0;
}

server {
listen 80 default deferred;
root /vagrant/public;
try_files $uri/index.html $uri @unicorn;
location @unicorn {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header HOST $http_host;
    proxy_redirect off;
    proxy_pass http://unicorn;
}

error_page 500 502 503 504 /500.html;
client_max_body_size 4G;
keepalive_timeout 5;
}

首先我尝试了 Ubuntu 14.04 附带的 nginx 1.4.x,然后尝试了 ppa 中的 1.7.x - 结果相同。我什至尝试了 apache2 并得到了完全相同的结果。

我想重申一下,视频文件不是的问题。如果我将它放在 public 中,那么 nginx 会为它提供适当的 mime 类型、headers 以及 Chrome 正常工作所需的一切。

所以我的问题是 two-parter:


Update 1

好的,所以我想我会尝试将 rails 的复杂性排除在外,看看我是否能让 nginx 正确地代理文件。所以我启动了一个 dead-simple nodjs 服务器:

var http = require('http');
http.createServer(function (req, res) {
res.writeHead(200, {
    'X-Accel-Redirect': '/path/to/file.mp4'
});
res.end();
}).listen(3000, '127.0.0.1');
console.log('Server running at http://127.0.0.1:3000/');

而且chrome快乐如鱼得水。 =/ curl -I 甚至显示 Accept-Ranges: bytesContent-Type: video/mp4 正在被 nginx 自动插入 - 因为它 应该 是。 rails 是什么阻止了 nginx 这样做?


Update 2

我可能会越来越近...

如果我有:

def show
  video = Video.find(params[:id])
  send_file video.location
end

然后我得到:

$ curl -I localhost:8080/videos/1/001.mp4
HTTP/1.1 200 OK
Server: nginx/1.7.9
Date: Sun, 18 Jan 2015 12:06:38 GMT
Content-Type: application/octet-stream
Connection: keep-alive
Status: 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Disposition: attachment; filename="001.mp4"
Content-Transfer-Encoding: binary
Cache-Control: private
Set-Cookie: request_method=HEAD; path=/
X-Meta-Request-Version: 0.3.4
X-Request-Id: cd80b6e8-2eaa-4575-8241-d86067527094
X-Runtime: 0.041953

而且我有上述所有问题。

但是如果我有:

def show
  video = Video.find(params[:id])
  response.headers['X-Accel-Redirect'] = video.location
  head :ok
end

然后我得到:

$ curl -I localhost:8080/videos/1/001.mp4
HTTP/1.1 200 OK
Server: nginx/1.7.9
Date: Sun, 18 Jan 2015 12:06:02 GMT                                                                                                                                                                            
Content-Type: text/html                                                                                                                                                                                        
Content-Length: 186884698                                                                                                                                                                                      
Last-Modified: Sun, 18 Jan 2015 03:49:30 GMT                                                                                                                                                                   
Connection: keep-alive                                                                                                                                                                                         
Cache-Control: max-age=0, private, must-revalidate
Set-Cookie: request_method=HEAD; path=/
ETag: "54bb2d4a-b23a25a"
Accept-Ranges: bytes

一切都很完美。

但是为什么呢?那些应该做完全相同的事情。为什么 nginx 不像简单的 nodejs 示例那样自动设置 Content-Type 呢?我设置了 config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect'。我将它在 application.rbdevelopment.rb 之间来回移动,结果相同。我想我从来没有提到过...这是 rails 4.2.0.


Update 3

现在我已经将我的独角兽服务器更改为在端口 3000 上侦听(因为对于 nodejs 示例,我已经将 nginx 更改为在 3000 上侦听)。现在我可以直接向 unicorn 发出请求(因为它正在监听一个端口而不是一个套接字)所以我发现 curl -I 直接向 unicorn 显示没有 X-Accel-Redirect header 被发送,只是curling unicorn 直接发送文件。就好像 send_file 没有做它应该做的事。

终于找到了我原来问题的答案。我不认为我会到达这里。我所有的研究都导致 dead-ends、hacky non-solutions 和 "it just works out of the box"(好吧,不适合我)。

Why doesn't nginx/apache handle all this stuff automagically with send_file (X-Accel-Redirect/X-Sendfile) like it does when the file is served statically from public? Handling this stuff in rails is so backwards.

确实如此,但必须正确配置才能取悦 Rack::Sendfile(见下文)。试图在 rails 中处理这个问题很麻烦 non-solution.

How the heck can I actually use send_file with nginx (or apache) so that Chrome will be happy and allow seeking?

我迫不及待地开始研究机架源代码,这就是我在 Rack::Sendfile 的评论中找到答案的地方。它们的结构是您可以在 rubydoc.

找到的文档

出于某种原因,Rack::Sendfile 需要前端代理发送 X-Sendfile-Type header。对于 nginx,它还需要 X-Accel-Mapping header。该文档还包含 apache 和 lighttpd 的示例。

人们会认为 rails 文档可以 link 到 Rack::Sendfile 文档,因为 send_file 在没有额外配置的情况下无法开箱即用。也许我会提交一个拉取请求。

最后我只需要在 app.conf:

中添加几行
upstream unicorn {
  server unix:/tmp/unicorn.app.sock fail_timeout=0;
}

server {
  listen 80 default deferred;
  root /vagrant/public;
  try_files $uri/index.html $uri @unicorn;
  location @unicorn {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header HOST $http_host;
    proxy_set_header X-Sendfile-Type X-Accel-Redirect; # ADDITION
    proxy_set_header X-Accel-Mapping /=/; # ADDITION
    proxy_redirect off;
    proxy_pass http://localhost:3000;
  }

  error_page 500 502 503 504 /500.html;
  client_max_body_size 4G;
  keepalive_timeout 5;
}

现在我的原始代码按预期运行:

def show
  send_file(Video.find(params[:id]).location)
end

编辑:

虽然这最初有效,但在我重新启动 vagrant box 后它停止了工作,我不得不做进一步的更改:

upstream unicorn {
  server unix:/tmp/unicorn.app.sock fail_timeout=0;
}

server {
  listen 80 default deferred;
  root /vagrant/public;
  try_files $uri/index.html $uri @unicorn;

  location ~ /files(.*) { # NEW
    internal;             # NEW
    alias ;             # NEW
  }                       # NEW

  location @unicorn {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header HOST $http_host;
    proxy_set_header X-Sendfile-Type X-Accel-Redirect;
    proxy_set_header X-Accel-Mapping /=/files/; # CHANGED
    proxy_redirect off;
    proxy_pass http://localhost:3000;
  }

  error_page 500 502 503 504 /500.html;
  client_max_body_size 4G;
  keepalive_timeout 5;
}

我发现将一个 URI 映射到另一个 URI,然后将该 URI 映射到磁盘上的某个位置这一整件事完全没有必要。它对我的用例没有用,我只是将一个映射到另一个然后再映射回来。 Apache 和 lighttpd 不需要它。但至少它有效。

我还添加了 Mime::Type.register('video/mp4', :mp4)config/initializers/mime_types.rb 以便文件以正确的 mime 类型提供。