今回の目的
ちょっとごちゃごちゃしてます。
- Raspberry Pi 3B+で(直接)動いていた自分用DjangoプロジェクトをRaspberry Pi 4Bに移行
- そのSQLite3 DBが肥大化してきた(vacuumして120MBくらい)のと並行性確保のためMySQLに移行
- せっかくなのでDjangoプロジェクトをdocker-composeで動かすようにする
- MySQLもdocker-composeで動かそう
- DBを自動で更新する定期実行スクリプトもdocker-composeで動かそう
1. Raspberry Pi 4Bの初期セットアップ
まずはRaspberry Pi 4Bをセットアップする。
3B+で動いていたStretchは4Bでは動かないというような話を聞いたので、Download Raspbian for Raspberry Piから新しいRaspbian Busterを持ってきてMicro SDカードに焼く。メモリ節約(増えたけど)できそうなのでBusterはDesktopではなくLiteにした(どうせサーバ用途)。
mini HDMIケーブル/アダプタの持ち合わせがなかった(つらい、Microならあるのに...)ので、(20/03/07追記:間違い。Micro HDMIです。Miniはうすっぺらいやつ?)モニタレスセットアップする。100均で売ってるので必要になったら調達。
Micro SDカードを本体に挿し、LANケーブルでルータに接続。5V 3A Type-C電源(つらい、Microから買い替え)を接続して電源を入れる(Micro USBとType-Cの変換も100均にありそうなので電源を買い替えたくなければ... ちょっとこわいけど)。
ルータからDHCPで割り当てられたIPを調べて、LAN内でSSH接続(初期パスワード)。
Too many authentication failures
と言われたり、publickey
で弾かれるときは公開鍵で認証しようとして失敗してるので、-o PreferredAuthentications=password
か-o PubkeyAuthentication=no
をsshコマンドのオプションに加えるか、~/.ssh/config
にPreferredAuthentications password
かPubkeyAuthentication no
を加える。
パスワード変更・ユーザ名変更
パスワード変更、それから同時にユーザ名を変更する。piユーザにログインしたままだとユーザ名を変更できないので、新しいsudoerなユーザを作成してログインし直す(今回はLAN内なので一時的にrootパスワードを設定してもいいかもだけど)。
piユーザで以下のコマンドを実行してtmpuserを作成。
# useraddの場合ホームディレクトリは作られない
sudo useradd tmpuser
sudo passwd tmpuser
次にtmpuserをsudoersに追加する。
sudo adduser tmpuser sudo
ちょっと回り道をしたい場合、/etc/sudoersを編集してtmpuserを追加する。なんか/etc/sudoers他がreadonlyになってるので(chmodしてもいいけど)、/etc/sudoers.d/011_tmpuserを作る(sudoグループに追加でもいける)。
# /etc/sudoers.d/011_tmpuser
tmpuser ALL=(ALL:ALL) ALL
一度ログアウト、tmpuserユーザでログインし直して、以下のコマンドでpiユーザの名前を変更、piグループの名前も変更、最後にホームディレクトリを移動する。
sudo usermod -l NEW_NAME pi
sudo groupmod -n NEW_NAME pi
sudo usermod -m -d /home/NEW_NAME NEW_NAME
- How to Change the Default Account Username and Password – The Pi Hut
- [Raspbian]ユーザ名変更の個人的に「正しい」と思うやり方 | 純規の暇人趣味ブログ
- Raspbianでpiユーザ名とパスワードをうまいこと変更する - Qiita
tmpuserユーザからログアウトして、NEW_NAMEユーザで再ログイン、tmpuserユーザを削除する。回り道をした場合、/etc/sudoers.d/011_tmpuserも削除する。なお、デフォルトでpiユーザがsudoグループに属しているので、NEW_NAMEユーザをあらためてsudoersに加える必要はない(はず)。
sudo userdel tmpuser
# sudo rm /etc/sudoers.d/011_tmpuser
ホスト名変更
# /etc/hostname
NEW_HOSTNAME
# /etc/hosts
...
127.0.1.1 NEW_HOSTNAME
公開鍵認証
NEW_NAMEユーザに公開鍵を登録、/etc/ssh/sshd_configを編集してSSHの認証を公開鍵のみにする。
pi側
mkdir ~/.ssh
chmod 700 ~/.ssh
ホスト側
cd ~/.ssh
ssh-keygen -f KEY_NAME
scp KEY_NAME.pub RPI4_HOST:.ssh/
pi側
cd ~/.ssh
cat KEY_NAME.pub >> authorized_keys
chmod 600 authorized_keys
あとは~/.ssh/configでIdentityFile
を指定すればよい。やはりToo many authentication failures
といわれるときは、IdentitiesOnly yes
を追加する。
- sshで「Too many authentication failures for ...」が出た場合の対処法 - tkuchikiの日記
- [ssh]Too many authentication failures for... のエラー - Qiita
必要に応じて/etc/ssh/sshd_config
を編集してPasswordAuthentication no
を設定するなどする。
2. Raspberry Pi 4BにDocker/docker-composeを導入
sudo curl -fsSL https://get.docker.com/ | sh
sudo apt install python3-pip
sudo apt install libffi-dev
sudo pip3 install docker-compose
3. DjangoプロジェクトのDocker/docker-compose移行
Djangoプロジェクトはgitで管理してたので、git clone
で新サーバにプログラムを移行。DB(SQLite3)はscp
で移行。
旧サーバではvirtualenvで環境管理してたので、ここからrequirements.txtを生成。
pip3 freeze > requirements.txt
せっかくなので新サーバの環境構築はDocker/docker-composeで行う。django:wsgi-gunicorn-nginxの構成だったが、まずは単体で動作テスト。
# Dockerfile
FROM python:3
ENV PYTHONUNBUFFERED 1
RUN mkdir /code
WORKDIR /code
COPY requirements.txt /code/
RUN pip install -r requirements.txt
COPY . /code/
# docker-compose.yml
version: '3'
services:
web:
build: .
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/code
ports:
- "127.0.0.1:8000:8000"
environment:
- ENVIRONMENT=production
sudo docker-compose up
4. MySQLをDocker上で動かす(docker-compose/Raspberry Pi 4)
Raspberry Pi上のdocker-composeでMySQL(MariaDB)を動かす。jsurf/rpi-mariadb
を使う。
...
db:
# image: mariadb
image: jsurf/rpi-mariadb
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
volumes:
- DATABASE_DIRECTORY:/var/lib/mysql
environment:
- MYSQL_ROOT_PASSWORD=ROOT_PASSWORD
- MYSQL_DATABASE=DATABASE_NAME
- MYSQL_USER=USER
- MYSQL_PASSWORD=PASSWORD
web:
...
5. DjangoのDB設定をSQLite3からMySQLへ変更
あとで戻すので、簡単に戻せるようにしつつ、Djangoのsettings.py
を適当にいじって環境変数からDBを指定するようにする。
DATABASE_ENGINE = os.environ.get('DATABASE_ENGINE', 'django.db.backends.sqlite3')
DATABASE_OPTIONS = {}
if DATABASE_ENGINE == 'django.db.backends.mysql':
DATABASE_OPTIONS = {
'charset': os.environ.get('DATABASE_CHARSET'),
}
DATABASES = {
'default': {
'ENGINE': DATABASE_ENGINE,
'HOST': os.environ.get('DATABASE_HOST'),
'PORT': os.environ.get('DATABASE_PORT'),
'NAME': os.environ.get('DATABASE_NAME', os.path.join(BASE_DIR, 'db.sqlite3')),
'USER': os.environ.get('DATABASE_USER'),
'PASSWORD': os.environ.get('DATABASE_PASSWORD'),
'OPTIONS': DATABASE_OPTIONS,
},
}
docker-compose.yml
は以下のように編集。environmentのDATABASE部分をすべてコメントアウトするか、docker-compose.yml
を他に作ることでDBをSQLite3に戻せるようにしておく。
web:
...
environment:
- ENVIRONMENT=production
- DATABASE_ENGINE=django.db.backends.mysql
- DATABASE_HOST=db
- DATABASE_PORT=3306
- DATABASE_NAME=DATABASE_NAME
- DATABASE_USER=USER
- DATABASE_PASSWORD=PASSWORD
- DATABASE_CHARSET=utf8mb4
depends_on:
- db
requirements.txt
にPyMySQL
を追記して、manage.py
の上部に以下を追記する。
if os.environ.get('DATABASE_ENGINE') == 'django.db.backends.mysql':
import pymysql
pymysql.install_as_MySQLdb()
なお、MySQL側の初期化が終わる前にDjango側がアクセスするとDjangoがエラー落ちするので、初回実行時はdocker-compose up
を再実行する。2回目移行もDjangoが先に起動してしまう場合は、適当なsleepをはさむか待機スクリプトを作るなどして対処する。9番のセクションでgunicornをはさんだあとだと、Django(gunicorn)が先に起動してもエラーは出なくなった(みたいな)ので、あんまり気にしなくてもいいかもしれない。
command: bash -c "sleep 5 && python manage.py runserver 0.0.0.0:8000"
- Docker: Wait until MySQL is available
- yaml - Using Docker-Compose, how to execute multiple commands - Stack Overflow
6. Migration Errorの解消
DBモデルの定義によっては、sudo docker-compose up -d
してsudo docker-compose exec web python3 manage.py migrate
するとエラーが出る。例えばunique制約付きのTextFieldがあって、max_length
を指定していない場合。今回はURLをURLFieldではなくTextFieldに入れていたのをURLFieldに直し、また短い文字列とわかっているTextFieldにmax_length
(255以下)を指定して解消した(ただし、Raspberry Pi上ではこれだけでは動かなかったので、あとで結局unique制約を外した)。
このあたりは高速化のためメイン機にプロジェクトとDBを移して実験しつつ行った。今回はMySQLのimageを変える必要があるが、(だいたい)同じ環境を自動で整えてくれて、しかもホストの環境を汚さない/影響を受けないのがDockerのいいところ(公式imageがないと互換性に悩まされるのが...?)。
- つまみがなければ鼻でもつまむ:djangoでmysqlをバックエンドにするとTextFieldにunique制約がつけられない - livedoor Blog(ブログ)
- MySQLでBLOB/TEXT型カラムにインデックスを作成してみる - Qiita
7. DjangoのDBデータをjsonに書き出す
Migration Errorを解消したらDBをSQLite3に戻し、migrationしたあとでデータをjsonにダンプする。
sudo docker-compose run web bash
python3 manage.py makemigrations
python3 manage.py migrate
# python3 manage.py dumpdata > dump.json
python3 manage.py dumpdata --natural-foreign --natural-primary -e contenttypes -e auth.Permission > dump.json
- Django SQLite3からMySQLへの移行 - Qiita
- SQLite3のデータをdumpしてMySQLに移行する - Qiita
- mysql - Problems with contenttypes when loading a fixture in Django - Stack Overflow
8. DjangoのDBデータをjsonから書き戻す
python3 manage.py migrate
python3 manage.py loaddata dump.json
django.db.utils.IntegrityError: Problem installing fixture '/code/dump.json': Could not load APP.MODELNAME(pk=PK_NUM): (1062, "Duplicate entry 'ONE_FIELD_NUM' for key 'ONE_FIELD'")
OneToOneFieldにunique_together制約をかけていたのがよくなかった(OneToOneは多対1に使えない)みたいなので、ForeignKeyに変えた。また、この時点でmariadb
では動いたものの、jsurf/rpi-mariadb
ではutf8mb4にしているせいかKey lengthまわりのエラーが消えずに動かなかったので、すべての文字列のunique制約を外してしまうことにしたほか、こちらではmigrateが途中で止まってしまうのでmigrations以下のファイルを直接書き変えなければならなかった。別のPCで処理したDBを直接送りつけても動かなかったので、互換性にも不安が残るが...。何度も試行錯誤してようやくloaddataすることができた。
9. ホストのWebサーバ(リバースプロキシ)とDjangoの間にgunicornをはさむ
requirements.txt
にgunicorn
を追加する。
docker-compose.yml
を編集する。メモリを食う(と思う)のでワーカー数-w
は必要に応じて調整する。
# command: /usr/local/bin/gunicorn -w 4 -b 0.0.0.0:8000 MY_PROJECT.wsgi -t 300
command: bash -c "sleep 5 && gunicorn -w 4 -b 0.0.0.0:8000 MY_PROJECT.wsgi -t 300"
manage.py
と同様に、wsgi.py
の上部に以下を追加する。
if os.environ.get('DATABASE_ENGINE') == 'django.db.backends.mysql':
import pymysql
pymysql.install_as_MySQLdb()
- gunicornでPython製Webアプリケーションを動作させよう(DjangoとFlask) - Make組ブログ
- nginx + gunicorn + Django タイムアウト処理 - Qiita
- CentOS7+Nginx+GunicornでDjangoを起動 - Narito Blog
- EC2にNginx + Gunicorn + SupervisorでDjangoアプリケーションをデプロイする - Qiita
- shell - What's the difference between semicolon and double ampersand && - Unix & Linux Stack Exchange
10. 定期実行スクリプトをDocker上で動かす
(20/02/15 追記)
以下でbusybox crondを使った設定をしたが、ログ周りが不便なのと結局本番用のスクリプトはうまく動いてくれていなかったみたいなので、Pythonで定期実行スクリプトを書いて、Djangoコンテナと同じ構成のコンテナをもう一個作って実行することにした。しかし冗長なので、Djangoコンテナ側に定期実行用のエンドポイントを作ってHTTPリクエストを飛ばすだけのコンテナにした方がいいかもしれない..。
(旧版)
今回はDjangoと同じコンテナ内で定期実行を走らせる。
定期実行スクリプトはこれまでだいたいpythonでループを書いちゃうか、systemdのtimerで動かしていた。今回のものはsystemd/timerだったので、これをDockerコンテナ内に移行しようとしたが、ホストからDockerコンテナ内のスクリプトを動かすのはexec
でできるものの、Dockerコンテナ内でsystemd/timerを動かすのはよくわからない。
- laravel 5 - How do I get schedule:run to work with systemd in a docker container? - Stack Overflow
- Running scheduled tasks in Docker containers using systemd timer-units
- systemdからdockerコンテナを起動+timerで定期実行 – 進捗ダメな人のブログ
- systemd in docker container without --privileged - Qiita
ともあれ、python:3
のベースOSはDebianでsystemdがなさそう(init.d)なので、cronで定期実行することにする。
- Docker上でpythonのプログラムをcronで定期実行する - Qiita
- crontabのガイドライン - Qiita
- cron実行時のカレントディレクトリは、実行ユーザのホームディレクトリ - Qiita
- Docker + Cron環境を実現する3つの方法 - Qiita
- 稼働済みdockerコンテナでcronを動かす - Qiita
- Cronの使い方とテクニックと詰まったところ - Qiita
- Dockerコンテナをホスト側のcronで実行する - 202号室の手記
- Docker コンテナ内でタスクを cron 起動する - Qiita
- Docker で /etc/cron.d を使って cron を実行する - Qiita
- Dockerでcronを回す時ハマったこと - Qiita
- crontabして何も見つからないのに設定ファイルはあるとき - helen's blog
- 【違い】/etc/crontabと/var/spool/cron/[user] - Qiita
- /etc/crontabと/etc/cron.d設定ファイルの書き方 | server-memo.net
- crondの使い方 - Qiita
- crontab -e は「絶対に」使ってはいけない - ろば電子が詰まつてゐる
- docker - コマンドはdocker-compose.ymlとDockerfileのどちらで定義するほうがいい? - スタック・オーバーフロー
- debian - Crontab never running while in /etc/cron.d - Unix & Linux Stack Exchange
- linux - docker cron not working - Server Fault
- linux - Why is my crontab not working, and how can I troubleshoot it? - Server Fault
cronを使うのは初めてなのもあって、そうとう迷走した。
- Dockerコンテナ上でCronを動かしたい - Qiita
- DockerでcronしたいときはBusyBox crondが便利 - shimoju.diary
- debian ベースの Docker コンテナで busybox の cron を実行 - ngyukiの日記
Dockerで指定した環境変数を引き継ぎたいので、busyboxに含まれるcrondを使う。
まず、実行ディレクトリに以下のようなファイルcrontab
を作成する。
# * * * * * cd /code && echo `env` >> env.txt
0 */6 * * * cd /code && /usr/local/bin/python3 AUTORUN_SCRIPT.py
上は1分ごとに環境変数をファイルに書き出す設定(デバッグ用)で、下は6時間ごとに/code/AUTORUN_SCRIPT.pyをrootユーザ、ワーキングディレクトリ/codeで自動実行する設定。時刻はJSTでOK。
次に、Dockerfileにcrondのインストールと設定ファイルの追加を定義する。/var/spool/cron/crontabs/rootはディレクトリではなくファイルになるのが正解。
# Dockerfile
...
RUN apt update && apt install -y \
busybox-static
ENV TZ Asia/Tokyo
COPY crontab /var/spool/cron/crontabs/root
...
それから、Dockerコンテナ起動時にcrondが起動するようにする。今回はdocker-composeを使っているので、Dockerfile
内のCMD
は実行されないのに注意。代わりに、docker-compose.yml
内のcommand
にcrondの起動コマンドを追加する。crond
はバックグラウンド実行されるので、続けて自動的にgunicorn
が起動する。
# docker-compose.yml
...
# command: bash -c "busybox crond && gunicorn -w 4 -b 0.0.0.0:8000 MY_PROJECT.wsgi -t 300"
command: bash -c "sleep 5 && busybox crond && gunicorn -w 4 -b 0.0.0.0:8000 MY_PROJECT.wsgi -t 300"
...
DjangoのDBをいじるのでAUTORUN_SCRIPT.py
中にpymysqlのinstallを追加する(manage.py
、wsgi.py
と同じ)などして、実験用のスクリプトが動作することが確認できた。デバッグ用のcron設定を消して設定完了。
11. 起動時自動実行とバックグラウンド実行
docker-compose.yml
にrestart: always
を追加してホスト起動時に自動的に開始するようにして、sudo docker-compose up -d
でバックグラウンドで起動。あとはホストをrebootしても大丈夫(sudo docker-compose ps
、sudo docker ps
)。
結果
無事にハードウェア(Raspberry Pi)の移行、DBの移行、DBエンジンの移行、Dockerへの移行&永続化に成功した。
ハードウェアのスペック向上、MySQLへの移行によってパフォーマンスが改善した(ように思われる)ほか、DB操作を並行して行うことができるようになった(みたいな)ので、同時にDBにアクセスしたときに発生していたDatabase is locked
エラーが見られなくなった。
個人用プロジェクトなのでログ設定端折ったりだとかunique切ったりだとかしてるけど...。ここはそうとう時間かけたのであきらめた。ただ、loaddataしたあとでならmigrationでunique戻したりできるかもしれない。
あとはcronを別のコンテナに分けた方がいいのかな、と思いつつ、Djangoプロジェクトとまったく同じ依存関係なので分けずにまとめてしまった。これどう分けるんだ...?