ヒスねこTechBlog

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

Pythonで仮想マシン管理いろいろ

 Ubuntuの自動インストールはできたので、Python使ってvirshの代わりになるような基本操作をやってみました。

techblog.hys-neko-lab.com

VMの起動

 virsh startと同じ操作です。libvirtのAPI一覧で「start」とかでページ内検索してもドンピシャのものがないなぁと思っていたんですが、virDomainCreate()が該当するんですね。libvirtのライフサイクルを知っていても、これはちょっとわからなかった...。startのほうが直感的でいいと思います。

libvirt.org

 Pythonバインディングされたものを使うと以下のような感じです。特定のドメイン(VM)に各種操作をするには、ドメイン名かUUIDをもとにドメインに接続する必要があります。今回の場合はlookupByNameで探して接続しています。

import sys
import libvirt

conn = libvirt.open('qemu:///system')
if conn == None:
  print('[ERROR]open connection to qemu:///system')
  exit(1)

dom = conn.lookupByName('xmltest')
if dom == None:
  print('[ERROR]find domain')
  exit(1)

if dom.create() != 0:
  print('[ERROR]create domain')
  exit(1)

print('domain started: '+dom.name())

conn.close()
exit(0)

うまいこと行っていれば、virt-viewerで接続できます。

VMの終了

停止の仕方としては穏便な方法(shutdown)と強制終了(destroy)があるので、その場に応じて使いたい方を使いましょう。できれば前者がいいのは言うまでもないですが、ゲストのサポート状況に依存するとのこと。

 なので代わりにdestroyFlags()の引数に1を指定してGRACEFULに閉じようとすることもできるようです。

"""
lookupByNameまで同じなので省略
"""
# 強制終了の場合はdom.destroy()
if dom.shutdown() != 0:
  print('[ERROR]shutdown domain')
  exit(1)

print('domain shutdown: '+dom.name())

conn.close()
exit(0)
VMのリストを取得

libvirtで管理しているVMのリストを表示してみます。listAllDomains()がVMリストを取得してくれます。

import sys
import libvirt

conn = libvirt.open('qemu:///system')
if conn == None:
  print('[ERROR]open connection to qemu:///system')
  exit(1)

domains = conn.listAllDomains()
for dom in domains:
  print(dom.name() + ' UUID:' + dom.UUIDString())
  if dom.isActive() is True:
    print('-> Active')
  else:
    print('-> Inactive')
  

conn.close()
exit(0)

C APIとそのPythonバインディングを見比べると命名規則や引数のとり方がわかってくると思います。色々試してみてください。

Ubuntu autoinstallでsshをセットアップ

cloud-initを使用したUbuntuのautoinstallで、sshを自動セットアップする方法です。前回長めの記事になってしまったので、今回は短めにまとめようと思います。

techblog.hys-neko-lab.com

下準備

クライアント側で、ssh-keygenによるキーペア生成をしておきます。内容を表示したものをまるっとコピーして、user-dataのauthorized-keysにセットする必要があります。

$ ssh-keygen -t rsa -f mypair
$ cat mypair.pub
ssh rsa (~中略~) クライアント側ユーザ名@クライアント側ホスト名
user-dataの編集

前回記事で使用していた~/www/user-dataを下記のように編集します。例によってpasswordの部分は適切に変更してください。authorized-keysには先程のcatの結果(ssh rsa~クライアント側ホスト名 まで全部)を、そのままコピペします。

#cloud-config
autoinstall:
  version: 1
  identity:
    hostname: ubuntu-server
    password: "$6$exDY1mhS4KUYCE/2$zmn9ToZwTKLhCw.b4/b.ZRTIZM30JZ4QrOQ2aOXJ8yk96xpcCof0kxKwuX1kqLG/ygbJ1f8wxED22bTL4F46P0"
    username: ubuntu
  ssh:
    allow-pw: false
    authorized-keys:
    - [cat mypair.pubの結果]
    install-server: true
動作確認

user-dataの編集が終わったら、前回記事同様にインストールします。ちなみにuser-dataに何らかの文法ミスがある場合、インストールが対話モードになってしまうことに注意してください。コロンの後にちゃんとスペースが入っているかなど、よく確認してみてください。

インストール完了後はdefineとstartして、クライアントから以下で接続します。

ssh -i mypair ubuntu@[仮想マシンのIPアドレス]

cloud-initとlibvirtでVMに自動OSインストール

 Ansibleほど大規模にVMを構築する気はないけど、OSのインストールは対話的にでなく自動で行いたい、という場合はcloud-initが選択肢に入るらしい。ちょっと試してみましょう。

セットアップ

 今回はUbuntu20.04(x86_64)でやってみました。

$ sudo apt install qemu-kvm libvirt-daemon-system virtinst
# libvirtグループにユーザを追加する
# (apt install時に追加されているはずだが念の為)
$ sudo adduser $USER libvirt
virt-installでインストール

 ゲストOSがUbuntuの場合、公式docに従えばインストールできてしまいます。

ubuntu.com

ただそれだと味気ないので、virt-installを使用してインストールにトライしてみます。

# 前半は公式docのProviding the autoinstall data over the networkと同じ
$ sudo mount -r ~/Downloads/ubuntu-20.04-live-server-amd64.iso /mnt
$ mkdir -p ~/www
cd ~/www
cat > user-data << 'EOF'
#cloud-config
autoinstall:
  version: 1
  identity:
    hostname: ubuntu-server
    password: "$6$exDY1mhS4KUYCE/2$zmn9ToZwTKLhCw.b4/b.ZRTIZM30JZ4QrOQ2aOXJ8yk96xpcCof0kxKwuX1kqLG/ygbJ1f8wxED22bTL4F46P0"
    username: ubuntu
EOF
touch meta-data
$ python3 -m http.server 3003

# 以下は別端末で操作(場所は任意)
$ truncate -s 10G image.img 
$ cd /mnt
$ virt-install \
--name=test \
--os-variant=ubuntu20.04 \
--memory=2048 \
--vcpus=1 \
--network network=default \
--disk=<image.imgへのパス>,cache=none,format=raw,bus=virtio \
--location=<ubuntu-20.04.4-live-server-amd64.isoへのパス>,kernel=casper/vmlinuz,initrd=casper/initrd \
--extra-args "autoinstall ds=nocloud-net;s=http://_gateway:3003/" \
--noreboot

 ちなみに--locationオプションのkernel, intridが/mnt/casper/vmlinuzや/mnt/casper/initrdだと以下のエラーが出たので、上記のように/mntに移動して実行しました。kvmコマンドの場合はフルパスでアクセスできたんですが...

Starting install...
ERROR    Couldn't find kernel for install tree.
Domain installation does not appear to have been successful.
If it was, you can restart your domain by running:
  virsh --connect qemu:///system start test
otherwise, please restart your installation.

 さらに補足ですが、passwordは公式docにならって「ubuntu」のハッシュ値としています。実際に使用する際は変更するべきでしょう。ハッシュ生成は「Linux パスワード ハッシュ」などで検索していただくと、SHA-512でハッシュ値計算するワンライナーがひっかかると思います。

Python+libvirtでインストール

 ついでにPythonでインストールする場合も試行してみました。

 インストールに必要なパラメータを記載したXMLが必要ですが、自分で書くとそれなりに大変です。virt-installのオプションを使って生成・流用するのがよいでしょう。

 ただし、virt-installの時とは異なり、Python+libvirtでは、/mnt配下のvmlinuzやinitrdを参照しようとすると読み取り専用で開けないと怒られます。なにか適切な設定があるのかもしれませんが、今回はとりあえず適当な場所にコピーしておきます。

# /home以下のような任意の場所にvmlinuzとinitrdをコピーしておく
$ mkdir casper
$ cp /mnt/casper/initrd ./casper/
$ cp /mnt/casper/vmlinuz ./casper/

# virt-installで実際にインストールをせずXMLを表示するには
# --print-xmlと--dry-runオプションを付加する
$ truncate -s 10G xmlimage.img
$ virt-install \
--name=xmltest \
--os-variant=ubuntu20.04 \
--memory=2048 \
--vcpus=1 \
--network network=default \
--disk=<xmlimage.imgへのパス>,cache=none,format=raw,bus=virtio \
--location=<ubuntu-20.04.4-live-server-amd64.isoへのパス>,kernel=casper/vmlinuz,initrd=casper/initrd \
--extra-args "autoinstall ds=nocloud-net;s=http://_gateway:3003/" \
--noreboot \
--print-xml \
--dry-run

 これで標準出力にXMLが出力されるのでコピーすればよいのですが、<domain type="kvm"> ... </domain>と囲まれたものが2つ出力されているかと思います。ここで使用するのは、先に出力された方です。後のものはdefine時に使用します。これも別でどこかに控えておいてください。

 コピーしたXMLは、以下のkernelタグ、initrdタグの内容を、先程コピーした先のものに変更する必要があります。元はvirt-install実行時に/var/lib/libvirt/boot/配下に作成した一時的なコピーを参照しているようで、ここの記載を変更しないと「そんなファイルはない」と怒られます。

  <os>
    <type arch="x86_64" machine="q35">hvm</type>
    <kernel>コピー先/casper/vmlinuz</kernel>
    <initrd>コピー先/casper/initrd</initrd>
    <cmdline>autoinstall ds=nocloud-net;s=http://_gateway:3003/</cmdline>
  </os>

 PythonでOSインストールするには、libvirtのPythonバインディングされたAPIがあるため、これを叩くことになります。なお本記事投稿時点のバージョンは6.1.0でした。

$ sudo apt install python3-libvirt

 XMLはファイルとして読み込んでもいいですし、ちょっと試すだけならPythonに直書きでもOKです。とりあえず今回は後者で。

import sys
import libvirt

xmlcreate = """
XMLをここにペースト
"""

conn = libvirt.open('qemu:///system')
if conn == None:
  print('[ERROR]open connection to qemu:///system')
  exit(1)

# defineしない一時的なVMを作成する
# (.imgにはインストールされているがlibvirt管理下にない状態になる)
dom_create = conn.createXML(xmlcreate, 0)
if dom_create == None:
  print('[ERROR]createXML')
  exit(1)


print('all success:'+dom_create.name())

conn.close()
exit(0)

 バックエンドで動作を始めるので、インストールしている様子を見届けたければvirt-viewerをGUI環境で実行するなどします。

$ python3 install.py
$ virt-viewer
VMをdefineする

 defineしていないので、virsh list --allなどしてもインストールしたVMが見えません。次のPythonスクリプトでdefineすることでlibvirtの管理が可能になり、virshでも表示されます。先程のvirt-installであとに出ていた方のXMLをペーストします。こちらは改変する必要は特にありません。

import sys
import libvirt

xmldefine = """
XMLをここにペースト
"""

conn = libvirt.open('qemu:///system')
if conn == None:
  print('[ERROR]open connection to qemu:///system')
  exit(1)

# VMをdefineする
dom_define = conn.defineXML(xmldefine)
if dom_define == None:
  print('[ERROR]defineXML')
  exit(1)

print('all success:'+dom_define.name())

conn.close()
exit(0)

 スクリプト実行はインストールが完了した後にしたほうがよいでしょう。実行後はvirshでdefineされているかの確認、virsh startとvirt-viewerで動作確認ができます。virt-viewer上でVMの操作が可能なので、pingなど試してみてもいいかもしれませんね。

$ python3 define.py
$ virsh list --all
 Id   Name      State
--------------------------
 -    xmltest   shut off

$ virsh start xmltest
Domain xmltest started

$ virt-viewer

今回はこれまで。

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

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