ミルク色の記録

やったこと、やってみたこと

Webプログラマの手持ちスキルを駆使してRaspberry PIに電源OFFボタンをつけた

回路組んで基盤作ったりとかできないので、Raspberry PI用のLCD購入して、UI作って電源OFFボタンをつけた。

UIはLinuxGUIアプリケーションを書く...のは回避して、ブラウザでできるようにして自分の手持ちスキル側に寄せた(Electron使うって手もあったかもだけど)。

使ったLCD

Quimatの3.5インチタッチスクリーン。

仕組み

localhostからのみアクセス可能にしたWebサーバをサービスとして動かしつつ、 GUIでユーザーを自動ログインにしておいて、電源投入されたらブラウザをkioskモードで起動してUI表示する。

UIのPower Offボタンをクリック(タッチ)したらWEB API投げてWebサーバ側でシャットダウンする。

GUI周りの環境構築

Raspberry PIのフォーラムの記事を参考にした。

ウィンドウマネージャはRPDやLXDEではなく一番シンプルなOpenboxを利用した。

Xサーバインストール

まずはフォーラムの記事の「Advanced - Custom Desktop Environment using Openbox WM」に従ってXサーバを推奨インストール無しでインストールする。

$ sudo apt-get install -y --no-install-recommends xserver-xorg

Openboxとlightdm、ブラウザとかのインストール

続いて、Openboxと自動ログインで使用するlightdm、Chromiumブラウザとフォントをインストールする。

lightdmは合わせてaccountsserviceを入れる。これを入れておかないとlightdmがError getting user list from org.freedesktop.Accounts: GDBus.Error:org.freedesktop.DBus.Error.ServiceUnknown: The name org.freedesktop.Accounts was not provided by any .service files みたいなエラーを履いたりする。

$ sudo apt-get install -y openbox lightdm accountsservice chromium-browser fonts-noto

自動ログイン後のブラウザのデフォルト起動設定

自動ログインさせるpiユーザーの.config/openbox/autostartを作成し、次の設定を行う。

xset -dpms &
xset s off &
chromium-browser --noerrdialogs --kiosk --incognito --disable-translate --fast --fast-start --disable-infobars --disable-features=TranslateUI --disk-cache-dir=/dev/null --no-first-run --no-default-browser-check --disk-cache-size=0 --media-cache-size=0 --disable-bookmark-reordering --disable-extensions --disable-dev-tools --disable-sync http://localhost:8080/ &

ディスプレイの省電力モードをオフにするためにxset -dpmsスクリーンセーバーをオフにするためにxset s offchromiumkioskモードで起動し、余計なものを色々出さないようにするためにオプションをモリモリつける。

マウスカーソルが表示されないようにする

画面にマウスカーソルが表示されないように、/etc/lightdm/lightdm.confを編集して#xserver-command=Xコメントアウトされている箇所を次のように書き換える。

xserver-command=X -nocursor

自動ログインを有効化

raspi-configで3 Boot Options->B1 Desktop / CLI->B4 Desktop Autologinを設定する。

動作デモ

見づらいGIFになっちゃったけど、こんな感じで動く。Raspberry PI 3 B+を使って、電源投入からブラウザの起動完了まで30秒くらい。

f:id:ushiboy:20190608135913g:plain
動作デモ

まとめ

フロントエンドとかバックエンドとか、諸々のソースコードはこちら。OSのイメージは前に作ったrootfsのReadOnly環境や、extraパーティションを作ったやつにした。今後もっと機能つけて遊んでみるかも。

github.com

Raspbianに追加したextrafsパーティションのためのツールを作った

以前にRaspbianのデフォルトOSイメージにextrafsパーティションを追加する手順をまとめて、スクリプト化した。

今回は追加したextrafsパーティションをSDカードの最大容量まで拡張するツール(expand-extrafs)と、SDカードの違いで起こる問題を考慮してOSイメージをエクスポートするツール(export-compact-os-image)を作った。

expand-extrafs

extrafsパーティションを任意のタイミングでSDカードの最大容量まで拡張するためのツールとして、expand-extrafsを作った。

事前にサービスとしてRaspbianに登録しておけば、bootパーティションexpandfsファイルを置いてOSを起動すると自動でサイズ拡張してくれる。

f:id:ushiboy:20190504132840p:plain
expand-extrafs

これはRaspbianのSSH有効化の仕様を参考にした(あっちはsystemd使ってやっているけど)。

動かすアプリケーション環境を出荷前まで準備しておいたOSをイメージファイルとして保存しておいて、SDカードに焼いて初回起動で自動拡張する。

export-compact-os-image

SDカードからextrafsパーティションの最後までをエクスポートするコマンドとして、export-compact-os-imageを作った。

これは単純にSDカード全体をddコマンドでエクスポートしただけだと、イメージファイルのサイズがそのSDカードのサイズになってしまうことを防ぐために作成した。例えば8GBのSDカードであってもメーカー毎とか、商品毎とかでSDカードの容量が微妙に異なることがあるので、あえて小さいイメージとして作成しておけるようにして、書き込むSDカードを制限しないことが目的。

開発時や環境構築時はextrafsパーティションを拡張せずにOSを作って、このコマンドでエクスポートしてイメージファイルにする。

f:id:ushiboy:20190504132923p:plain
export-compact-os-image

とりあえずこれでextrafsパーティションを使う準備ができた。

RaspbianのrootfsをReadOnlyにした

fsprotectとroot-ro

Raspberry PIでRaspbianを使っている時、rootfsのReadOnly化については、fsprotectを使った方法を過去に試したことがあった。

github.com

fsprotectはdebianパッケージとしてインストールして、/boot/cmdline.txtfsprotectパラメータを追加するだけで設定できるのだけど、 Raspbianではaufsのパッチを当ててカーネルを再構築する必要があり、非力なRaspberry PIでカーネルビルドするの辛いのでPCでクロスコンパイルってことになって、前提条件を達成する作業が結構大変だった。

で、他の方法を調べていたら、最近root-roというものを知った。

github.com

root-roはoverlayfsを利用しているので、カーネルの再構築が不要で簡単に導入できる。

ただ、/boot/config.txtinitramfs initrd.gzを設定して、initramfsで起動する = ReadOnlyinitramfsで起動しない = 通常となっていて、 このあたりは常にinitramfsで起動し、cmdlineパラメータでReadOnlyの有効/無効を切り替えられるfsprotectの方が好みだった。

また、root-roは/bootパーティションもデフォルトでReadOnlyにしていて、 fsprotectは追加設定で/bootもReadOnlyにできるがデフォルトではなっていないという違いがあった。

fsprotectを使っていた時はデフォルトのままで使っていたので、rootfsのReadOnlyを一時的に解除したいときは/boot/cmdline.txtをちょっと編集して再起動するだけで良かった。

root-roもremountして/mnt/boot-roを編集すれば良いという手はあるが、/bootではなく/mnt/boot-roを意識する必要があり、個人的には/bootはReadOnlyじゃないほうが好みだった。

で、どうせRaspbianでしか使うつもりないしということもあり、好みを満たすために1から自分で作ってみた。

readonlyfs

作ったのはこれ。

github.com

仕組みはoverlayfsを利用してinitramfsでrootfsのマウントをtmpfsを使ったupperdir、lowerdir、workdirを組み合わせたものに取り替えるというもの。

ReadOnlyの有効/無効の切り替えはcmdlineパラメータにreadonlyfsがあるかどうかで行う。

とりあえずこれを適用したものとしていないものを用意して、Diskへの書き込みをログ取りしてみた。

ReadOnly化していないRaspbianでは次のようになり、Diskへの書き込みが発生していることがわかる。

f:id:ushiboy:20190427100905p:plain
ReadOnlyなし

ReadOnly化したRaspbianでは次のようになり、Diskへの書き込みが発生していないことがわかる。

f:id:ushiboy:20190427100949p:plain
ReadOnlyあり

最低限の実装なので、tmpfsのサイズ決めたり、/bootパーティションをReadOnlyにする機能はつけていない。

とりあえずこれでしばらく様子見。

RaspbianのOSイメージのパーティションを分割した

動機

公式で配布されているRaspbian OSイメージはbootとrootfsのパーティションのみで構成されていて、 Raspberry PIに入れて起動するとrootfsが自動でSDカード全体まで拡張される。

そのまま常時稼働で運用していると、OSのログやtmpやswapの利用でSDカードへの書き込みがチクチク行われて、 NANDフラッシュの書き込み上限を迎えてある日ディスクが壊れることになる。

NANDフラッシュはSLC、MLCTLCで書き込み上限が異なるので、なるべく上限の多いタイプを選ぶべきだけど、 それでも上限があることには変わりないし、工業用のSLCのSDカードとか結構高価。

なのでなるべくディスクへの書き込みは最小限にしたい。

また、電源ぶち切りとか停電とか考えると、そもそもrootfsはReadOnlyにしたい。

ただし、ReadOnlyにすると設定ファイルの書き換えみたいな最低限の書き込みや変更ができなくなってしまうので、 書き込みができる領域が別にほしいということになる。

というわけで、公式のRaspbian OSイメージをカスタマイズして、bootとrootfsの他にもう一つパーティションを追加する手順をまとめた。

作業環境とRaspbian OSイメージのバージョン

作業環境は今回もLinux PC。

Raspbianのイメージは(もう新しいの出てるけど)2018-11-13-raspbian-stretch-lite.imgを利用した。

カスタマイズ手順

作業は次のように行った。

カスタマイズ用のOSイメージを作ってパーティションの状況確認

まずカスタマイズ用に公式イメージのコピーを作成した。

$ cp 2018-11-13-raspbian-stretch-lite.img 2018-11-13-raspbian-stretch-lite-custom.img

次にfdiskで現在のイメージのパーティション状況を確認した。

$ fdisk -l 2018-11-13-raspbian-stretch-lite-custom.img
Disk 2018-11-13-raspbian-stretch-lite-custom.img: 1.8 GiB, 1866465280 bytes, 3645440 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7ee80803

デバイス                                     起動 Start 最後から セクタ  Size Id タイプ
2018-11-13-raspbian-stretch-lite-custom.img1       8192    98045   89854 43.9M  c W95 FAT32 (LBA)
2018-11-13-raspbian-stretch-lite-custom.img2      98304  3645439 3547136  1.7G 83 Linux

バイス2018-11-13-raspbian-stretch-lite-custom.img1がbootパーティションで、 2018-11-13-raspbian-stretch-lite-custom.img2がrootfsパーティションに当たる。

OSイメージのファイルサイズを増やす

パーティションを追加できるようにするために、まずtruncateを使ってOSイメージのファイルサイズを増やす。

$ truncate -s 4GiB 2018-11-13-raspbian-stretch-lite-custom.img

とりあえず4GBまで増やした。

改めてfdiskでイメージの状況を確認する。

$ fdisk -l 2018-11-13-raspbian-stretch-lite-custom.img
Disk 2018-11-13-raspbian-stretch-lite-custom.img: 4 GiB, 4294967296 bytes, 8388608 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7ee80803

デバイス                                     起動 Start 最後から セクタ  Size Id タイプ
2018-11-13-raspbian-stretch-lite-custom.img1       8192    98045   89854 43.9M  c W95 FAT32 (LBA)
2018-11-13-raspbian-stretch-lite-custom.img2      98304  3645439 3547136  1.7G 83 Linux

イメージのサイズが1.8GBから4GBまで増えたことが確認できる。

rootfsのパーティションサイズを変更

次にrootfsのパーティションサイズを拡張する。

fdiskの対話モードでカスタムイメージを開いて、dコマンドで一度rootfsのパーティションを削除し、nコマンドで改めて作りなおす。 この時新たに作るパーティションのFirst sectorはもともとの値(今回であれば98304)を指定する。 Last sectorは今回はrootfsを3GB固定にするので+3Gを指定した。

$ fdisk 2018-11-13-raspbian-stretch-lite-custom.img

Welcome to fdisk (util-linux 2.27.1).
Changes will remain in memory only, until you decide to write them.
Be careful before using the write command.


コマンド (m でヘルプ): d
パーティション番号 (1,2, default 2): 2

Partition 2 has been deleted.

コマンド (m でヘルプ): n
Partition type
   p   primary (1 primary, 0 extended, 3 free)
   e   extended (container for logical partitions)
Select (default p): p
パーティション番号 (2-4, default 2): 2
First sector (2048-8388607, default 2048): 98304
Last sector, +sectors or +size{K,M,G,T,P} (98304-8388607, default 8388607): +3G

Created a new partition 2 of type 'Linux' and of size 3 GiB.

作りなおしたrootfsパーティションをpコマンドで確認すると次のようになる。

コマンド (m でヘルプ): p
Disk 2018-11-13-raspbian-stretch-lite-custom.img: 4 GiB, 4294967296 bytes, 8388608 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7ee80803

デバイス                                     起動 Start 最後から セクタ  Size Id タイプ
2018-11-13-raspbian-stretch-lite-custom.img1       8192    98045   89854 43.9M  c W95 FAT32 (LBA)
2018-11-13-raspbian-stretch-lite-custom.img2      98304  6389759 6291456    3G 83 Linux

書き込み用領域のパーティションを追加

次に書き込み領域にする予定のパーティションをnコマンドで追加する。 First sectorは2018-11-13-raspbian-stretch-lite-custom.img2の最後のセクタ + 1の値(今回であれば6389760)とし、 Last sectorは指定せずにイメージファイルのサイズいっぱいまでを使う。

コマンド (m でヘルプ): n
Partition type
   p   primary (2 primary, 0 extended, 2 free)
   e   extended (container for logical partitions)
Select (default p): p
パーティション番号 (3,4, default 3): 3
First sector (2048-8388607, default 2048): 6389760
Last sector, +sectors or +size{K,M,G,T,P} (6389760-8388607, default 8388607):

Created a new partition 3 of type 'Linux' and of size 976 MiB.

追加したパーティションをpコマンドで確認する。

コマンド (m でヘルプ): p
Disk 2018-11-13-raspbian-stretch-lite-custom.img: 4 GiB, 4294967296 bytes, 8388608 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7ee80803

デバイス                                     起動   Start 最後から セクタ  Size Id タイプ
2018-11-13-raspbian-stretch-lite-custom.img1         8192    98045   89854 43.9M  c W95 FAT32 (LBA)
2018-11-13-raspbian-stretch-lite-custom.img2        98304  6389759 6291456    3G 83 Linux
2018-11-13-raspbian-stretch-lite-custom.img3      6389760  8388607 1998848  976M 83 Linux

wコマンドでパーティションの変更を反映してfdiskの対話モードを抜ける。

コマンド (m でヘルプ): w
The partition table has been altered.
Syncing disks.

書き込み用領域のフォーマットとラベル付け

追加したパーティションはまだフォーマットしていないので、フォーマットするためにイメージをLinux PCでマウントできるようにする。 これにはkpartxを利用してイメージファイル内のパーティションをデバイスマッピングする。

$ sudo kpartx -av 2018-11-13-raspbian-stretch-lite-custom.img
add map loop0p1 (253:0): 0 89854 linear 7:0 8192
add map loop0p2 (253:1): 0 6291456 linear 7:0 98304
add map loop0p3 (253:2): 0 1998848 linear 7:0 6389760

パーティション/dev/mapper下にマッピングされる。

$ ls /dev/mapper
control  loop0p1  loop0p2  loop0p3

/dev/mapper/loop0p1がboot、/dev/mapper/loop0p2がrootfs、/dev/mapper/loop0p3が今回追加したパーティションに当たる。

/dev/mapper/loop0p3EXT4でフォーマットする。

$ sudo mkfs -t ext4 /dev/mapper/loop0p3
mke2fs 1.42.13 (17-May-2015)
Discarding device blocks: done
Creating filesystem with 249856 4k blocks and 62464 inodes
Filesystem UUID: 828efd40-40d9-42f9-85f7-5d39b1154840
Superblock backups stored on blocks:
        32768, 98304, 163840, 229376

Allocating group tables: done
Writing inode tables: done
Creating journal (4096 blocks): done
Writing superblocks and filesystem accounting information: done

わかりやすいようにパーティションにラベルをつける。

$ sudo e2label /dev/mapper/loop0p3 extrafs

ラベルがついたか確認。

$ sudo e2label /dev/mapper/loop0p3
extrafs

rootfsの自動リサイズを無効化・書き込み用領域のマウントを設定

bootとrootfsのファイルに手を加えるために、それぞれのマウントポイントを作成する。

$ mkdir boot
$ mkdir rootfs

bootをマウントする。

$ sudo mount /dev/mapper/loop0p1 boot

rootfsをマウントする。

$ sudo mount /dev/mapper/loop0p2 rootfs

boot/cmdline.txtを編集し、末尾のinit=/usr/lib/raspi-config/init_resize.shを削除して、初回起動時のrootfsの自動拡張を無効にする。

$ sudo vi boot/cmdline.txt`

編集後のboot/cmdline.txt`は次のようになる。

dwc_otg.lpm_enable=0 console=serial0,115200 console=tty1 root=PARTUUID=7ee80803-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait quiet

rootfs/etc/fstabを編集し、extrafsパーティションが起動時にマウントされるように設定する。 末尾に/dev/mmcblk0p3 /extra ext4 defaults,noatime 0 1を追加する。

$ sudo vi rootfs/etc/fstab

編集後のrootfs/etc/fstabは次のようになる。

proc            /proc           proc    defaults          0       0
PARTUUID=7ee80803-01  /boot           vfat    defaults          0       2
PARTUUID=7ee80803-02  /               ext4    defaults,noatime  0       1
/dev/mmcblk0p3  /extra      ext4    defaults,noatime  0       1

他のパーティションがPARTUUID指定なのでちょっと歪だけどとりあえずこれで行く。

rootfs内にextrafs用のマウントポイントを追加しておく。

$ sudo mkdir rootfs/extra

bootとrootfsをアンマウントする。

$ sudo umount boot
$ sudo umount rootfs

バイスマッピングを解除する。

$ sudo kpartx -d 2018-11-13-raspbian-stretch-lite-custom.img

カスタムイメージの動作確認

出来上がったカスタムイメージをSDカードに書き込む。

$ sudo dd if=2018-11-13-raspbian-stretch-lite-custom.img of=/dev/mmcblk0 bs=1M
4096+0 レコード入力
4096+0 レコード出力
4294967296 bytes (4.3 GB, 4.0 GiB) copied, 776.849 s, 5.5 MB/s

書き込み完了したSDカードをRaspberry PIに入れて起動する。

起動したRaspbianにログインし、fdiskでパーティションの状況を確認する。

$ sudo fdisk -l /dev/mmcblk0
Disk /dev/mmcblk0: 7.4 GiB, 7969177600 bytes, 15564800 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7ee80803

Device         Boot   Start     End Sectors  Size Id Type
/dev/mmcblk0p1         8192   98045   89854 43.9M  c W95 FAT32 (LBA)
/dev/mmcblk0p2        98304 6389759 6291456    3G 83 Linux
/dev/mmcblk0p3      6389760 8388607 1998848  976M 83 Linux

mountの状況を確認する。

$ mount
/dev/mmcblk0p2 on / type ext4 (rw,noatime,data=ordered)
devtmpfs on /dev type devtmpfs (rw,relatime,size=217616k,nr_inodes=54404,mode=755)
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime)
proc on /proc type proc (rw,relatime)
tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000)
tmpfs on /run type tmpfs (rw,nosuid,nodev,mode=755)
tmpfs on /run/lock type tmpfs (rw,nosuid,nodev,noexec,relatime,size=5120k)
tmpfs on /sys/fs/cgroup type tmpfs (ro,nosuid,nodev,noexec,mode=755)
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/net_cls type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
sunrpc on /run/rpc_pipefs type rpc_pipefs (rw,relatime)
debugfs on /sys/kernel/debug type debugfs (rw,relatime)
systemd-1 on /proc/sys/fs/binfmt_misc type autofs (rw,relatime,fd=35,pgrp=1,timeout=0,minproto=5,maxproto=5,direct)
mqueue on /dev/mqueue type mqueue (rw,relatime)
configfs on /sys/kernel/config type configfs (rw,relatime)
/dev/mmcblk0p1 on /boot type vfat (rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,errors=remount-ro)
/dev/mmcblk0p3 on /extra type ext4 (rw,noatime,data=ordered)
tmpfs on /run/user/1000 type tmpfs (rw,nosuid,nodev,relatime,size=44384k,mode=700,uid=1000,gid=1000)

/dev/mmcblk0p3 on /extra type ext4 (rw,noatime,data=ordered)となっていれば/extraに追加したパーティションがマウントされていることが確認できる。

まとめ

とりあえず分割までできた。

この作業をいちいち手でやるのが面倒になったので、作成用スクリプト作った。

github.com

$ sudo ./create-extra-partition <path to>/YYYY-MM-DD-raspbian-stretch-lite.img

みたいな感じで作成できる。

Raspbianの初期起動でのrootfsパーティション自動拡張の仕組みを調べた

環境

試したRaspbianのイメージは2018-11-13-raspbian-stretch-lite.img。母艦はLinux PCで調べた。

きっかけ

Raspbian Jessieのいつ頃からか忘れたけど、イメージをSDカードに焼いてRaspberry PIに入れて起動すると、 自動でrootfsのパーティションがSDカードの容量全体まで拡張されるようになった。

RaspbianのOSイメージをSDカードに焼いた直後のパーティションはfdiskで確認すると、次のようになっている。

$ sudo fdisk -l /dev/mmcblk0
Disk /dev/mmcblk0: 3.7 GiB, 3965190144 bytes, 7744512 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7ee80803

デバイス       起動 Start 最後から セクタ  Size Id タイプ
/dev/mmcblk0p1       8192    98045   89854 43.9M  c W95 FAT32 (LBA)
/dev/mmcblk0p2      98304  3645439 3547136  1.7G 83 Linux

これをRaspberry PIに入れて起動すると、自動拡張のメッセージが表示されて、再起動後に次のようになる。

$ sudo fdisk -l /dev/mmcblk0
Disk /dev/mmcblk0: 3.7 GiB, 3965190144 bytes, 7744512 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7273b820

デバイス       起動 Start 最後から セクタ  Size Id タイプ
/dev/mmcblk0p1       8192    98045   89854 43.9M  c W95 FAT32 (LBA)
/dev/mmcblk0p2      98304  7744511 7646208  3.7G 83 Linux

/dev/mmcblk0p2の容量が1.7Gから3.7Gに広がっているのがわかる。

これはこれで便利なのだけれど、rootfsとは別のパーティション作ったり色々やりたいことがあるので、 そもそもこれはどうやって実現しているのだろうと思って調べてみた。

/boot/cmdline.txtを調べる

ブート時になにかやっているのだから/boot/cmdline.txtをとりあえず見てみようと思い、 イメージをSDカードに焼いてLinux PCからマウントしてファイルを覗いてみた。

/boot/cmdline.txtの中身

dwc_otg.lpm_enable=0 console=serial0,115200 console=tty1 root=PARTUUID=7ee80803-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait quiet init=/usr/lib/raspi-config/init_resize.sh

末尾に、なんかそのものズバリになりそうなinit=/usr/lib/raspi-config/init_resize.shを発見。

/usr/lib/raspi-config/init_resize.shを調べる

今度はrootfsをマウントして/usr/lib/raspi-config/init_resize.shの中身を覗いてみた。

init_resize.shの中身は次の通り。

#!/bin/sh

reboot_pi () {
  umount /boot
  mount / -o remount,ro
  sync
  if [ "$NOOBS" = "1" ]; then
    if [ "$NEW_KERNEL" = "1" ]; then
      reboot -f "$BOOT_PART_NUM"
    else
      echo "$BOOT_PART_NUM" > "/sys/module/${BCM_MODULE}/parameters/reboot_part"
    fi
  fi
  echo b > /proc/sysrq-trigger
  sleep 5
  exit 0
}

check_commands () {
  if ! command -v whiptail > /dev/null; then
      echo "whiptail not found"
      sleep 5
      return 1
  fi
  for COMMAND in grep cut sed parted fdisk findmnt partprobe; do
    if ! command -v $COMMAND > /dev/null; then
      FAIL_REASON="$COMMAND not found"
      return 1
    fi
  done
  return 0
}

check_noobs () {
  if [ "$BOOT_PART_NUM" = "1" ]; then
    NOOBS=0
  else
    NOOBS=1
  fi
}

get_variables () {
  ROOT_PART_DEV=$(findmnt / -o source -n)
  ROOT_PART_NAME=$(echo "$ROOT_PART_DEV" | cut -d "/" -f 3)
  ROOT_DEV_NAME=$(echo /sys/block/*/"${ROOT_PART_NAME}" | cut -d "/" -f 4)
  ROOT_DEV="/dev/${ROOT_DEV_NAME}"
  ROOT_PART_NUM=$(cat "/sys/block/${ROOT_DEV_NAME}/${ROOT_PART_NAME}/partition")

  BOOT_PART_DEV=$(findmnt /boot -o source -n)
  BOOT_PART_NAME=$(echo "$BOOT_PART_DEV" | cut -d "/" -f 3)
  BOOT_DEV_NAME=$(echo /sys/block/*/"${BOOT_PART_NAME}" | cut -d "/" -f 4)
  BOOT_PART_NUM=$(cat "/sys/block/${BOOT_DEV_NAME}/${BOOT_PART_NAME}/partition")

  OLD_DISKID=$(fdisk -l "$ROOT_DEV" | sed -n 's/Disk identifier: 0x\([^ ]*\)/\1/p')

  check_noobs

  ROOT_DEV_SIZE=$(cat "/sys/block/${ROOT_DEV_NAME}/size")
  TARGET_END=$((ROOT_DEV_SIZE - 1))

  PARTITION_TABLE=$(parted -m "$ROOT_DEV" unit s print | tr -d 's')

  LAST_PART_NUM=$(echo "$PARTITION_TABLE" | tail -n 1 | cut -d ":" -f 1)

  ROOT_PART_LINE=$(echo "$PARTITION_TABLE" | grep -e "^${ROOT_PART_NUM}:")
  ROOT_PART_START=$(echo "$ROOT_PART_LINE" | cut -d ":" -f 2)
  ROOT_PART_END=$(echo "$ROOT_PART_LINE" | cut -d ":" -f 3)

  if [ "$NOOBS" = "1" ]; then
    EXT_PART_LINE=$(echo "$PARTITION_TABLE" | grep ":::;" | head -n 1)
    EXT_PART_NUM=$(echo "$EXT_PART_LINE" | cut -d ":" -f 1)
    EXT_PART_START=$(echo "$EXT_PART_LINE" | cut -d ":" -f 2)
    EXT_PART_END=$(echo "$EXT_PART_LINE" | cut -d ":" -f 3)
  fi
}

fix_partuuid() {
  DISKID="$(fdisk -l "$ROOT_DEV" | sed -n 's/Disk identifier: 0x\([^ ]*\)/\1/p')"

  sed -i "s/${OLD_DISKID}/${DISKID}/g" /etc/fstab
  sed -i "s/${OLD_DISKID}/${DISKID}/" /boot/cmdline.txt
}

check_variables () {
  if [ "$NOOBS" = "1" ]; then
    if [ "$EXT_PART_NUM" -gt 4 ] || \
       [ "$EXT_PART_START" -gt "$ROOT_PART_START" ] || \
       [ "$EXT_PART_END" -lt "$ROOT_PART_END" ]; then
      FAIL_REASON="Unsupported extended partition"
      return 1
    fi
  fi

  if [ "$BOOT_DEV_NAME" != "$ROOT_DEV_NAME" ]; then
      FAIL_REASON="Boot and root partitions are on different devices"
      return 1
  fi

  if [ "$ROOT_PART_NUM" -ne "$LAST_PART_NUM" ]; then
    FAIL_REASON="Root partition should be last partition"
    return 1
  fi

  if [ "$ROOT_PART_END" -gt "$TARGET_END" ]; then
    FAIL_REASON="Root partition runs past the end of device"
    return 1
  fi

  if [ ! -b "$ROOT_DEV" ] || [ ! -b "$ROOT_PART_DEV" ] || [ ! -b "$BOOT_PART_DEV" ] ; then
    FAIL_REASON="Could not determine partitions"
    return 1
  fi
}

check_kernel () {
  local MAJOR=$(uname -r | cut -f1 -d.)
  local MINOR=$(uname -r | cut -f2 -d.)
  if [ "$MAJOR" -eq "4" ] && [ "$MINOR" -lt "9" ]; then
    return 0
  fi
  if [ "$MAJOR" -lt "4" ]; then
    return 0
  fi
  NEW_KERNEL=1
}

main () {
  get_variables

  if ! check_variables; then
    return 1
  fi

  check_kernel

  if [ "$NOOBS" = "1" ] && [ "$NEW_KERNEL" != "1" ]; then
    BCM_MODULE=$(grep -e "^Hardware" /proc/cpuinfo | cut -d ":" -f 2 | tr -d " " | tr '[:upper:]' '[:lower:]')
    if ! modprobe "$BCM_MODULE"; then
      FAIL_REASON="Couldn't load BCM module $BCM_MODULE"
      return 1
    fi
  fi

  if [ "$ROOT_PART_END" -eq "$TARGET_END" ]; then
    reboot_pi
  fi

  if [ "$NOOBS" = "1" ]; then
    if ! parted -m "$ROOT_DEV" u s resizepart "$EXT_PART_NUM" yes "$TARGET_END"; then
      FAIL_REASON="Extended partition resize failed"
      return 1
    fi
  fi

  if ! parted -m "$ROOT_DEV" u s resizepart "$ROOT_PART_NUM" "$TARGET_END"; then
    FAIL_REASON="Root partition resize failed"
    return 1
  fi

  partprobe "$ROOT_DEV"
  fix_partuuid

  return 0
}

mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t tmpfs tmp /run
mkdir -p /run/systemd

mount /boot
mount / -o remount,rw

sed -i 's| init=/usr/lib/raspi-config/init_resize.sh||' /boot/cmdline.txt
if ! grep -q splash /boot/cmdline.txt; then
  sed -i "s/ quiet//g" /boot/cmdline.txt
fi
sync

echo 1 > /proc/sys/kernel/sysrq

if ! check_commands; then
  reboot_pi
fi

if main; then
  whiptail --infobox "Resized root filesystem. Rebooting in 5 seconds..." 20 60
  sleep 5
else
  sleep 5
  whiptail --msgbox "Could not expand filesystem, please try raspi-config or rc_gui.\n${FAIL_REASON}" 20 60
fi

reboot_pi

partedを使って$ROOT_DEVをゴニョゴニョしている部分があるのでビンゴっぽい。

whiptail --infobox "Resized root filesystem. Rebooting in 5 seconds..." 20 60ってのがリサイズ完了メッセージを出すやつみたい。

面白いのは174行目から178行目あたり。

sed -i 's| init=/usr/lib/raspi-config/init_resize.sh||' /boot/cmdline.txt
if ! grep -q splash /boot/cmdline.txt; then
  sed -i "s/ quiet//g" /boot/cmdline.txt
fi
sync

/boot/cmdline.txtからinit=/usr/lib/raspi-config/init_resize.shを消して次から実行されないようにしている。

自動拡張を無効にしてみる

とりあえず/boot/cmdline.txtからinit=/usr/lib/raspi-config/init_resize.shを削除して、 Raspberry PIに入れて起動してみた。

初回起動を終えた後に改めてfdiskで確認すると、自動拡張されていないことが確認できる。

$ sudo fdisk -l /dev/mmcblk0
Disk /dev/mmcblk0: 3.7 GiB, 3965190144 bytes, 7744512 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7ee80803

デバイス       起動 Start 最後から セクタ  Size Id タイプ
/dev/mmcblk0p1       8192    98045   89854 43.9M  c W95 FAT32 (LBA)
/dev/mmcblk0p2      98304  3645439 3547136  1.7G 83 Linux

この状態で、raspi-configの7 Advanced Options -> A1 Expand Filesystemすれば、昔のRaspbianのように手動で拡張できる。

resize2fsの実行はどうなっているか調べる

通常、パーティションをリサイズしたあとはOSを再起動してからresize2fsをしなければいけないはずなんだけど、 それはどこでやっているのだろうと思ってraspi-configのExpand Filesystem機能を調べてみた。

raspi-config自体はgithubリポジトリがあったのでそこのraspi-configのソースを読んだ。

github.com

Expand Filesystem機能はdo_expand_rootfsで実装されていて、その中のコードを読んでみたら、fdiskでパーティションを拡張した後に、/etc/init.d/resize2fs_onceを作成してupdate-rc.dでサービスに登録し、再起動後に実行させていることがわかった。

resize2fs_onceはサービスが起動されたら、resize2fsをrootfsパーティションに対して行い、自分自身のサービス登録を解除して、ファイル削除するようになっている。

RaspbianのOSイメージを焼いたばかりのSDカードでrootfsをマウントして/etc/init.dの中を調べてみると、確かにresize2fs_onceが入っていた。

OSイメージに入っていた/etc/init.d/resize2fs_onceはこんな感じになっていた。

#!/bin/sh
### BEGIN INIT INFO
# Provides:          resize2fs_once
# Required-Start:
# Required-Stop:
# Default-Start: 3
# Default-Stop:
# Short-Description: Resize the root filesystem to fill partition
# Description:
### END INIT INFO
. /lib/lsb/init-functions
case "$1" in
  start)
    log_daemon_msg "Starting resize2fs_once"
    ROOT_DEV=$(findmnt / -o source -n) &&
    resize2fs $ROOT_DEV &&
    update-rc.d resize2fs_once remove &&
    rm /etc/init.d/resize2fs_once &&
    log_end_msg $?
    ;;
  *)
    echo "Usage: $0 start" >&2
    exit 3
    ;;
esac

Raspbianの初回起動時には必ずこのサービスが動くので、rootfsのパーティションに対してresize2fsが行われる。

/etc/init.d/resize2fs_onceもスパイ映画の「なお、このテープは自動的に消滅する」みたいな作りになっていて面白い。

まとめ

Raspbianのrootfsの初回起動時自動拡張は/usr/lib/raspi-config/init_resize.sh/etc/init.d/resize2fs_onceの合わせ技で実現されていることがわかった。

Elmのupdate: Msg -> Model -> (Model, Cmd Msg)にあこがれてJavaScriptの状態管理用のライブラリ作った

作ったのはこれ。

www.npmjs.com

動機

普段Reduxで非同期なことをやる時にredux-thunkミドルウェアを使っている。

で、次のようなことをするときにいつも悩む。

  • 非同期にデータを取ってくるアクションで取得前にローディングマスクを表示し、取得完了後にローディングマスクを非表示にする。
  • なんらかのメッセージ(完了したとかエラーになったとか)をToast的に表示して、数秒後に消す。

例えばローディングマスクのやつだと、だいたい次のように実装している。

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const fetchMessage = () => {
  return new Promise(resolve => {
    // サンプルなのでsetTimeoutでごまかし
    setTimeout(() => {
      resolve('hello');
    }, 1000);
  });
};

const load = () => {
  return async dispatch => {
    dispatch({ type: 'prepare-for-loading' });
    const msg = await fetchMessage();
    dispatch({ type: 'loaded', payload: msg });
  };
};

const store = createStore((state = { msg: '', loading: false }, action) => {
  switch (action.type) {
    case 'prepare-for-loading': {
      return {
        ...state,
        loading: true
      };
    }
    case 'loaded': {
      return {
        msg: action.payload,
        loading: false
      };
    }
    default: {
      return state;
    }
  }
}, applyMiddleware(thunk));

store.subscribe(() => {
  console.log(store.getState());
});

store.dispatch(load());

このとき気になるのが、loadの実装。

const load = () => {
  return async dispatch => {
    dispatch({ type: 'prepare-for-loading' });
    const msg = await fetchMessage();
    dispatch({ type: 'loaded', payload: msg });
  };
};

thunkなアクションとしてdispatchを受け取る関数を返し、非同期API実行の前後で2回dispatchしている。 これが1つのアクションで2つの仕事をしているように見えて、なんだかなーと思っていた。 テストを書くときも、この関数は2つアクションを返して、1つ目は...みたいになっていて、ますますなんだかなーと。

次のように中身を分離して使う側でそれぞれ呼び出すという手もあるが、これを並べて同じタイミングで実行するのか、 それとも1つ目のdispatchで変更されたStateのloading:trueが来たら2つ目を別のタイミングで実行するべきかなどで悩ましい。

store.dispatch({ type: 'prepare-for-loading'});

store.dispatch(() => {
  return async dispatch => {
    const msg = await fetchMessage();
    dispatch({ type: 'loaded', payload: msg });
  }
});

そんな感じで悶々としていた。

Elmとの出会い

ReduxがElmを参考にしたという話は知っていたけど、特別触ってみたりはしていなかった。 ある日rebuild.fmのエピソードで取り上げられているのを聴いて、ちょっと興味が湧いたので勉強してみた。

rebuild.fm

Elmアーキテクチャの簡単なやつを触って、updateインターフェイスupdate : Msg -> Model -> Model な辺りまでは、 確かにReduxっぽい...みたいな印象だった。

その後、commandを使う辺りまで進んで、インターフェイスupdate : Msg -> Model -> (Model, Cmd Msg)になった時に、自分がやりたかったのこれだなーと思った。

ローディングのやつの例でElmだと次のようになると思う(実際は非同期取得の結果の成否とかもっとcase増えると思うけど)。

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        PrepareForLoading ->
            ( { model | loading = True }, load )

        Loaded payload ->
            ( { model | msg = payload }, Cmd.none )

で、JavaScriptでこんな感じにやるには...と考えて冒頭に貼ったcycloneというライブラリを作った。

cycloneを使った場合

Reduxがupdate<S, A>(state: S, action: A) => Sなのに対して、cycloneはupdate<S, A>(state: S, action: A) => [S, A]というインターフェイスになっていて、更新された状態と次に実行するアクションをタプルで返すようにしてある。次に実行するアクションがない場合はnoneを返す。これはElmでのCmd.noneを意識した。

上のローディングマスクの例をcycloneで書き直すと次のようになる。

import { createStore, none } from '@ushiboy/cyclone';

const fetchMessage = () => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('hello');
    }, 1000);
  });
};

const prepareForLoading = () => ({ type: 'prepare-for-loading' });

const load = async () => {
  const msg = await fetchMessage();
  return { type: 'loaded', payload: msg };
};

const store = createStore({ msg: '', loading: false }, (state, action) => {
  switch (action.type) {
    case 'prepare-for-loading': {
      return [
        {
          ...state,
          loading: true
        },
        load()
      ];
    }
    case 'loaded': {
      return [
        {
          msg: action.payload,
          loading: false
        },
        none()
      ];
    }
    default: {
      return [state, none()];
    }
  }
});

store.subscribe(() => {
  console.log(store.getState());
});

store.dispatch(prepareForLoading());

prepareForLoadingを実行すると、updateはloadingをtrueにし、次に実行するloadを返す。 storeは更新された状態を通知しつつ、次に実行すべきアクションを実行する。

loadの前に実行する関数の名前をどうするか、名前お悩み問題が増えたような気がするけど、 アクション的には1つのアクションで1つの仕事をするようになったので、概ね満足。

その他の機能

Reducer

ついでにつけた機能として、Reducerでの分割統治はできるようにしておこうということでcombinereducerというAPIを用意した。

使用例は次の通り。

import { createStore, none, combine, reducer } from '@ushiboy/cyclone';

const store = createStore({ a: 0, b: 0, c: '' }, combine(
  reducer('a', (state, action) => {
    switch (action.type) {
     default: {
        return [state, none()];
      }
    }
  }),
  reducer('b', (state, action) => {
    switch (action.type) {
      default: {
        return [state, none()];
      }
    }
  }),
  reducer('c', [ 'a', 'b' ], (state, action, a, b) => {
    switch (action.type) {
      default: {
        return [state, none()];
      }
    }
  })
));

Reducerの定義はreducer関数に対象とするStateのキー名とupdate関数を渡して行うようにした。それをcombine関数で統合してひとつのupdate関数にする。

Reducerで分割統治すると、後々この状態はこっちでも参照したい...がでるので、reducer関数の第2引数に参照したい他の状態のキー名リストを渡すとupdate関数の第3引数以降で受け取れるようにした(上の例だとcのReducerでabの状態を参照している)。

Extra Argument

もうひとつ、redux-thunkのwithExtraArgument的な機能も足した。

createStoreの第3引数にアクション実行時に利用したいオブジェクトなどを設定すると、アクション側で受け取って使用できる。

これはWebAPIみたいな、テストの時はインターフェイス同じなモックに差し替えたいものを使うことを目的としている。

サンプルコードは次の通り。

const greet = () => async ({ sleep }) => {
  await sleep(1000);
  return { type: 'greet', payload: { msg: 'hello' } };
};

const store = createStore(
  { word: '', waiting: false },
  (state, action) => {
    switch (action.type) {
      default: {
        return [state, none()];
      }
    }
  },
  {
    sleep(time) {
      return new Promise(resolve => {
        setTimeout(() => {
          resolve();
        }, time);
      });
    }
  }
);

greetアクションはcreateStoreの第3引数で指定したsleepAPIを持つオブジェクトを受け取って使用している。

まとめ

Elmの学習はupdateのインターフェイス以外にも参考になることが結構あったのでやってよかった。

作ったライブラリは、ひとまず意図した動きになっているけど、中身の実装でまだ迷っている部分があるので、 その辺はドッグフーディングしながら様子見。

E2Eテストの実行時間を1/2とか1/4に縮めた

前回も触れたけど、普段Seleniumを使ってpythonでE2Eテストを書いている。

E2Eテストの接続先サーバはdocker-composeを使って必要なものをガッと立ち上げていて、 テスト内でredisやDBに直接接続してデータの初期化をしながらやっている。

使っているdocker-compose.ymlはだいたい次のような感じ。

version: '2'
services:
  web:
    container_name: "test-web"
    image: nginx:1.11
    ports:
      - "8080:80"
    volumes:
      - /etc/localtime:/etc/localtime
      - ./build:/usr/share/nginx/html
      - ./fixture/nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app
  app:
    container_name: "test-app"
    image: myapp
    volumes:
      - /etc/localtime:/etc/localtime
      - ./fixture/run.sh:/myapp/run.sh
      - ./fixture/production.ini:/myapp/production.ini
    depends_on:
      - redis
      - db
  redis:
    container_name: "test-redis"
    ports:
      - "6379:6379"
    volumes:
      - /etc/localtime:/etc/localtime
    image: redis
  db:
    container_name: "test-db"
    image: postgres:9.5
    ports:
      - "5432:5432"
    volumes:
      - /etc/localtime:/etc/localtime
      - ./fixture/postgresql/initdb:/docker-entrypoint-initdb.d
    environment:
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_USER=postgres

webサーバとしてnginxがあり、WEB APIのためのappサーバへのリクエストをリバースプロキシする。 appサーバはAPIのリクエストが来たら、dbやredisに対してアレコレする。

これをdocker-compose up -dしたあとにpy.test testして実行する...という感じにやっていた。

あるアプリケーションで、機能が増えるに連れてだんだんとテストの量が増えていき、 現在は150件くらいのテストを実行するのに15分近くかかるようになっていた。

DBコンテナの/var/lib/postgresql/dataをtmpfsにし、selenium.webdriver.support.ui.WebDriverWaitpoll_frequencyパラメータ(UIの変化を待機する条件をチェックする周期)をデフォルトの0.5秒から小さくして、 トータル時間12分位までには縮まったものの、劇的な効果は得られなかった。

そこで以前から考えていたpytest-xdistを使ったテストの並列化を試してみた。

pytest-xdistとdocker-composeの複数起動

pytest-xdistを使うとpy.test -n 2 testのようにして、-nオプションでワーカーの数を指定してテストを並列に実行できるようになる。

ただし、E2Eテストでこれをそのままやってしまうと、あるワーカーがテストを行っている最中に別のワーカーがDBの初期化をしてしまうので、 ワーカー毎に接続先のサーバ側を分ける必要があり、そのままでは使えない。

docker-composeは-pオプションでプロジェクト名を指定して複数起動することができるので、 docker-compose.yml環境変数で各種ポートを指定できるように修正を加えて、プロジェクトごとにポートを変えて起動できるようにした。

修正したdocker-compose.ymlファイルは次のような感じになった。

version: '2.1'
services:
  web:
    container_name: "test-web-${ID:-1}"
    image: nginx:1.11
    ports:
      - "${WEB_PORT:-8080}:80"
    volumes:
      - /etc/localtime:/etc/localtime
      - ./build:/usr/share/nginx/html
      - ./fixture/nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app
  app:
    container_name: "test-app-${ID:-1}"
    image: myapp
    volumes:
      - /etc/localtime:/etc/localtime
      - ./fixture/run.sh:/myapp/run.sh
      - ./fixture/production.ini:/myapp/production.ini
    depends_on:
      - redis
      - db
  redis:
    container_name: "test-redis-${ID:-1}"
    ports:
      - "${REDIS_PORT:-6379}:6379"
    volumes:
      - /etc/localtime:/etc/localtime
    image: redis
  db:
    container_name: "test-db-${ID:-1}"
    image: postgres:9.5
    ports:
      - "${DB_PORT:-5432}:5432"
    tmpfs:
      - /var/lib/postgresql/data
    volumes:
      - /etc/localtime:/etc/localtime
      - ./fixture/postgresql/initdb:/docker-entrypoint-initdb.d
    environment:
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_USER=postgres

コンテナ名が被らないように名称の末尾に付けるためのIDと各ポートWEB_PORTDB_PORTREDIS_PORT環境変数で渡せるようにした。

これで次のようにコマンドを叩けば同じ構成の環境をポートを変えて複数立ち上げられる。

$ ID=1 WEB_PORT=8080 DB_PORT=54320 REDIS_PORT=63790 docker-compose -p s1 up -d
$ ID=2 WEB_PORT=8081 DB_PORT=54321 REDIS_PORT=63791 docker-compose -p s2 up -d

pytest-xdistによって、テスト実行中のワーカーにはPYTEST_XDIST_WORKER環境変数が渡される。 ここから、gw0gw1といった自分のワーカーIDを取得できる。

このワーカーIDと接続するサーバのポートを関連付けて使うようにすれば、ワーカー毎に異なるサーバへ接続して互いに干渉することなくテストできる。

ワーカーと接続サーバの関連付け

docker-composeの環境変数に渡す値と、ワーカー内で使用するポートの値を関連付けて管理するために、次のようなe2e.config.yml設定ファイルを用意した。

workers:
  - web_port: 8080
    redis_port: 63790
    db_port: 54320
  - web_port: 8081
    redis_port: 63791
    db_port: 54321
  - web_port: 8082
    redis_port: 63792
    db_port: 54322
  - web_port: 8083
    redis_port: 63793
    db_port: 54323

ワーカーごとにweb_portredis_portdb_portを記述できるようにした。

そして、この設定ファイルを読み込んで使うためのAPIenvironment.pyとして用意した。

import yaml
import subprocess
import os

def load_e2e_config(filepath):
    with open(filepath, 'r') as f:
        data = yaml.load(f, Loader=yaml.SafeLoader)
    config = dict()
    workers = data.get('workers', [])
    for i, w in enumerate(workers):
        w['id'] = i + 1
        config['gw%d' % i] = w
    return config

def docker_compose_up(config):
    for name, c in config.items():
        env = os.environ.copy()
        env['ID'] = str(c['id'])
        env['WEB_PORT'] = str(c['web_port'])
        env['REDIS_PORT'] = str(c['redis_port'])
        env['DB_PORT'] = str(c['db_port'])
        subprocess.run(['docker-compose', '-p', name, 'up', '-d'], env=env)

def docker_compose_down(config):
    for name, c in config.items():
        subprocess.run(['docker-compose', '-p', name, 'down'])

load_e2e_config関数はe2e.config.ymlを読み込んでワーカーIDをキーとした設定オブジェクトに変換する。

docker_compose_up関数はload_e2e_config関数で作った設定オブジェクトを利用して、必要な数だけdocker環境を立ち上げる。 docker_compose_down関数は立ち上げていたdocker環境を停止するために使う。

これを使ってサーバ起動用スクリプトstartup-serversとして用意した。

#!/usr/bin/env python3
from myapp.environment import load_e2e_config, docker_compose_up

if __name__ == '__main__':
    config = load_e2e_config('./e2e.config.yml')
    docker_compose_up(config)

同じくサーバ停止用スクリプトhalt-serversとして用意した。

#!/usr/bin/env python3
from todo.environment import load_e2e_config, docker_compose_down

if __name__ == '__main__':
    config = load_e2e_config('./e2e.config.yml')
    docker_compose_down(config)

テストケースのコードではsetUpメソッドでload_e2e_config関数を使って設定ファイルを読み込み、PYTEST_XDIST_WORKER環境変数から自身のワーカーIDを取得して、接続先のWeb、DB、Redisのポートを取り出して使うようにした。

    def setUp(self):
        target_port = '8080'
        db_port = '5432'
        redis_port = '6379'

        worker = os.environ.get('PYTEST_XDIST_WORKER')
        if worker is not None:
            config = load_e2e_config(E2E_CONFIG_PATH)
            c = config[worker]
            target_port = str(c['web_port'])
            db_port = str(c['db_port'])
            redis_port = str(c['redis_port'])

テストの実行

用意したスクリプトと設定で、テストの実行を行った。

$ ./startup-servers
$ HEADLESS=1 py.test -n 4 test
$ ./halt-servers

並列化なしで12分位だった実行時間が、ワーカー数2つの並列化で6分位、ワーカー数4つで3分位で終わるようになった。

ちなみに使ったWebDriverはChromeだけで、FirefoxIE、Edgeは試していない。 Firefoxは大丈夫そうな気がするけど、IEとEdgeは駄目なんじゃないかと予想している。

まとめ

並列化して実行時間をいい感じに縮められた。

前回の参考リポジトリにも反映しておいたけど、こちらはそもそもDBとか使わないやつなのであまり恩恵はない。 とりあえずテンプレート用に入れておいた。

github.com

こちらは並列化なしの結果が次のような感じ。

$ HEADLESS=1 py.test todo/                     
============================================================== test session starts ===============================================================
platform linux -- Python 3.7.1, pytest-4.2.1, py-1.8.0, pluggy-0.9.0
Using --randomly-seed=1551505883
rootdir: /work/my-todo-2019-early/e2e, inifile:
plugins: xdist-1.26.1, randomly-1.2.3, forked-1.0.2
collected 5 items                                                                                                                                

todo/specs/todos_test.py .....                                                                                                             [100%]

=========================================================== 5 passed in 16.32 seconds ============================================================

並列化あり(ワーカー数4つ)だと結果が次のような感じになる。

$ HEADLESS=1 py.test -n 4 todo/
============================================================== test session starts ===============================================================
platform linux -- Python 3.7.1, pytest-4.2.1, py-1.8.0, pluggy-0.9.0
Using --randomly-seed=1551505959
rootdir: /work/my-todo-2019-early/e2e, inifile:
plugins: xdist-1.26.1, randomly-1.2.3, forked-1.0.2
gw0 [5] / gw1 [5] / gw2 [5] / gw3 [5]
.....                                                                                                                                      [100%]
============================================================ 5 passed in 6.59 seconds ============================================================