Отдаем статические файлы с помощью Nginx и Rails

В легаси проекте на Rails столкнулись с проблемой раздачи pdf файлов, которая была реализована через send_file. (никогда так не делайте!!!)
Рельса не рассчитана на раздачу файлов или какого-нибудь медиа-контента, когда кто-то начинал скачивание довольно большого файла, тред блокировался и ждал окончания загрузки, в итоге однопоточное приложение не позволяло другим работать.
Попытка сменить thin на puma, не увенчалась успехом, ибо у нас и девелопмент и продакшен на Windows Server + Helicon Zoo + Ruby 1.9.3 (да, я страдаю от этого, ничего не поделать, проект легаси, тестов нет и переводить его на другую платформу или версию не представляется возможным).

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


Скачивам nginx с nginx.org основную версию и распаковываем её на C: диск. В случае с дистром линукса (у меня Debian testing) обойдемся одной командой sudo apt-get install nginx

Перейдем в nginx-1.9.15/conf/ и откроем nginx.conf

По умолчанию выглядит он так: (я удалил некоторые закомментированные строки)

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
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;

    keepalive_timeout  65;

    server {
        listen       85;
        server_name  localhost;

        access_log  logs/host.access.log  main;

        location / {
            root   html;
            index  index.html index.htm;
        }

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

Включим логи для просмотра запросов к нашим файлам, раскомментируем строчки, которые начинаются на log_format и access_log (11 и 15 строки).


У Nginx уже идет с замечательным модулем Secure Link module, так что нам не нужно вручную собирать.

Определим новый путь (data) к pdf файлам:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
location /data/ {
  types { application/octet-stream .pdf; }

  add_header Content-disposition "attachment; filename=$arg_name";

  set $security_key 'some';

  secure_link $arg_hash,$arg_expires;
  secure_link_md5 "$secure_link_expires$uri $security_key";

  if ($secure_link = "") {
    return 403;
  }

  if ($secure_link = "0") {
    return 410;
  }

  alias "//PC-ALEX/PdfFiles/$arg_ts/";
}

Работа модуля заключается в генерации хэша и в сравнивании поступающего через query url params хэша.

Сравниваются $secure_link и $secure_link_md5 и результат их сравнения поступает в $secure_link, если значение пусто, тогда хэши не равны, нулевое значение говорит нам, что об истечении срока жизни ссылки, и только если они равны отдает файл. Чтобы сгененрировать защищенный хэш, нужно объявить $security_key тут и в коде руби.

Определим путь до папки с файлами:

alias "//PC-ALEX/PdfFiles/$arg_ts/";, где $arg_ts, путь к подпапкам этой директории, который определяется динамически.

$arg_expires - timestamp, определяет время жизни ссылки, передается через url параметры.

Отдаем файлы как attachment:
types { application/octet-stream .pdf; }
add_header Content-disposition "attachment; filename=$arg_name";


Хорошо, теперь нужно сделать из nginx сервис, для этого качаем NSSM Указываем название сервиса и путь до сервера.

.\nssm install "Nginx" Nginx Запускаем сервис.


Сгенерируем хэш и нужный url на Ruby, думаю тут всё понятно:

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
class NginxUrlGenerator
  SECURITY_KEY = 'some'
  LIFETIME_SEC = 3
  HOST = 'http://localhost:85'

  NGINX_PATH_MAP = {
    inline: 'inline',
    attachment: 'data'
  }

  attr_reader :file, :disposition, :file_path, :remote_ip, :name

  def initialize(file_path, file_name, disposition, request, name)
    @file_path = file_path
    @disposition = NGINX_PATH_MAP[disposition.to_sym]
    @file = "/#{@disposition}/#{file_name}"
    @remote_ip = request.remote_ip
    @name = name
  end

  # Обязательно нужен utc timestamp
  def timestamp_with_lifetime
    Time.now.utc.to_i + LIFETIME_SEC
  end

  def md5_hash
    Digest::MD5.digest("#{timestamp_with_lifetime}#{file}#{remote_ip} #{SECURITY_KEY}")
  end

  def base64_hash
    Base64.encode64(md5_hash).gsub(/\+/, '-').gsub(/\//, '_').gsub(/\=/, '')
  end

  def full_file_path
    "#{HOST}/#{file}?hash=#{base64_hash}&ts=#{file_path}" +
    "&expires=#{timestamp_with_lifetime}&name=#{name}"
  end
end

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

1
2
3
4
5
6
# 201605098 - подкаталог

link =
  NginxUrlGenerator.new('20160509','25d044d3896a.3.pdf','attachment',request,'file_name')

redirect_to link.full_file_path

Вот как бы всё. Полный конфиг можно найти по ссылке ngixn.conf

Спасибо за внимание! :)