見出し画像

DjangoでNginxのX-Accel-Redirectを使って、保護されたコンテンツを配信する方法

DjangoCongressJP 2019が5/18にあり、無事終えることができました。今年も第一回の去年と同様にスタッフとして活動させていただき、充実した楽しい時間を過ごせました。

今回はトークの中で聞いたことを実際に試してみます。まず一番最初のトークの一つに「Djangoで静的ファイルとうまくやる」という発表がありました。詳しい資料は以下になります。

発表自体はcss, jsなどの静的ファイルをDjangoのアプリケーション環境下でどのようにして配信していくかというお話です。基礎的な内容ではありますが、非常にわかりやすく、WEBアプリケーションの基礎としては復習となるような良い発表だったと思います。

上記の発表で最後の方に「認証付きの静的ファイル」という章が出てきました。

Nginxには静的ファイルに直接アクセスされてもレスポンスをせず、アプリケーションサーバー経由(Django経由)で閲覧許可が出なければ、Nginxから静的ファイルを返さない「X-Accel-Redirect」という仕組みがあります。

今回はそちらの機能を組み込んだ簡易的なDjangoアプリケーションを作ってみます。

「X-Accel-Redirect」とは?

そもそも「X-Accel-Redirect」とは何のかについて説明します。

例えばユーザーAさんがアップロードした画像ファイルがあるとします。しかし、その画像ファイルを基本的にはAさん以外には誰にも閲覧させたくはないとします。

Nginxが動いているサーバー内に画像などを保存していれば、普通に「/media/image/user_a_image.png」などのパスでアクセスすれば、普通に画像を閲覧できてしまいます。

そういった許可したユーザーのみファイル(コンテンツ)の閲覧を許可するという仕組みがX-Accel-Redirectという機能です。

以下の図を用いて具体的に説明します。この図ではNginxが動いているWebServerとDjangoが動いているApplicationServerを別としていますが、同一インスタンス(同一サーバー)上で動かしていても変わりません。

① 「/photo/1d6a3ab6-452b-40af-a422-514acbb7cfd2.jpg」にリクエストが飛び、Nginxが一度その処理を受ける。

② Nginxで設定したパスでは「/photo」以下はDjangoに投げるようになっているため、Djangoが動いているアプリケーションサーバーに処理を投げる

③ HTTPリクエストの内容からアクセスしてきたのが正規のユーザーなどかの認可の確認を行い、問題なければHTTPレスポンスに「X-Accel-Redirect」を付け加え、目的の静的ファイルである「1d6a3ab6-452b-40af-a422-514acbb7cfd2.jpg」があるパスを指定してレスポンスを返す。

④ Djangoが動くアプリケーションサーバーからレスポンスを貰うと、Nginx側でX-Accel-Redirectにしているパスを元にストレージサーバーにリダイレクトを行う。
ストレージはNginxと同じサーバー内のファイルだったり、Amazon S3だったりと環境によって違います。

今回作成するもの

今回作成するものは、直接パスを指定してNginxから取得しようとしたメディアファイル(画像ファイル)には制限をかけてアクセスできないようにし、Django経由でアクセスされたメディアファイルに関してアクセスを許可するというものです。

説明を簡略するためにも、認証や認可の機能に関しては実装しません。シンプルにDjangoを経由したものに関してはメディアファイルのアクセスを許可するようにします。

DockerでNginxの開発環境を作る

今回はNginxを動かすためにもDockerを用います。またNginxとDjangoを動かすコンテナ類をまとめるためにもdocker-compose.ymlを使います。

まずはnginxというディレクトリを作り、nginx.confとDockerfileというファイルを作成します。以下のようになっていれば成功です。

$ mkdir nginx
$ touch nginx/Dockerfile
$ touch nginx/nginx.conf

$ tree
.
└── nginx
   ├── Dockerfile
   └── nginx.conf

1 directory, 2 files

コンテナの設定情報であるDockerfileとNginxの設定ファイルであるnginx.confを編集していきます。
Dockerfileの方は基本的には至ってシンプルでnginxのイメージを指定し、nginxを起動するコマンドの二つだけです。

# Dockerfile
FROM nginx

CMD ["nginx", "-g", "daemon off;"]

続けてnginx.confの設定していきます。基本的に大事なのはhttp以下の内容で、eventsより上の内容は別に省略してもかまいません。

nginx.confは、この後も再度編集していきます。

worker_processes 1;

user nobody nogroup;
pid /tmp/nginx.pid;
error_log /tmp/nginx.error.log;

events {
   worker_connections 1024;
   accept_mutex off;
}

http {
   include /etc/nginx/mime.types;
   default_type application/octet-stream;
   access_log /tmp/nginx.access.log combined;
   sendfile on;

   server {
       listen 8000 default;
       client_max_body_size 75M;
       server_name localhost:8000;
       server_tokens off;

       keepalive_timeout 5;
   }
}

今回はdocker-compose.ymlを使います。Djangoとgunicornを動かすコンテナとNginxを動かすコンテナを連携させるためにもdocker-compose.ymlを使います。

一旦、docker-compose.ymlにはnginxのコンテナだけの設定を記述します。

version: '3'
services:
 nginx:
   build: ./nginx
   tty: true
   container_name: nginx
   ports:
     - "80:8000"
   volumes:
     - "./nginx/nginx.conf:/etc/nginx/nginx.conf"

一旦、実際にコンテナを起動させてみます。以下のようになっていれば成功です。

$ docker-compose build

# コンテナの起動
$ docker-compose up -d
Creating network "django_nginx_default" with the default driver
Creating nginx ... done

# 起動しているコンテナ類の確認
$ docker-compose ps
Name          Command          State              Ports            
-------------------------------------------------------------------
nginx   nginx -g daemon off;   Up      80/tcp, 0.0.0.0:80->8000/tcp

# コンテナの停止
$ docker-compose down

Djangoの環境を構築

nginxの環境を構築出来たため、次にDjangoの環境を構築します。まずは以下コマンドで先程の同一ディレクトリ上でDjangoのプロジェクトを作成します。

$ django-admin startproject mysite .

次にメディアファイル(画像ファイル)を扱うためのアプリケーションを作成します。

$ django-admin startapp photo

これによりphotoというアプリケーションが同ディレクトリ上に作成されます。ディレクトリ構成は以下のようになっているはずです。

$ tree
.
├── Dockerfile
├── docker-compose.yml
├── manage.py
├── mysite
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── nginx
│   ├── Dockerfile
│   └──nginx.conf
├── photo
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   ├── urls.py
│   └── views.py
└── requirements.txt

次にsettings.pyを設定していきます。INSTALLED_APPSにphotoアプリケーションを追加します。

LANGUAGE_CODEとTIME_ZONEも日本語の環境に設定します。

ALLOWED_HOSTS = ['127.0.0.1', '*']


# Application definition

INSTALLED_APPS = [
   'django.contrib.admin',
   'django.contrib.auth',
   'django.contrib.contenttypes',
   'django.contrib.sessions',
   'django.contrib.messages',
   'django.contrib.staticfiles',

   'photo',
]

LANGUAGE_CODE = 'ja'
TIME_ZONE = 'Asia/Tokyo'

保存する画像を扱うモデルを作成

それでは画像を扱うテーブルを作成するためにもモデルを作成していきます。models.pyを以下のように設定をしていきます。photo/models.pyを開いて、Photoモデルを作成してください。

# photo/models.py

from uuid import uuid4
from django.db import models


def uploaded_image_path(instance, filename):
   # アップロード先:MEDIA_ROOT/photos/<uuid>.png
   image_extension = filename.split('.')[-1]
   return f'photos/{instance.id}.{image_extension}'


class Photo(models.Model):
   id = models.UUIDField(default=uuid4, primary_key=True, editable=False)
   image = models.ImageField('画像', upload_to=uploaded_image_path)
   uploaded_date = models.DateTimeField('アップロード日', auto_now_add=True)

   def __str__(self):
       return f'{self.id}'

Django Admin(Django管理画面)側でもモデルを扱えるように、adminにPhotoモデルを追加します。photo/admin.pyを以下のように編集します。これでDjangoの管理画面からPhotoを扱えるようにします。

from django.contrib import admin
from .models import Photo

admin.site.register(Photo)

settings.pyのMEDIA_ROOTも設定します。これにより、画像保存時に画像ファイルの保存先はmediaディレクトリ上になります。

MEDIA_ROOT = os.path.join(BASE_DIR, './media')

models.pyの中を再度見てみましょう。

def uploaded_image_path(instance, filename):
   # アップロード先:MEDIA_ROOT/photos/<uuid>.png
   image_extension = filename.split('.')[-1]
   return f'photos/{instance.id}.{image_extension}'

それではマイグレーションファイルを作成して、実際にテーブルを作る作業であるマイグレーションを行いましょう。

以下のように結果が出れば成功です。

# 実際にマイグレーションファイルを作成する作業
$ python manage.py makemigrations
Migrations for 'photo':
 photo/migrations/0001_initial.py
   - Create model Photo

# マイグレーション作業
$ python manage.py migrate    19:53:00
Operations to perform:
 Apply all migrations: admin, auth, contenttypes, photo, sessions
Running migrations:
 Applying photo.0001_initial... OK

実際に画像を保存してみる

それでは実際に画像を保存してみましょう。まずはdjangoを起動して、/adminに入ります。

認証用の管理アカウントを作成していない方は各自で作成してください。(こちらを参考にしてください。)

$ python manage.py runserver

http://127.0.0.1:8000/adminのURLにアクセスして、管理画面を開きます。

Photosの追加を選び、保存する画像ファイルを選んで保存します。

保存にすると以下のように画像一覧で画像が保存できたことが確認できます。ファイル名はuuidで自動的に決定します。

実際に以下のようにmedia/photosディレクトリの中に画像ファイルが保存されているのが確認できます。

$ tree
.
├── Dockerfile
├── docker-compose.yml
├── manage.py
├── media
│   └── photos
│       └── 903cad03-2a4c-46c1-86a6-615b32e15ed3.jpg
├── mysite
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── nginx
│   ├── Dockerfile
│   └── nginx.conf
├── photo
│   ├──__init__.py
│   ├──admin.py
│   ├──apps.py
│   ├── migrations
│   │   ├── 0001_initial.py
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   ├── urls.py
│   └── views.py
└── requirements.txt

Nginx側からアクセスできるように設定し直す

画像ファイルを保存する機構ができたので、Nginx側からアクセスできるようにnginx.confを設定し直します。

./nginx/nginx.confの中身を以下のように編集し直します。/mediaに対してのアクセスを設定しているのがわかると思います。

worker_processes 1;

user nobody nogroup;
pid /tmp/nginx.pid;
error_log /tmp/nginx.error.log;

events {
   worker_connections 1024;
   accept_mutex off;
}

http {
   include /etc/nginx/mime.types;
   default_type application/octet-stream;
   access_log /tmp/nginx.access.log combined;
   sendfile on;

   server {
       listen 8000 default;
       client_max_body_size 75M;
       server_name localhost:8000;
       server_tokens off;

       keepalive_timeout 5;

       location /media/ {
           alias /usr/local/media/;
       }
   }
}

docker-compose.ymlの方も設定し直します。/mediaをvolumesに追加します。これにより./media以下のディレクトリををnginxを動かしているコンテナにマウントします。

version: '3'
services:
  nginx:
    build: ./nginx
    tty: true
    container_name: nginx
    ports:
      - "80:8000"
    volumes:
      - "./media:/usr/local/media"
      - "./nginx/nginx.conf:/etc/nginx/nginx.conf"

設定し直したらdocker-composeを再起動します。

$ docker-compose build
$ docker-compose up -d

./media以下に903cad03-2a4c-46c1-86a6-615b32e15ed3.jpgというファイルがあったとします。

$ cd media/photos/
$ ls
903cad03-2a4c-46c1-86a6-615b32e15ed3.jpg

http://127.0.0.1/media/photos/903cad03-2a4c-46c1-86a6-615b32e15ed3.jpgにアクセスします。問題なく以下のように画像が表示されるはずです。

Nginx側からのアクセスを禁止する

上記のように/media以下のディレクトリにある画像が表示できるようになりました。今度はこれを普通にURLで指定された場合は、禁止するように設定し直します。

先程の/nginx/nginx.confの設定ファイルの中にあるlocation /media/の部分を以下のように設定し直します。

Nginxではlocatinにinternalを付けることで、内部リダイレクトのような、内部からのリクエストのみを受け付けるように設定されます。

location /media/ {
   internal;
   alias /usr/local/media/;
}

再度、docker-compose upで再起動後に、接続し直すと以下のように404を返されて接続できなくなっていることが確認できます。

Django経由でアクセスできるように設定する

Nginx経由でのメディアファイルへのアクセスは禁止にしましたが、Django経由からのアクセスを許可するように設定していきましょう。

Nginx経由でのURLのpathは、/media/photos/ファイル名.(png/jpg)でしたが、URLのpathが「/photo/ファイル名.(png/jpg)」だった場合はDjango経由でアクセスできるようにしていきます。

まずはmysite/urls.pyを以下のように編集していきます。Pathがphoto/から始まる場合はinclude('photo.urls')としているように、それ以降のルーティングがphoto/urls.pyになります。

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
   path('admin/', admin.site.urls),
   path('photo/', include('photo.urls')),
]

photo/urls.pyを以下のように書き換えます。ここでは3つのルーティングを設定しました。

・/photo/list
保存している画像のリストをjsonで返す

・/photo/<uuid:photo_name>.png
・/photo/<uuid:photo_name>.png
画像ファイルが存在する場合は、Nginxの/media/photosに対してリダイレクトする
from django.urls import path
from . import views

urlpatterns = [
   path('list/', views.photo_list, name='photo_list'),

   path('<uuid:photo_name>.png/', views.photo_detail, name='photo_detail'),
   path('<uuid:photo_name>.jpg/', views.photo_detail, name='photo_detail'),
]

次にphoto/views.pyに上のルーティングであるurls.pyに記述してあるとおり、二つのメソッドを用意します。

from .models import Photo
from django.http import HttpResponse
from django.http.response import JsonResponse


def photo_list(request):
   photos = Photo.objects.order_by('uploaded_date')
   url_path = '/photo/'

   photo_dict = {
       str(photo.pk):
       url_path + str(photo.image).replace('photos/', '')
       for photo in photos
   }
   response = JsonResponse(photo_dict)
   return response


def photo_detail(request, photo_name):
   photo_name = str(photo_name)
   uuid = photo_name.split('.')[0]
   photo = Photo.objects.filter(id=uuid).first()
   print("photo : ", photo)

   if photo:
       response = HttpResponse(status=200)

       response["Content-Type"] = "image/png"
       response["Content-Disposition"] = "inline; filename={0}".format(
           photo_name)
       response['X-Accel-Redirect'] = "/media/{0}".format(photo.image)

       return response
   else:
       response = HttpResponse(status=400)
       return response

photo_listメソッドの方は、Photo.objects.order_by()とある通り、全てのレコードを取得し、最後の方にJsonResponse()でレスポンスを返します。

photo_detailメソッドの方はまず最初にPhoto.objects.filter(id=uuid).first()とあるとおり、uuidを元にファイルpathを指しているオブジェクトを取得します。

その後、photoが存在しているかを確認し、responseのHTTPヘッダを操作しています。ポイントとなるのは、X-Accel-Redirectの部分です。

Content-Typeでファイルの種類がimage/pngであることを指定します。Content-Dispositionではinlineを指定しており、WEBページの一部であることを指定しています。attachmentを指定してしまうと、ダウンロードファイルとしてブラウザが解釈してしまいます。

X-Accel-Redirectは実際の画像ファイルがある先のPathを指定しています。

response = HttpResponse(status=200)

response["Content-Type"] = "image/png"
response["Content-Disposition"] = "inline; filename={0}".format(
   photo_name)
response['X-Accel-Redirect'] = "/media/{0}".format(photo.image)

return response

上記のコードを元に、最初のX-Accel-Redirectを入れる事による動きをおさらいします。

Nginxにリクエストが飛ぶと、URLのPathを元にDjangoを動かしているgunicornなどにリクエストが飛ぶわけですが、それをレスポンスフィールドをいじってX-Accel-Redirectに値を設定します。Nginxにレスポンスを返すと、Nginx側でX-Accel-Redirectの値を元に、リダイレクトしてブラウザにレスポンスを返します。

docker-composeを設定し直す

次にdocker-composeを設定し直して、djangoとnginxのコンテナを連携させます。docker-compose.ymlを以下のように設定し直します。

version: '3'
services:
 app:
   build: .
   tty: true
   container_name: app
   expose:
     - "5001"
   command: gunicorn mysite.wsgi -b 0.0.0.0:5001
   volumes:
     - './:/usr/src/app'
   working_dir: '/usr/src/app'

 nginx:
   build: ./nginx
   tty: true
   container_name: nginx
   ports:
     - "80:8000"
   volumes:
     - "./media:/usr/local/media"
     - "./blog/static:/usr/local/static"
     - "./nginx/nginx.conf:/etc/nginx/nginx.conf"
   depends_on:
     - app

Djangoを動かしているappコンテナを見てみましょう。「build: .」と指定しているように同ディレクトリにDockerfileを作成します。

services:
 app:
   build: .
   tty: true
   container_name: app
   expose:
     - "5001"
   command: gunicorn mysite.wsgi -b 0.0.0.0:5001
   volumes:
     - './:/usr/src/app'
   working_dir: '/usr/src/app'

Dockerfileの中身は以下のようになっています。ちなみに「python:3.6」では、WSGIミドルウェアであるgunicornが入っています。ちなみに必要なパッケージ類を入れるためのrequirements.txtも用意してください。(ちなみに自分はDjango~=2.0.6の環境で試しています)

FROM python:3.6
ENV PYTHONUNBUFFERED 1

RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

RUN apk update 

ADD . /usr/src/app/
RUN pip install -r requirements.txt

次に/nginx/nginx.confを再度書き換えます。「/」がアクセスがあった場合は、app_serverに処理を投げます。つまりはgunicornが動いているコンテナです。

worker_processes 1;

user nobody nogroup;
pid /tmp/nginx.pid;
error_log /tmp/nginx.error.log;

events {
   worker_connections 1024;
   accept_mutex off;
}

http {
   include /etc/nginx/mime.types;
   default_type application/octet-stream;
   access_log /tmp/nginx.access.log combined;
   sendfile on;

   upstream app_server {
       server app:5001 fail_timeout=0;
   }

   server {
       listen 8000 default;
       client_max_body_size 75M;
       server_name localhost:8000;
       server_tokens off;

       keepalive_timeout 5;

       # DjangoのCSSのstaticファイルへのアクセスを
       # Nginxから配信する
       location /static/ {
           alias /usr/local/static/;
       }

       location /media/ {
           internal;
           alias /usr/local/media/;
       }

       location / {
           try_files $uri @proxy_to_app;
       }

       location @proxy_to_app {
           proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
           proxy_set_header Host $http_host;
           proxy_set_header X-Real-IP $remote_addr;
           proxy_redirect off;
           proxy_pass http://app_server;
       }

   }
}

ここまで作成してきたファイル類をおさらいしましょう。以下のようになっているはずです。

$ tree
.
├── Dockerfile
├── db.sqlite3
├── docker-compose.yml
├── manage.py
├── media
│   └── photos
│       ├── 1d6a3ab6-452b-40af-a422-514acbb7cfd2.jpg
│       └── 45d67a68-085c-4d34-b0e0-0fb48acee793.png
├── mysite
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── nginx
│   ├── Dockerfile
│   └── nginx.conf
├── photo
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   │   ├── 0001_initial.py
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   ├── urls.py
│   └── views.py
└── requirements.txt

Django経由でアクセスできるかを確認する

上記でDjango経由で画像を閲覧できるように設定しました。それでは実際に/photo/<uuid>.pngでアクセスできるかを確認します。

$ docker-compose build
$ docker-compose up -d

まずはhttp://127.0.0.1/photo/listにアクセスできるかを確認します。photo/listにアクセスすると現在保存してある画像のリストがJSONで返ってきます。

keyにはuuid, value部分にはその画像ファイルのpathを指しています。

実際にvalue部分が指しているURLにアクセスして、以下のように画像が表示されれば成功です。してください。

試しにphoto/の部分をmedia/photos/に変えても404が返ってくるはずです。

まとめ

今回は簡略のためにもviews.pyに複雑なロジックなどは一切実装していません。主に使える場面は特定のユーザーのみに許す画像などを作る際などです。是非試してみてください。

ソースコードは実際に以下に置いてありますので、よろしければご参考にしてください。

参考資料


この記事が気に入ったらサポートをしてみませんか?