TL;DR
- DRF(Django REST framework)とBasic認証問題が絡んだ時に起きた私の期待と異なる動作を再現するための環境構築の話
- 一応、上から読んでいくことで最終的に目的の環境を作れるようには書いているつもり
- なお未解決
DRFとBasic認証
Basic認証がかかっているとDRFの挙動が私が期待したものとは違う動作になる、という問題に遭遇しました。
こういうときに私はまっすぐ問題解決に進むのではなく、よし!再現できる環境をdocker-composeで作ってみよう!という方向に考えが行きがちです。これはその環境構築の記録です。なお、問題は現在も未解決です。しかし環境を構築して再現できたのでそこそこの満足感はあります。
docker-composeの登場人物紹介
docker-composeを使い、複数のdockerコンテナをいい感じにcomposeして動かします。以下のような構成です。
- dbコンテナ・・・
mysql:5.7
を使用。別にsqliteでも良かった気がしますが気分で選びました。 - proxyコンテナ・・・
nginx:1.16.0
を使用。静的ファイルはこいつが返し、appへのリクエストはリバースプロキシします。 - appコンテナ・・・
python:3.7
を使用。Djangoの本体。uwsgiで動かします。 - わたし・・・しがないエンジニア。最近また視力が落ちてきた気がする。これ以降登場しない。
docker-compose.yml
を載せるのがわかりやすそうなので以下に示します。
version: '3'
services:
db:
image: mysql:5.7
environment:
- MYSQL_USER=user
- MYSQL_PASSWORD=secret
- MYSQL_ROOT_PASSWORD=password
- MYSQL_DATABASE=database
app:
build: .
command: /srv/run.sh
tty: true
volumes:
- .:/srv
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
ports:
- "3031:3031"
user: "${UID}:${GID}"
depends_on:
- db
proxy:
image: nginx:1.16.0
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- .:/srv
links:
- app
ports:
- "0.0.0.0:80:80"
volumes:
.:
各コンテナの解説
dbコンテナは環境変数を指定して実行しているだけなので、これは飛ばしてappコンテナの話をします。
まずrequirements.txtも示します。今回使うパッケージは以下です。
Django==2.2.1
djangorestframework==3.9.4
mysqlclient==1.4.2.post1
uWSGI==2.0.18
Dockerfileはこんな感じです。コンテナにrequirements.txtをコピーしてpip install
します。
FROM python:3.7
ENV PYTHONUNBUFFERED 1
WORKDIR /srv
COPY requirements.txt /srv/
RUN pip install --no-cache-dir -r requirements.txt
ちなみにdocker-composeではディレクトリごとマウントして動かしているのにrequirements.txtだけコピーしているのかというと、ビルド時点ではマウントされないのでこうする必要があるということに作っていて気づきました。
PYTHONUNBUFFERED
は標準入出力のバッファを無効化するオプションらしいですが、まああまり深く考えずに無効化してます。環境変数に空ではない文字列を入れればいいみたいです。
Djangoのコードを置く場所は/srv/
にしてあります。一応、FHS(Filesystem Hierarchy Standard)を意識してのことですが本当にこれであってるのか?という不安は尽きないです。が、まあいいです。
buildとDjangoのプロジェクトの作成
Dockerfileもあることだし、ビルドしてみよう。
UID=$UID GID=$GID docker-compose build
「UID=$UID GID=$GID
ってなんだよ?」と思われるかもしれないですが、理由は後述します。兎にも角にもこのコマンドでずらずらとビルドされて、DjantoとかDRFとかが入ったイメージが作られます。
では作ったイメージで走り出します。Djangoのプロジェクトを作るコマンドを実行します。
UID=$UID GID=$GID docker-compose run --rm app django-admin.py startproject app .
appディレクトリとmange.pyファイルができたと思います。Djangoの雛形ですね。ところで、ここでUID=$UID GID=$GID
をつけた意味が出てきます。というもの、これを無くしてdocker-compose.ymlのuser: "${UID}:${GID}"
の行も消した上でコマンドを実行して貰えれば分かるのですが、ファイルもディレクトリもrootで作られてしまい、root権限がないと編集できなくなってしまいます。
コンテナを動かしているホストのユーザでも編集したいと思うのが人情ってもんですよね。なので変数を介してUIDとGIDを渡してますが、コンテナの中にはそんなユーザはいませんので/etc/passwd
と/etc/group
を以下のようにマウントしてます。
volumes:
- .:/srv
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
これによりコンテナ内で作ったファイルの編集が可能になります。
しかしUID=$UID GID=$GID
を毎度毎度つけなきゃいけないというところは私も気に入らない点でして、何とかしたいのですがいい方法が思い浮かばずこれで妥協している状態です。
mysqlを使うように設定
dbコンテナを用意しているのでちゃんと設定します。app/settings.py
のDATABASES
を書き換えます。
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'database',
'USER': 'user',
'PASSWORD': 'secret',
'HOST': 'db',
'PORT': '3306',
'OPTIONS': {
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
},
'ATOMIC_REQUESTS': True,
}
}
uwsgiで動かしてみる
プロジェクトも作ったことだし、この辺でDjangoのツラを拝んでやりたくなったのでそうしてみます。しかしuwsgiで動かしたいのでいくつか必要なものがあります。例えばuwsgi.ini
とか。
[uwsgi]
wsgi-file = /srv/app/wsgi.py
master = true
processes = 1
http = :3031
chmod-socket = 666
vacuum = true
die-on-term = true
py-autoreload = 1
正直、各オプションについて詳しく知っているというわけではないので不要なものが含まれていたりする可能性もありますが、ひとまず動くの良しとします。
さて、appコンテナのcommandには/srv/run.sh
を指定しています。なのでここに必要なコマンドを書いて行きます。
# !/bin/bash
set -eux
uwsgi --ini /srv/uwsgi.ini
用意したiniファイルを読んでuwsgiが動きます。
それから、app/settings.py
のALLOWD_HOSTS
を書き換えて、このDjangoをどこからでもみられるようにします。
ALLOWED_HOSTS = ['*']
フルオープンというのはどうかと思わないでもないですが検証目的なので良いことにします。
これで準備はできたので動かしてみます。
UID=$UID GID=$GID docker-compose up -d app
実行できたら http://127.0.0.1:3031/ でDjangoのツラを拝めるはずです。
staticディレクトリの設定
http://127.0.0.1:3031/admin/login/?next=/admin/ でDjango administrationの画面が見れると思いますが恐らくレイアウトが崩れているはずです。必要な画像やcssが見つけられなくてこうなっています。見つけられるようにしましょう。正直、Djangoの静的ファイルの扱いはなかなか一筋縄ではいかなくて理解しきれてない部分もありますが、まずはapp/settings.py
に一行追加しましょう。
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATIC_ROOT
はcollectstaticが静的ファイルを集めるディレクトリを定義します。そして collectstatic とは、静的ファイルを良い感じに一か所に集めてくれるコマンドということです。
https://docs.djangoproject.com/en/2.2/ref/contrib/staticfiles/#collectstatic
説明が余り十分ではないですがこの辺にしておきます。
どうやって一か所に集めるのがいいのかと考えましたが、run.sh
で集めるのが良さそうという結論に至ったので、こうします。
# !/bin/bash
set -eux
python manage.py collectstatic --noinput
uwsgi --ini /srv/uwsgi.ini
最後に集めた静的ファイルを見れるようにapp/urls.py
を書き換えます。
"""app URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/2.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
変わらない場合は、UID=$UID GID=$GID docker-compose down
でコンテナを消して、もう一度UID=$UID GID=$GID docker-compose up -d app
したりすれば行けると思います(何を変更するといつ反映されるのか、どういうときにコンテナ作り直す必要があるのか正確に理解できてない)。
DRFでAPIを作成
この辺りから検証っぽくなってきます。DRFでPOSTリクエストを受け取って、何もせずにレスポンスを返すAPIを作ってみようと思います。
次のコマンドでposttestアプリ(5秒で考えたような適当な名前)を作ります。
UID=$UID GID=$GID docker-compose run --rm app python manage.py startapp posttest
作成したposttestアプリとrest_frameworkをapp/settings.py
のINSTALLED_APPS
に追加します。
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'posttest',
]
さて、viewを作成しますが本当に何もせずにレスポンスを返すだけのAPIです。
from django.http.response import JsonResponse
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
class ApiPosttestView(APIView):
permission_classes = (
AllowAny,
)
def post(self, _request):
return JsonResponse(
{'message': 'message'},
)
permission_classes
はAllowAny
なので誰でも見れるAPIです。このAPIを呼び出せるようにurls.py
を必要に応じて作ったり追記したりします。
"""app URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/2.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.urls import include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('posttest.urls')),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
from django.urls import path
from posttest import views
app_name = 'posttest'
urlpatterns = [
path(
'api/posttest/',
views.ApiPosttestView.as_view(),
name='api_posttest',
),
]
ここまで出来たらdown
してup
し直して http://127.0.0.1:3031/api/posttest/ を開くとDRFの画面が見れると思います。
GETは許可してない、という意味のエラーメッセージが出てますね。POSTすると一応、メッセージが返ってきますが、あまり面白くはないですね。
nginxでリバースプロキシ
さて今まではuwsgiで動いているDjangoを直接見ていましたがnginxを立てたいと思います。
docker-compose.yml
を見てもらえれば./nginx/nginx.conf
の設定ファイルで上書きするようにマウントしてます。この設定ファイルの内容は以下です。
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/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 /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
upstream uwsgi {
server app:3031;
}
server {
listen 80;
charset utf-8;
location / {
include uwsgi_params;
uwsgi_pass unix:/srv/app.sock;
}
location /static {
alias /srv/static;
}
}
}
この中で重要なところはここですね。
upstream uwsgi {
server app:3031;
}
server {
listen 80;
charset utf-8;
location / {
include uwsgi_params;
uwsgi_pass unix:/srv/app.sock;
}
location /static {
alias /srv/static;
}
}
/static/
へのリクエストは静的ファイルなのでnginxが返します。そうでなければuwsgiにリバースプロキシします。これに合わせてuwsgi.ini
も変えます。
[uwsgi]
wsgi-file = /srv/app/wsgi.py
master = true
processes = 1
socket = /srv/app.sock
chmod-socket = 666
vacuum = true
die-on-term = true
py-autoreload = 1
変えたところはsocket = /srv/app.sock
です。今までは動作確認目的でHTTPサーバとして動かしていましたが、nginxを立てた以上それは不要になりますし、UNIXドメインソケットで繋げるのがこういった場合の常套手段であろうと考えてこうしました。
さて動かしてみましょう。
UID=$UID GID=$GID docker-compose up -d
今までは末尾にapp
を付けてproxy
コンテナが起動しないようにしていましたがもうその必要もないのですべて動かします。
http://127.0.0.1/api/posttest/ これで見れるようになったはずです。相変わらず"detail": "Method \"GET\" not allowed."
のエラーが出てるはずです。
静的ファイルの配信方法
静的ファイルはnginxが配信するので、app/urls.py
の記述は不要になります。
from django.contrib import admin
from django.urls import path
from django.urls import include
-from django.conf import settings
-from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('posttest.urls')),
-] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+]
gitでcommitしているのでしたらrevertしてなかったことにしましょう。gitのように人生の過去の出来事もなかったことに出来たらいいですよね。
しかし、STATIC_ROOT
の定義やrun.sh
のpython manage.py collectstatic --noinput
は必要です。proxyコンテナはappコンテナと同じボリュームをマウントし、受けた静的ファイルのリクエストはcollectstaticしたものをnginxが返します。
Basic認証をかける
前置きがずいぶん長くなりましたがいよいよ本題です。Basic認証をかけてみたときのDRFの挙動を見てみます。
htpasswd
を使うので無い場合はインストールします(centosならhttpd-tools)。
sudo yum install -y httpd-tools
それではBasic認証用のファイルをnginx/.htpasswd
に作ってみましょう。ユーザ名はuserでパスワードはpasswordです。
htpasswd -c nginx/.htpasswd user
New password:
Re-type new password:
Adding password for user user
このファイルをマウントするようにdocker-compose.yml
に- ./nginx/.htpasswd:/etc/nginx/.htpasswd
を書き足します。
proxy:
image: nginx:1.16.0
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/.htpasswd:/etc/nginx/.htpasswd
- .:/srv
links:
- app
ports:
- "0.0.0.0:80:80"
Basic認証が効くようにnginx.confに
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
を追記します。
server {
listen 80;
charset utf-8;
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
location / {
include uwsgi_params;
uwsgi_pass unix:/srv/app.sock;
}
location /static {
alias /srv/static;
}
}
さて、それではdown
してup
してみましょう。どうなるでしょうか。
ProgrammingErrorが発生しました。エラーメッセージを読むとDBにテーブルが無い、と言っています。ユーザ認証に使うテーブルです。今までDBは使ってなかったはずなのにBasic認証をかけると急に使いだすのは不思議です。まあ、でも無いと言って怒るのであれば作りましょう。
UID=$UID GID=$GID docker-compose run --rm app python manage.py migrate
これで必要なテーブルが出来ました。もう一度見てみます。
今度はエラー画面こそ表示されないものの今までとは違うエラーメッセージが表示されたと思います。
{
"detail": "Invalid username/password."
}
Basic認証を通ってるはずなのに、"Invalid username/password."
で弾かれるという現象を再現できました。
まとめ
これで無事に、私の期待とは異なる動作をDRFがすることを再現できました。ちなみに現在も未解決です。
https://www.django-rest-framework.org/api-guide/authentication/#basicauthentication
このあたりを読み込めば解決策が見つかりそうな気が・・・引き続き調査を進めようと思います。