ヒスねこTechBlog

日々の気になる技術をまとめてます。

gRPCをラズパイ3+Pythonで試す

gRPCを試してみました。

準備

gRPC試したいだけなら環境は正味なんでもよいと思いますが、今回はラズパイ3でPython製サーバを立ててみます。

まずはPythonの開発環境を整えます。Raspberry Pi OSは標準でPythonが入っていましたが、pipは入っていませんでした。gRPCでの開発に必要なパッケージがpipで入るので、導入しておきます。

$ sudo apt install python3-pip
$ python -m pip install --upgrade pip

次に、gRPCのパッケージをインストールします。

$ python -m pip install grpcio
$ python -m pip install grpcio-tools
protoの作成

これで準備は整ったので、protoファイルを作成してスタブを生成したのち、サーバ/クライアントのプログラムを作成すればgRPCによるやりとりができるそう。まずはprotoファイルから。sample.protoとして作成しました。

syntax = "proto3";

package sample;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

GreeterサービスにSayHelloメソッドを持たせます。このメソッドは、string型のnameを持つHelloRequestメッセージを受け取り、同じくstring型のmessageを持つHelloReplyメッセージを返します。

nameやmessageといったフィールドに「=1」とあるのは、代入ではなくタグナンバーの割当てだそうで、数字はメッセージ内で重複してはいけないとのこと。長くプログラムを書いているとnameやmessageに1が入るように錯覚しますね。私は初見でちょっと混乱しました。

サーバ/クライアントプログラムで使用するスタブを生成します。今回作成した程度の規模であれば、生成されたPythonファイルもあまり大きくないので、試しに中身を確認してみてもいいかもしれません。

$ python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. ./sample.proto
$ ls
# 左の2つが生成された
sample_pb2_grpc.py  sample_pb2.py  sample.proto
サーバ側の作成

gRPCにconcurrent.futuresモジュールのThreadPoolExecutorを渡してあげる必要があるので、importを忘れないようにしてください。ファイル名はserver.pyとしました。

from concurrent import futures
import grpc
import sample_pb2
import sample_pb2_grpc

class Sample(sample_pb2_grpc.GreeterServicer):
    def SayHello(self, request, context):
        message = "Hello %s." % request.name
        return sample_pb2.HelloReply(message=message)

def run_server():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=5))
    sample_pb2_grpc.add_GreeterServicer_to_server(Sample(), server)
    server.add_insecure_port('[::]:8080')
    server.start()
    server.wait_for_termination()

if __name__ == '__main__':
    run_server()

max_workersやポート番号などはお好みで設定してください。なおadd_insecure_port()は平文での接続になるため、SSL/TLSによる暗号化をしたい場合はadd_secure_port()を使うとのこと。こちらは後日改めて試そうと思います。

クライアント側の作成

次にクライアント側を作りますが、ポート番号はサーバ側と一致させ、リクエストの内容(name)はお好みで。ファイル名はclient.pyとしました。

import grpc
import sample_pb2
import sample_pb2_grpc

def run_client():
    with grpc.insecure_channel('localhost:8080') as ch:
        stub = sample_pb2_grpc.GreeterStub(ch)
        reply = stub.SayHello(sample_pb2.HelloRequest(name='Bob'))
    print("Reply: %s" % reply.message)

if __name__ == '__main__':
    run_client()
動作確認

以下でサーバとクライアントをそれぞれ実行します。クライアントは別端末で実行することを推奨します。確認できたらサーバ側はCtrl+Cで停止してください。

# 端末1
$ python ./server.py
# 端末2
$ python ./client.py
Reply: Hello Bob.

Raspberry Pi 3にQEMUを入れたりlibvirtを試したり

ARMv8から仮想化支援機能が実装されているので、Raspberry Pi 3(Cortex-A53コア)以降はKVMが使用可能とのこと。前々から気になっていたlibvirtのお試しもかねて導入してみました。

HW準備

ラズパイは4まで出ていることはもちろん知っているんですが、昨今の半導体不足もあって入手できておらず。とりあえず家に転がっていた3で試します。

それから、せっかくの機会なのでUSBブートでSSDに置いたOSを起動してみます。今回はバッファロー製のものを使用しました。これも家に転がってた。

OSセットアップ

Raspberry Pi OSは64bit版でないとKVM対応していない(おそらく互換性の問題で仮想化支援命令を使えない)。よって64bit版をインストール。

Raspberry Pi Imagerを使用して、PCにUSB接続したSSDのフォーマットとOS書込みを行います。

www.raspberrypi.com

このとき、OS選択後に出てくる右下の歯車マーク(Advanced options)を押して、以下の項目を設定しておきました。ラズパイ出始めの時と比べると、圧倒的に環境構築しやすくなっていて驚きです。

  • Set hostname
  • Enable SSH
  • Set username and password
  • Set locale settings

Raspberry Pi Imager

ラズパイにSSDをUSB接続して電源投入。問題なく起動することが確認できたらSSH接続も試します。「ssh ユーザ名@ホスト名」でPCから接続できるはず。

QEMUとlibvirtの導入

次にQEMUを入れました。

$ sudo apt update
$ sudo apt upgrade
$ sudo apt install qemu-system

この時点ではlibvirtは入っていない。virshも触ってみたかったので以下で導入。

$ sudo apt install libvirt-clients virtinst

# 動作確認
$ virsh list
 Id   Name   State
--------------------
仮想マシンの作成

仮想マシン起動のお試しとして、QCOW2(QEMUのディスクイメージ)を作成し、Alpine Linuxのaarch64版を立ち上げてみました。

$ mkdir images
$ cd images

# Alpine LinuxのISO入手(バージョンは適宜選択のこと)
$ wget https://dl-cdn.alpinelinux.org/alpine/v3.16/releases/aarch64/alpine-standard-3.16.0-aarch64.iso

# ディスクイメージと仮想マシンの作成
$ qemu-img create -f qcow2 alpine.qcow2 8G
$ virt-install --name alpine --ram 512 --disk=alpine.qcow2,bus=virtio --boot hd --cdrom alpine-standard-3.16.0-aarch64.iso -v

しばらく待つとAlpine Linuxが立ち上がります(RAM割り当てに失敗する場合は--ramの引数を小さくすること)。

ディスクイメージにAlpineをインストールして再起動までしようと思ったけれど、いったんここまで。

tarを展開する(おためし)

よく発作的に、何かのファイルフォーマットだとかプロトコルの中身が気になったりします。きっと持病なんだと思います。

Dockerイメージの話の絡みで、tarってどうなっているんだろうと考え始めてしまいました。

アーカイブとしてファイルをまとめただけですよ、という説明はよく見るんですが、「いやいや単にくっつけているわけじゃないでしょ?」と思ってフォーマットを眺めて、結構手軽に展開できそうだなと思ってしまったのが最後、気が付いたらちょっとだけですがコードを書いてました。

仕様のほとんどに準じていない実験用コードなのであしからず。

github.com

チェックサムの計算がどうも256だけ足りずに合わないんですが、自分が仕様を誤解している気がします...

そちらは追々解決するとして、フォーマット眺めたりプログラムを作っていると色々気が付いて面白いです。ヘッダだけではなくアラインメントやゼロのブロックがたくさんついていて、まあまあファイルサイズが膨らんでしまうんですね。然るべきアルゴリズムで圧縮されていれば、結局元のサイズよりは小さくなったりするのでしょうけれど。

サイズやチェックサムが単純に16進数なのではなく、8進数のASCII表現なのも興味深いです。この仕様になった経緯もGNU tarを時間があるときに眺めて確認しようと思います。

DockerイメージをOverlayFSでマウントする

前回記事で、Dockerイメージ内に含まれるmanifest.jsonがレイヤ構成を教えてくれることがわかりました。 このレイヤ構成の情報を使用して、OverlayFSでマウントしてみます。

techblog.hys-neko-lab.com

今回やることの概要

OverlayFSでDockerイメージをマウントします。必要な作業としては、

  1. イメージのtarを展開する
  2. マウントするディレクトリ(lowerdir, upperdir, workdir, mergeddir)を準備する
  3. manifest.jsonからレイヤ情報を抜き出す
  4. 抜き出した情報をもとに、順番にレイヤをlowerdirへ展開する
  5. 2で作成したディレクトリをOverlayFSでマウントする

となるかと思います。

Dockerイメージに含まれていたレイヤは、lowerdirとしてマウントします。mergeddirが、実際に私たちが見て、ファイルの追加/変更/削除を行うことになるディレクトリです。upperdirはmergeddirに施した変更が反映されるディレクトリになります。workdirはシステムの内部作業用とのことで、ディレクトリの用意は必要ですが、基本的に気にする必要はなさそうです。

OverlayFSでのマウント

jsonの解析のため、jqを導入してみました。まだまだ使いこなせていないですが、慣れたら便利そう。

sudo apt install jq

イメージのtarをOverlayFSでマウントするシェルスクリプト(mount.sh)を書いてみました。lowerdirはレイヤごとに分けて作成するようにしています。 最後のmountコマンドのlowerdir引数には、上のレイヤから指定する必要があります。したがって、本スクリプトでも上のレイヤから順に展開しています。

#!/bin/bash
#[Usage] ./mount.sh yourImageName.tar
LOWERDIR=""

# イメージのtarを展開
EXTRACT_DIR=${1%.*}
mkdir -p $EXTRACT_DIR
tar xvf $1 -C $EXTRACT_DIR

# レイヤ数の取得
LOWER_LAYER_NUM=$(cat $EXTRACT_DIR/manifest.json | jq -r '.[].Layers | length')
i=$(expr $LOWER_LAYER_NUM - 1)

while [ $i -ge 0 ]
do
    # 各レイヤのtarをlowerNディレクトリに展開
    mkdir -p lower$i
    QUERY=".[].Layers | .[$i]"
    LOWER_LAYER_TAR=$(cat $EXTRACT_DIR/manifest.json | jq -r "$QUERY")
    tar xvf $EXTRACT_DIR/$LOWER_LAYER_TAR -C lower$i

    # マウント時の引数(lowerdir)を作成
    LOWERDIR+="lower$i:"

    i=$(expr $i - 1)
done

# マウント用にディレクトリ作成
mkdir -p {upper,work,merged}

# OverlayFSでマウント
# (lowerdirは上のレイヤから指定することに注意)
sudo mount \
    -t overlay overlay \
    -o lowerdir=${LOWERDIR/%?/},upperdir=upper,workdir=work merged

スクリプトを実行してmergedディレクトリの中身を見ると、前回作成したファイル(hello.txt, hoge.txt)が確認できます。

$ chmod +x mount.sh
$ ./mount.sh testimage2.tar
$ ls merged
hello.txt hoge.txt
# マウントを解除する場合は以下を実行
$ sudo umount merged

次回はマウント先をルートディレクトリとしてプロセス実行し、コンテナっぽいことをさせてみようと思います。

Dockerイメージの観察

Dockerイメージの中身を簡単に見てみました。

イメージのビルド

調査用であまり複雑なイメージを使いたくなかったので、scratchからビルドすることにしました。

まず適当なファイル(hello.txt)を用意します。

Hello, world!

次にDockerfileを作成。

FROM scratch
ADD hello.txt /hello.txt

同ディレクトリ内で、イメージのビルドを行い、tarで保存します。

$ docker build -t testuser/testimage:v1 .
$ docker save -o testimage.tar testuser/testimage
イメージの観察

適当にディレクトリを掘って、そこにtarを展開してみます。

$ mkdir testimage && cd testimage
$ tar xvf ../testimage.tar
$ tree
.
├── d38166f2a4f5f18ce19da70292c3b6bd6f46d97ea73b8fd7937c28a19e94aa6c
│   ├── VERSION
│   ├── json
│   └── layer.tar
├── ea17a750c30e2a58a7697df587f1e033becfacc9d7ab628ec33fe331f5ff8b1d.json
├── manifest.json
└── repositories

このd38166~配下のlayer.tarを展開すると、そのレイヤに含まれるファイル、つまりhello.txtが取得できます。

レイヤをもう一層重ねる

もう一層重ねてみます。別のディレクトリを作成し、適当なファイル(hoge.txt)を置きます。

hogehoge

Dockerfileを次のように記載します。

FROM testuser/testimage:v1
ADD hoge.txt /hoge.txt

これで先ほど作成したイメージにさらにレイヤを重ねた、新たなイメージが作成されます。

$ docker build -t testuser/testimage:v2 .
$ docker save -o testimage2.tar testuser/testimage:v2
$ mkdir testimage2 && cd testimage2
$ tar xvf ../testimage2.tar
$ tree
.
├── a1ac6e668a69447918ad829196eba5a04490122120eb813b4c4fca3ba4222a9c
│   ├── VERSION
│   ├── json
│   └── layer.tar
├── b371b2502f66dfe68ddd56aa8e8411f529760fecab4222231f9c2db21159ed8b.json
├── fb378a43fa513bafd90ff70cd93564065eb0c2c93da99faea70e1a3e3883b755
│   ├── VERSION
│   ├── json
│   └── layer.tar
├── manifest.json
└── repositories

一層目で作成したhello.txtが入っているのは、fb378a~の方です。配下のlayer.tarを展開するか、jsonを見てみるとそのことがわかります。

同じレイヤであればディレクトリ名も変わらないだろうと予想していたのですが、変化していますね。ディレクトリごとdiffを取ると、jsonの中身が変化している様子。これがハッシュ値の違いに影響していそうですが、公式の情報をあたってみるべきかなぁ。

manifest.jsonのLayersを見てみると、下のレイヤから順番に並んでいそう。

[{"Config":"b371b2502f66dfe68ddd56aa8e8411f529760fecab4222231f9c2db21159ed8b.json","RepoTags":["testuser/testimage:v2"],"Layers":["fb378a43fa513bafd90ff70cd93564065eb0c2c93da99faea70e1a3e3883b755/layer.tar","a1ac6e668a69447918ad829196eba5a04490122120eb813b4c4fca3ba4222a9c/layer.tar"]}]

次回はOverlayFSでマウントしてみます。

techblog.hys-neko-lab.com