🪆

ラズベリーパイをラズベリーパイで焼く仕組みを作った

2023/01/04に公開

ラズベリーパイの初期設定をするラズベリーパイを作りました。
といっても見た目にインパクトがあるわけではありませんが...

(上のRaspberryPi 3Aが本記事の対象マシンです)

以下のようなデーモンが動いており、USB接続を自動で感知&SDカードに書き込みを行ってくれます。

※Cloud-initのバグについては参考を確認してください

概要

目的

ラズパイをサーバーとしてセットアップする際、色々面倒くさいです。
ubuntu serverを初期設定する手順をざっと羅列するだけでも...

  1. SDカードをSDカードリーダーに差し込む
  2. RaspberryPi Imagerを使ってOSを選択
  3. 管理者権限ウィンドウにて書き込みを許可
    (SDカードにOSを書き込み)
  4. SDカードを再挿入(ソフトウェアがアンマウントを実行するため)
  5. network-configを設定(ヘッドレスインストールですぐssh接続できるようにするため)
  6. user-dataを設定(ssh鍵認証の有効化を初期段階で実施するため)
  7. SDカードをラズパイに差す
  8. ラズパイを起動

1台ならともかく複数台設定する場合にいちいちこの操作をするのは面倒くさいため、
これを以下のような手順で、SDカードを差し込んだらすべてが完了するようにしてしまうことが目的です。

  1. SDカードをSDカードリーダーに差し込む
    (OSの書きこみ・あらかじめ用意されたnetwork-config/user-dataを自動で書き込み)
  2. SDカードをラズパイに差す
  3. ラズパイを起動

実施環境

  • RaspberryPi 3A+
    • OS: Ubuntu server 22.04.1 LTS (64 bit)
    • 8GB SD card
root@ubuntu:~# python3 --version
Python 3.10.6

成果物

リポジトリは以下になります。

フィジビリティ確認のみでテストをしていないのでreleaseはしていません。
README.mdにインストール・設定・アンインストール方法を記載しています。
https://github.com/nkte8/pisdwriter

技術的な話

細かなところは既存の技術の寄せ集めです。本記事ではjinja2とcallback関数について触れておきます。

初期設定について

jinja2の利用

設定値の作成はjinja2 templateを利用しました。ansibleではおなじみです。
こちらはpythonから呼び出して利用することができます。

jinja2_writter.py
# -*- coding: utf-8 -*-
from PiSDWriter import global_vars as g
import jinja2, yaml, subprocess, os
# 設定値の読み込み部分
def load_vars():
    main_config_path = g.conf_dir + '/main.yml'
    wifi_config_path = g.conf_dir + '/wifi.yml'
    config = yaml.safe_load(open(main_config_path)) # 辞書型として格納される

# ...(wifi読み込み部分。省略)...

    return config 
#...(中略)...
# ファイルの書き込み部分
def write_config(file_name, outconf_path):
    templates_path = g.template_dir # templateまでのフルパス
    os.makedirs(outconf_path, exist_ok=True)

    fileSystemLoader = jinja2.FileSystemLoader(searchpath=templates_path) # FileSystemLoaderを用意
    env = jinja2.Environment(loader=fileSystemLoader) # FileSystemLoaderを用いてrendererを用意
    template = env.get_template(file_name+'.j2') # templateをロード

    template_vars = load_vars() # dictを格納

    with open(outconf_path + '/' + file_name, 'w') as file:
        file.write(template.render(template_vars))  # dictを用いてレンダリングした結果を書き込み
    print("config: " + file_name + " is ready")

本プログラムでの実装

設定をjinja2を用いて作成している部分がミソです。このためユーザーが作成する設定はキーと値のセットのみになります。
本プログラムでは以下のような設定ファイルを用います。

configs/main.yml
gateway_addr: 192.168.3.1 #デフォルトゲートウェイ
nameserver_addrs: #DNSサーバ 2台まで設定可能
  - 8.8.8.8
  - 8.8.4.4
authorized_keys: #初期設定ユーザの公開鍵を直接記載
  - ssh-XXXXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
ip_address: 192.168.3.XX #IPアドレス

今のところIPアドレスはファーストアクセス後に変更する前提のため、SDカードを焼いたら固定のIPアドレスが付与されます。
ユーザー名も同じ理由で固定していますが、インストール時に作成されるtemplates内のjinjaを設定することで変数の定義・カスタマイズができます。

templates/user-data.j2
#...(省略)...
hostname: ubuntu
timezone: "Asia/Tokyo"
locale: "ja_JP.UTF-8"

package_update: true
package_upgrade: true

password: ubuntu
chpasswd:
  expire: false
ssh_pwauth: false
ssh_authorized_keys: {{ authorized_keys }}

write_files:
  - path: /boot/firmware/config.txt
    append: true
    content: |
      dtparam=audio=on
      dtparam=act_led_trigger=default-on
      dtparam=pwr_led_trigger=panic

runcmd:
  - sed -i -e 's/$/ cgroup_enable=memory cgroup_memory=1/' /boot/firmware/cmdline.txt
  - touch /etc/cloud/cloud-init.disabled
  - reboot

実行部分

subprocessを用いてひたすら処理を行う仕組みになっています。

callback関数について

コールバック関数は、関数の引数に関数(厳密には関数のポインタ)を利用することで、関数内で関数を呼び出せる仕組みです。
pythonでは次のようにして利用することができます。

def example_add(b,c):
    return b + c

def example_func(a, function, *args):
    print(a)
    print("{0} + {1} = ".format(args[0],args[1]))
    # print("{0} + {1} = ".format(*args)) でもよい
    return function(*args)

callback_func = example_add 
example_func("add arguments", callback_func, 2, 3)

上記の結果では以下の出力を得られます。

>>> callback_func = example_add
>>> example_func("add arguments", callback_func, 2, 3)
add arguments  # example_funcのprint出力
2 + 3 =    # example_funcのprint出力
5          # example_func内のfunction = callback_func = example_addの結果出力

本プログラムでの実装

本プログラムではマウント/アンマウントの部分に用いています。
Cloud-initの更新と設定値(network-config,user-data)の書き込みについて、
マウント処理が共通しているためです。

sd_writer.py
# -*- coding: utf-8 -*-
import subprocess, os, tempfile, re, pathlib
from tqdm import tqdm

# ...(省略)...
def __mount_and_write_sdcard(device_node, function, *args):
# ...(マウント処理)...
    
    function(dirpath, *args)

# ...(アンマウント処理)...

def __open_gz_copy_to_sdcard(mntdir, dest, src, pattern):
# ...(subprocessで tar展開やcpを実行)...

def __open_conf_copy_to_sdcard(mntdir, dest, src):
# ...(subprocessでcpを実行)

## cloud-initの書き込みを行う関数の呼び出し部分
def write_cloudinit_to_sdcard(device_node, dest, src, pattern):
    if os.path.isfile(src):
        callback = __open_gz_copy_to_sdcard ## callback変数に__open_gz_copy_to_sdcardを代入
        __mount_and_write_sdcard(device_node,callback,dest,src,pattern)

## 各種設定の書き込みを行う関数の呼び出し部分
def write_configs_to_sdcard(device_node,dest,*src):
    callback = __open_conf_copy_to_sdcard  ## callback変数に __open_conf_copy_to_sdcardを代入
    __mount_and_write_sdcard(device_node,callback,dest,*src)

複雑そうに見えますが、実際のところは

  • write_cloudinit_to_sdcardが呼び出された場合

    • __mount_and_write_sdcardが実行される
    • __mount_and_write_sdcardの処理中、__open_gz_copy_to_sdcardが呼び出される
    • callback関数終了後、__mount_and_write_sdcardの残りの処理が実行される
  • write_configs_to_sdcardが呼び出された場合

    • __mount_and_write_sdcardが実行される
    • __mount_and_write_sdcardの処理中、__open_conf_copy_to_sdcardが呼び出される
    • callback関数終了後、__mount_and_write_sdcardの残りの処理が実行される

という具合の処理を行っています。

インストールに関して

パッケージのインストーラとしてsetup.pyを利用しています。
モジュール外のファイルの追加については[options.package_data]で行っています。

以下のファイル構成をしている時

/
    src/
        PiSDWriter/
            configs/
                ***.yml
            templates/
                ****
            hoge.py
            fuga.py
            main.py
            __init__.py
    README
    setup.py
    setup.cfg

以下のような設定を行っています。

setup.cfg
[metadata]
name = PiSDWriter
version = 0.0.1
description = write RaspberryPi SD easiry
license='MIT'

[options]
install_requires =
    jinja2
    pyyaml
    requests
    tqdm
    pyudev
packages = find:
package_dir=
    =src
zip_safe = False

[options.package_data]
PiSDWriter = 
    configs/*.template
    templates/*

[options.packages.find]
where=src

[options.entry_points]
console_scripts =
  pisdwriter = PiSDWriter.main:main

[options.entry_points]を設定することでパスを自動で通してくれます。

今後の課題

  • ユーザー設定の作成難易度を下げたい
    • ウィザード形式のセットアップ画面があってもいいかもしれない
  • IPアドレスのマネジメント
    • SDカードを差し込む際にIPアドレスを何かしらの形で設定出来たらうれしい
    • せっかくRaspberryPiなのでGPIOを用いた入力デバイスがあってもいいかもしれない
  • 終了通知
    • 現在はSDカードリーダーの光が点滅から点灯になることで終了を認識
    • 時間がかかる処理ではないが、書き込んだこと自体を通知できたらもっと便利かも?
      • 追記: 2023/11/04 LINE Notification APIを用いて通知を送信できるようにした。

終わりに

ラズパイで中規模のクラスタを作る人は、SDカードの初期セットアップに苦労されると思います(実際してました)
これでUIを触ることなくRaspberryPiをセットアップできます。

参考

オフィシャルイメージのCloud-initでは、Wifiを初回起動時に設定できないバグが存在する。
(この現象はUbuntu20.4、22.4のいずれのLTSで確認しています。いずれも最新版のCloud-initのインストールで解消する模様)
https://bugs.launchpad.net/ubuntu/+source/cloud-init/+bug/1870346
https://github.com/kubernetes-sigs/image-builder/issues/712

Discussion