RaspbianのrootfsをReadOnlyにした
fsprotectとroot-ro
Raspberry PIでRaspbianを使っている時、rootfsのReadOnly化については、fsprotectを使った方法を過去に試したことがあった。
fsprotectはdebianパッケージとしてインストールして、/boot/cmdline.txt
にfsprotect
パラメータを追加するだけで設定できるのだけど、
Raspbianではaufsのパッチを当ててカーネルを再構築する必要があり、非力なRaspberry PIでカーネルビルドするの辛いのでPCでクロスコンパイルってことになって、前提条件を達成する作業が結構大変だった。
で、他の方法を調べていたら、最近root-roというものを知った。
root-roはoverlayfsを利用しているので、カーネルの再構築が不要で簡単に導入できる。
ただ、/boot/config.txt
にinitramfs initrd.gz
を設定して、initramfsで起動する = ReadOnly
、initramfsで起動しない = 通常
となっていて、
このあたりは常に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
作ったのはこれ。
仕組みはoverlayfsを利用してinitramfsでrootfsのマウントをtmpfsを使ったupperdir、lowerdir、workdirを組み合わせたものに取り替えるというもの。
ReadOnlyの有効/無効の切り替えはcmdlineパラメータにreadonlyfs
があるかどうかで行う。
とりあえずこれを適用したものとしていないものを用意して、Diskへの書き込みをログ取りしてみた。
ReadOnly化していないRaspbianでは次のようになり、Diskへの書き込みが発生していることがわかる。
ReadOnly化したRaspbianでは次のようになり、Diskへの書き込みが発生していないことがわかる。
最低限の実装なので、tmpfsのサイズ決めたり、/boot
パーティションをReadOnlyにする機能はつけていない。
とりあえずこれでしばらく様子見。
RaspbianのOSイメージのパーティションを分割した
動機
公式で配布されているRaspbian OSイメージはbootとrootfsのパーティションのみで構成されていて、 Raspberry PIに入れて起動するとrootfsが自動でSDカード全体まで拡張される。
そのまま常時稼働で運用していると、OSのログやtmpやswapの利用でSDカードへの書き込みがチクチク行われて、 NANDフラッシュの書き込み上限を迎えてある日ディスクが壊れることになる。
NANDフラッシュはSLC、MLC、TLCで書き込み上限が異なるので、なるべく上限の多いタイプを選ぶべきだけど、 それでも上限があることには変わりないし、工業用の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/loop0p3
をEXT4でフォーマットする。
$ 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
に追加したパーティションがマウントされていることが確認できる。
まとめ
とりあえず分割までできた。
この作業をいちいち手でやるのが面倒になったので、作成用スクリプト作った。
$ 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のソースを読んだ。
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の状態管理用のライブラリ作った
作ったのはこれ。
動機
普段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のエピソードで取り上げられているのを聴いて、ちょっと興味が湧いたので勉強してみた。
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での分割統治はできるようにしておこうということでcombine
とreducer
という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でa
とb
の状態を参照している)。
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引数で指定したsleep
APIを持つオブジェクトを受け取って使用している。
まとめ
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.WebDriverWait
のpoll_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_PORT
、DB_PORT
、REDIS_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
環境変数が渡される。
ここから、gw0
やgw1
といった自分のワーカー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_port
、redis_port
、db_port
を記述できるようにした。
そして、この設定ファイルを読み込んで使うためのAPIをenvironment.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だけで、FirefoxとIE、Edgeは試していない。 Firefoxは大丈夫そうな気がするけど、IEとEdgeは駄目なんじゃないかと予想している。
まとめ
並列化して実行時間をいい感じに縮められた。
前回の参考リポジトリにも反映しておいたけど、こちらはそもそもDBとか使わないやつなのであまり恩恵はない。 とりあえずテンプレート用に入れておいた。
こちらは並列化なしの結果が次のような感じ。
$ 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アプリケーションを作りながらまとめた。
前置き
自分が普段開発しているWebフロントエンドのボリュームは2,3万行くらいのコード量のSingle Page Application。 一般公開されるWebサービスではなくて、ログインして使う業務アプリケーションみたいなのが多め。 Web API叩いて必要なデータをバックエンドとやりとりしながら、JavaScriptで動的にすべての画面を描いていく感じ。 初期描画のパフォーマンス要求とかは今のところそこまで気にしなくていい程度のもの。 それをフロントエンドチーム(自分一人)で開発・メンテしてる。
開発スタイルはLinuxデスクトップで、フルスクリーン表示したターミナルをtmuxで画面分割して、コマンド叩きつつvimでコード書いてる。 動作確認はChromeでDevTools開きながらやって、大枠できてから他のブラウザで確認する感じ。
そんな人間の環境なので、これだとできないことがあるとか、チームでやったらやりにくい、うまくいかないとかそういうのはあると思う。
偏りがあるのはさておき、とりあえずJavaScript、CSS、開発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-assert
とreact-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.scss
とsrc/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.js
にpower-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テストはコスト高いけど、手作業でする簡単な動作確認を自動で繰り返しできるようにしておく程度のモチベーションで書いている。
SeleniumはJavaScriptで使うと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.js
でreact-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ベッタリになった。
こうして書き出してみると、偏った環境でやっているせいか、甘えてゆるふわになっている部分が結構ある気がする。 必要に応じてそのあたりを引き締めていくのが今後の課題となりそう。
とりあえず色んな動向をうかがいつつ、自分のペースで調整していくつもり。
Selenium WebDriverでテスト書くのにAsync/Awaitを使ってみた
E2Eテストを書くときにSelenium WebDriverを使っているんだけど、テストをJavaScriptで書くと、下みたいな感じでPromiseのチェーンだらけになるのがあまり好みじゃなかった。
const assert = require('power-assert'); const chrome = require('selenium-webdriver/chrome'); const {Builder, By, Key} = require('selenium-webdriver'); describe('Promiseベースで書いたテスト', function () { this.timeout(20000); let driver; beforeEach(() => { driver = new Builder() .forBrowser('chrome') .setChromeOptions(new chrome.Options().headless()) .build(); }); afterEach(() => { driver.quit(); }); it('then,then,then', () => { return driver.get('https://www.google.com').then(() => { driver.findElement(By.name('q')).sendKeys('webdriver'); return driver.sleep(1000); }).then(() => { driver.findElement(By.name('q')).sendKeys(Key.TAB); driver.findElement(By.name('btnK')).click(); return driver.sleep(1000); }).then(() => { return driver.getTitle(); }).then((title) => { assert(title === 'webdriver - Google 検索'); }); }); });
なので、普段はPythonとか別の言語で書いていた。 ECMAScript2017でAsync/Await入るの見た時に、雰囲気変わるかなーと思いつつ試さずに放置してたので、最近はじめた気分転換のついでに試してみた。
こんな感じになった。
const assert = require('power-assert'); const chrome = require('selenium-webdriver/chrome'); const {Builder, By, Key} = require('selenium-webdriver'); describe('Async/Awaitで書いたテスト', function () { this.timeout(20000); let driver; beforeEach(() => { driver = new Builder() .forBrowser('chrome') .setChromeOptions(new chrome.Options().headless()) .build(); }); afterEach(() => { driver.quit(); }); it('await,await,await', async () => { await driver.get('https://www.google.com'); driver.findElement(By.name('q')).sendKeys('webdriver'); await driver.sleep(1000); driver.findElement(By.name('q')).sendKeys(Key.TAB); driver.findElement(By.name('btnK')).click(); await driver.sleep(1000); const title = await driver.getTitle(); assert(title === 'webdriver - Google 検索'); }); });
多少マシになった感じ?
ちなみにPythonで書くとこんな感じ。
import unittest from selenium import webdriver from selenium.webdriver.common.keys import Keys import os import time class GoogleSearch(unittest.TestCase): def setUp(self): driver_path = os.path.join(os.path.dirname(__file__), 'chromedriver') options = webdriver.chrome.options.Options() options.add_argument('--headless') self.driver = webdriver.Chrome(executable_path=driver_path, chrome_options=options) def test_search_in_google_com(self): driver = self.driver driver.get('https://www.google.com') q = driver.find_element_by_name('q') q.send_keys('webdriver') time.sleep(1) q.send_keys(Keys.TAB) driver.find_element_by_name('btnK').click() time.sleep(1) assert driver.title == 'webdriver - Google 検索' def tearDown(self): self.driver.close() if __name__ == '__main__': unittest.main()
もっと込み入った感じのを書いてみないとなんとも言えないけど、ありかなー?どうかなー?って感じ。