ミルク色の記録

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

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 ============================================================

2019年現時点の自分のWebフロントエンド開発環境

数年前、年末にその時点での自分のWebフロントエンド開発まわりの環境を記録用にまとめていたけど、 それからすっかり怠けてしまい、去年の暮れには久しぶりにやろうと思っていたものの、気がついたらすっかり年が明けていた。

最近色々ずっとアレな感じでメンタル低空飛行気味なんだけど、このまま朽ち果てていくのは嫌なので、気分転換兼ねてまとめてみた。

今回はサンプルとしてTodoアプリケーションを作りながらまとめた。

github.com

前置き

自分が普段開発しているWebフロントエンドのボリュームは2,3万行くらいのコード量のSingle Page Application。 一般公開されるWebサービスではなくて、ログインして使う業務アプリケーションみたいなのが多め。 Web API叩いて必要なデータをバックエンドとやりとりしながら、JavaScriptで動的にすべての画面を描いていく感じ。 初期描画のパフォーマンス要求とかは今のところそこまで気にしなくていい程度のもの。 それをフロントエンドチーム(自分一人)で開発・メンテしてる。

開発スタイルはLinuxデスクトップで、フルスクリーン表示したターミナルをtmuxで画面分割して、コマンド叩きつつvimでコード書いてる。 動作確認はChromeでDevTools開きながらやって、大枠できてから他のブラウザで確認する感じ。

そんな人間の環境なので、これだとできないことがあるとか、チームでやったらやりにくい、うまくいかないとかそういうのはあると思う。

偏りがあるのはさておき、とりあえずJavaScriptCSS、開発Webサーバ、テストあたりについて順にまとめていく。

JavaScriptまわり

「FlowからTypeScriptへ移行した」みたいな記事をよく見かけるようになった昨今で、 @babel/preset-typescriptが出てきたというのもあり、先のこと考えたらTypeScriptに行っとくべきかなーと思いつつ、 とりあえずBabel + Flowで型付けたECMAScriptで書き続けている。

babel.config.jsの設定はこんな感じ。

module.exports = {
  'presets': [
    '@babel/preset-env',
    '@babel/preset-react',
    '@babel/preset-flow'
  ],
  'env': {
    'development': {
      'presets': [
        'power-assert'
      ]
    },
    'production': {
      'plugins': [
        [
          'react-remove-properties',
          {
            'properties': ['data-test']
          }
        ]
      ]
    }
  }
}

ViewのライブラリにReactを使ってるので@babel/preset-reactを追加している。power-assertreact-remove-propertiesに関しては、後でテストの方で説明する。

Flowで外部ライブラリの型定義を扱うときに、flow-typedを真面目には使っていなくて、必要に応じてリポジトリから取ってきて定義ファイルを設置してみているけど、ほとんどはdeclare module.exports: any;で握りつぶしている。

バンドルはwebpackのbabel-loader頼み。

webpack.config.jsのbabel-loader部分はこんな感じ。

    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          loader: 'babel-loader'
        },

特に変わったことはしていない。

コードフォーマットをPrettierにまかせていて、ESlintは細かく設定していない。

.eslintrc.jsonはこんな感じ。

{
  "env": {
    "browser": true,
    "commonjs": true,
    "es6": true,
    "node": true,
    "mocha": true
  },
  "globals": {
    "ENABLE_SERVICE_WORKER": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended"
  ],
  "parser": "babel-eslint",
  "parserOptions": {
    "ecmaFeatures": {
      "jsx": true
    },
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  "plugins": [
    "flowtype",
    "react"
  ],
  "rules": {
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  }
}

ReactとFlow使ってるぶんの設定があるくらい。no-unused-varsの検出できれば良いくらいの感覚で使っている。

実はコードフォーマット系は苦手意識があったけど、去年Elmの勉強した時にelm-format使ったらすごい楽で、 フォーマットに頭使わなくていいことの良さを思い知って、JavaScriptでもやるようになった。

Prettierはシングルクォートについて以外、特に指定してなくてnpm scriptで使うためにpackage.jsonに次のように設定している。

"scripts": {
    "lint": "eslint src",
    "format-js": "prettier --single-quote --write 'src/**/*.js'",

ファイル保存時に自動整形はやってなくて、gitコミットする前にフォーマットして、テストとか通るの確認してからコミットしてる。

CSSまわり

LESSをやめて、Sassをやめて、PostCSSを使うようにしているけど、Bootstrap依存症なのでSassも入れることになり、結局Sassで書いている。 Bootstrapが次のバージョンからPostCSSにするとか言ってる記事を見た気がするので、次バージョンあげたらSassはやめるかもしれない。

バンドルはwebpack頼み。昔はwebpackはJavaScriptのバンドルのみに使って、CSSまわりはgulpと色々組み合わせてやっていたけど、後で書く開発Webサーバのこともあり、もうwebpackで全部やってる。

Reactを使っているので、styled-componentsとかやったほうがいいのかなーと思いつつも、 そもそもデザインやCSS得意じゃないので、デザイナーが作ってくれたCSSを取り込みながらやるスタイルを想定してCSSは別にしてる。 スコープ問題とかで苦しみたくないし、BEM記法は性に合わないので色々工夫してみていたけど、ひとまずCSS Modulesってやつでやってみている。

webpack.config.jsのその辺りの設定は次の通り。

    module: {
      rules: [
        {
          test: /src\/app\.scss$/,
          use: [
            { loader: MiniCssExtractPlugin.loader },
            {
              loader: 'css-loader'
            },
            {
              loader: 'postcss-loader',
              options: {
                plugins: function () {
                  return [
                    require('precss'),
                    require('autoprefixer')
                  ];
                }
              }
            },
            {
              loader: 'sass-loader'
            }
          ]
        },
        {
          test: /\.(css|scss)$/,
          include: /src\/presentation/,
          use: [
            { loader: MiniCssExtractPlugin.loader },
            {
              loader: 'css-loader',
              options: {
                modules: true,
                localIdentName: mode === 'production' ?
                  '[hash:base64]' : '[path][name]-[local]-[hash:base64:5]'
              }
            },
            {
              loader: 'postcss-loader',
              options: {
                plugins: function () {
                  return [
                    require('precss'),
                    require('autoprefixer')
                  ];
                }
              }
            },
            {
              loader: 'sass-loader'
            }
          ]
        }

src/app.scsssrc/presentation配下の.(css|scss)で分けてルールを設定している。

src/app.scssの方は次のようになっていて、BootstrapやFontAwesomeのスタイルはCSS Modulesの対象外にしたいので、こんな分け方になっている。

@import "./scss/_variables.scss";
@import "~bootstrap/scss/bootstrap";
$fa-font-path: "../node_modules/@fortawesome/fontawesome-free/webfonts";
@import "~@fortawesome/fontawesome-free/scss/fontawesome.scss";
@import "~@fortawesome/fontawesome-free/scss/solid.scss";
@import "~@fortawesome/fontawesome-free/scss/brands.scss";
@import "~react-toastify/scss/main.scss";

アプリケーションのViewコンポーネントのスタイルはsrc/presentation配下にコンポーネントごとにディレクトリを切って、対になるJavaScriptファイルと並べておいている。

Header
├── Header.js
└── Header.scss

こちらはCSS Modulesの対象にしている。

例えばHeader.scssの内容は次のようになっている。

@import "../../../scss/_variables.scss";

.head {
  background-color: $header-bg-color;
  .sub {
    color: $brand-title-color;
    &:hover {
      color: $brand-title-color;
    }
  }
}

これをHeader.js側で次のようにimport style from './Header.scss';して使っている。

/* @flow */
import React from 'react';
import style from './Header.scss';
import '../../../images/icon.svg';

export const Header = () => {
  return (
    <nav className={`navbar navbar-light ${style.head}`}>
      <span className={`navbar-brand ${style.sub}`}>
        <img
          src="images/icon.svg"
          width="30"
          height="30"
          className="d-inline-block align-top"
          alt=""
        />{' '}
        Todo
      </span>
    </nav>
  );
};

mini-css-extract-pluginを使って、ビルドしたらcssファイルとして出力されるようにしているけど、webpackのバージョンが上がる度にこのあたりは躓いているので、毎回ヒヤヒヤする。

開発Webサーバ

以前はgulpの中でconnectとかlive-reloadとか使ってやっていた。今ではwebpack-dev-serverでWeb APIのプロキシとか、 History APIのフォールバックとか全部任せてやっている。

これとCSSの件を合わせて、gulpを完全にやめるきっかけとなり、npm scriptとwebpackでだいたい何とかするようになった。

webpack.config.jsのwebpack-dev-server周りの設定は次の通り。

      devServer: {
        contentBase: './src',
        inline: true,
        host: '0.0.0.0',
        port: 8080,
        disableHostCheck: true,
        historyApiFallback: true,
        stats: {
          version: false,
          hash: false,
          chunkModules: false
        }
      },
      devtool: 'source-map'

これをnpm startで起動できるようにpackage.jsonに次の通り設定している。

  "scripts": {
    "start": "webpack-dev-server",

テストまわり

単体テスト

Jestの話題を見かけるようになったなーと思いつつ、あいかわらずmochaとpower-assertでやってる。spyやstubが必要になったらsinonも使っている。 そのためbabel.config.jspower-assertの設定がしてある。

以前はtestファイルの置き場はアプリケーションとは別にtestディレクトリを切って、そちらにアプリケーションと同じ階層で置くようにしていた。 しかし、importの都合とかで最終的に面倒になってアプリケーションのファイルと同列に*.spec.jsとして置くようになった。

domain
├── Todo.js
└── Todo.spec.js

*.spec.jsファイルはESLintの対象にしたくないので、.eslintignoreファイルに次のように設定している。

src/**/*.spec.js

テストの実行はnpm testで行うため、package.jsonに次のように設定してある。

"scripts": {
    "test": "mocha --require src/testSetup.js --recursive './src/**/*.spec.js'",

テストもECMAScriptで書きたいので、mochaに渡している--requireパラメータのsrc/testSetup.jsファイルで次のようにしている。

require('@babel/register')();
require('@babel/polyfill');

テストの実行はNode環境でのみにしていて、ブラウザ環境で実行するKarmaやtestemは使わなくなった。 ViewのテストはEnzymeとjsdomで頑張ってみたこともあるけど、極力しないようになった。

AjaxなどのWEB API叩く系は、以前はfetch-mockを使ってfetchを差し替えたりしていたが、 今はアプリケーションのコードでfetchを直接使わずに一枚かぶせたインターフェイスを定義して、テストの時だけ差し替えるようにしている。

例えば、今回のTodoアプリケーションだとlocalStorageを直接使わずにStorageInterfaceのオブジェクトとして、 テストの時はMapをベースにしたMemoryStorageに差し替えている。

src/infrastructure/LocalStorageRepository.jsの次のあたり。

export class LocalStorageRepository implements TodoRepository {
  _storage: StorageInterface;

  constructor(storage: StorageInterface) {
    this._storage = storage;
  }

  // 中略
}

interface StorageInterface {
  +length: number;
  getItem(key: string): ?string;
  setItem(key: string, data: string): void;
  clear(): void;
  removeItem(key: string): void;
}

export class MemoryStorage implements StorageInterface {
  _map: Map<string, string>;

  constructor() {
    this._map = new Map();
  }

  get length(): number {
    return this._map.size;
  }

  getItem(key: string): ?string {
    return this._map.get(key);
  }

  setItem(key: string, data: string): void {
    this._map.set(key, data);
  }

  clear(): void {
    this._map.clear();
  }

  removeItem(key: string): void {
    this._map.delete(key);
  }

  key(index: number): ?string {
    return Array.from(this._map.keys())[index];
  }
}

E2Eテスト

Seleniumを使ってE2Eテストをわりと書いている。 E2Eテストはコスト高いけど、手作業でする簡単な動作確認を自動で繰り返しできるようにしておく程度のモチベーションで書いている。

SeleniumJavaScriptで使うとAPIが非同期系多すぎて書きづらいので、pythonで書いている。テストにはpy.testを使っている。

環境はe2eディレクトリ配下にまとめている。

e2e
├── drivers
│   ├── chromedriver
│   └── geckodriver
├── requirements.txt
├── todo
│   ├── __init__.py
│   ├── datas
│   ├── page_objects
│   └── specs
└── venv

driversディレクトリにはWebドライバーをダウンロードしてきたものを置く。ブラウザがアップデートするとドライバーが合わなくなるので、 都度更新できるように中身はgitignore対象にしてある。

テストケースはe2e/todo/specs下に置いてある。E2Eテストを書くときにドライバーでのDOMの取得をテストにベタ書きすると 後々非常に辛くなるので、ページオブジェクトパターンを使ってテストケースではDOMを直接触らずに、 ページオブジェクト経由でシナリオに沿って画面操作して取得した結果をアサーションにかけている。

ページオブジェクトはe2e/todo/page_objects配下に置いてあり、例えばTodo一覧のページオブジェクト(todo_page.py)は次のようになっている。

from . import PageObject

class TodoListPage(PageObject):

    @property
    def el(self):
        return self.driver.find_element_by_css_selector('div[data-test="todo-list"]')

    def wait_show_todo_list(self):
        self._wait_show('div[data-test="todo-list"]')
        return self

    def wait_loading_complete(self):
        return self._wait_loading_complete(self.el)

    def get_todo_rows(self):
        rows = self.el.find_elements_by_css_selector('tr[data-test="todo-list-row"]')
        return list(map(lambda el: TodoListRow(self.driver, el), rows))

    def click_create_todo_button(self):
        b = self.el.find_element_by_css_selector('a[data-test="create-todo-button"]')
        b.click()
        return TodoFormPage(self.driver)

    def click_all_tab(self):
        t = self.el.find_element_by_css_selector('a[data-test="all-tab"]')
        t.click()
        return self

    def click_active_tab(self):
        t = self.el.find_element_by_css_selector('a[data-test="active-tab"]')
        t.click()
        return self

    def click_completed_tab(self):
        t = self.el.find_element_by_css_selector('a[data-test="completed-tab"]')
        t.click()
        return self

これを使ったTodo一覧の表示確認用のテスト(todos_test.py)は次の通り。IndexPageのopenでTodoListPageが取得できるようになっている。

class TodosTest(E2ETest):

    def test_show_todo_list_rows(self):
        p = IndexPage(self.driver, self.target_host)\
                .open()

        init_data(self.driver)

        p = p.reload()\
                .wait_show_todo_list()

        rows = p.get_todo_rows()
        assert len(rows) == 3
        r1, r2, r3 = rows;
        assert r1.get_title() == 't1'
        assert r2.get_title() == 't2'
        assert r3.get_title() == 't3'

画面のHTMLの構造が変わっても、機能自体が変わっていなければ、ページオブジェクトの修正だけでテストは変えずにすませられる。 それと、テストケース間でページオブジェクトを使いまわせるので重宝している。

CSS Modulesを使っている関係上、DOMへのアクセスにセレクタCSSクラスを使いにくい(テスト用の余計なCSSクラスとか付けたくない) のでDOMにはdata-test属性を付けてアクセスできるようにしている。

TodoListのViewだと次のようにdata-test="todo-list"とかしている。

/* @flow */

// 省略

export class TodoList extends React.Component<Props> {

  // 省略
  
  render() {
    const { visibleRecords, loading, filter } = this.props.todos;
    const rows = visibleRecords.map((t, i) => {
      return <TodoRow key={i} todo={t} />;
    });
    return (
      <div className={styles.todoList} data-test="todo-list">
        <div className="card">
          <div className="card-header">
            <ul className="nav nav-tabs card-header-tabs">

// 省略

babel.config.jsreact-remove-propertiesを利用していて、productionビルドの時はdata-test属性が取り除かれるようにしてある。

ビルド用のタスクはpackage.jsonに次のように設定している。

  "scripts": {
    "clean": "rm -rf build",
    "build": "npm run clean && NODE_ENV=production webpack",
    "build-testing": "npm run clean && NODE_ENV=testing webpack",

npm run buildでビルドするとproductionビルドをする。 npm run build-testingでビルドするとdata-test属性がついたままのビルドになるので、E2Eテストの時はこちらを使う。

今回のサンプルアプリケーションではWEB APIを叩くことも無いし、バックエンドにDBやredisがあったりするわけでもないので、 npmで立ち上げたWebサーバにアクセスするだけにしているけど、 業務ではバックエンドをDockerで動かせるようにしておいて、docker-composeでサーバ環境を立ち上げて、 テスト実行ごとにDB初期化とかデータ投入とか色々やって動かしている。

フロントエンドチーム外のメンバーへの提供

フロントエンドのgitリポジトリをクローンしてもらって各自にビルドしてもらうやり方だと、普段フロントエンドの開発をしてないメンバーの環境ではNodeのバージョンが古かったり、npmのバージョンが古かったりして、「ビルドできないんだけど...」と言われることが多かった。

そこでDocker内でビルドできるようにビルド用のDockerイメージを作成するスクリプト(create_docker_image.sh)と、 そのイメージを使ってビルドするスクリプト(build_on_docker.sh)を同梱するようにしている。

ビルド用のDockerイメージを作成するcreate_docker_image.shは次のようにしてある。

#!/bin/sh
SCRIPT_DIR=$(cd $(dirname ${0}) && pwd)
cd "$SCRIPT_DIR/.."

DOCKER_IMAGE_NAME="nodejs10-for-frontend-build"
IMAGE=`docker images | awk '{print $1}' | grep $DOCKER_IMAGE_NAME`
if [ "$IMAGE" != "$DOCKER_IMAGE_NAME" ]; then
    docker build -t $DOCKER_IMAGE_NAME .
fi

作成するDockerイメージのDockerfileは次の通り。

FROM ubuntu:18.04

RUN apt-get update && \
    apt-get install -y locales && \
    locale-gen en_US.UTF-8

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

RUN sed -i.bak -e "s%http://archive.ubuntu.com%http://jp.archive.ubuntu.com%g" /etc/apt/sources.list && \
    apt-get update && \
    apt-get install -y unzip wget git curl gnupg && \
    curl -sL https://deb.nodesource.com/setup_10.x | bash - && \
    apt-get install -y nodejs && \
    mkdir /.npm && \
    chmod 777 /.npm && \
    mkdir /.config && \
    chmod -R 777 /.config

node用の公式イメージでも良さそうだけど、とりあえず昔からの流れでubuntuベースで作ってる。

Docker内でフロントエンドのビルドをするbuild_on_docker.shは次の通り。

#!/bin/sh

SCRIPT_DIR=$(cd $(dirname ${0}) && pwd)
cd "$SCRIPT_DIR/.."
DOCKER_IMAGE_NAME="nodejs10-for-frontend-build"
PROJECT_ROOT_DIR=`pwd`
USER_ID=`id -u`
GROUP_ID=`id -g`

RUN_ACTION=`cat << EOS
    cd /tmp/frontend && \
    npm install && \
    npm run build
EOS`
docker run -u $USER_ID:$GROUP_ID \
    -v $PROJECT_ROOT_DIR:/tmp/frontend:rw \
    --rm $DOCKER_IMAGE_NAME /bin/sh -c "$RUN_ACTION"
rm -rf Todo Todo.zip Todo-*.zip
mv build Todo
REV=`git rev-parse HEAD`
echo $REV > Todo/GIT_REVISION
zip Todo.zip -r Todo
cp Todo.zip "Todo-$REV.zip"

ビルドのついでにzipで固めることと、gitのリビジョンつけたzipも作るようにしてある。

とりあえずこれらを使ってもらうようにしたが、そもそもビルド済みのを配れるようにしておけば良いのでは...と思い、 JenkinsなどのCIでこのスクリプトを使ってビルドし、zipをs3とかにアップロードして配布している。

まとめ

昔と比べるとgulpを捨ててwebpackベッタリになった。

こうして書き出してみると、偏った環境でやっているせいか、甘えてゆるふわになっている部分が結構ある気がする。 必要に応じてそのあたりを引き締めていくのが今後の課題となりそう。

とりあえず色んな動向をうかがいつつ、自分のペースで調整していくつもり。