Jinja2のhello, world!

はじめに

JInja2のドキュメントの最初のサンプルコードが分かりにくくて、しばらくぶりだと色々調べ直しになる。。。 なので、最小限のコードを備忘録として残しておく。

環境

Ubuntu 18.04LTS

Jinja2のインストール

# pip3 install jinja2

hello, world!

以下の内容でgreeting.pyを作成する。

import jinja2

# create Jinja2 Environment
env = jinja2.Environment(loader = jinja2.FileSystemLoader('./'))

# load template
template = env.get_template('template.txt.j2')

# rendering
context = { 'name': 'world' }
result = template.render(context)

# print "hello, world!"
print(result)

以下の内容でtemplate.txt.j2を作成する。

hello, {{ name }}!

以下のように実行する。

$ python3 greeting.py 
hello, world!

参考

Pythonでリストからディクショナリを生成する

Pythonでキーとバリューが交互に並んだリストからディクショナリを生成するには、以下のようにする。

x = ['key1', 'value1', 'key2', 'value2']
y = dict(zip(x[0::2], x[1::2]))

まず、ステップ数を指定したスライスを利用して、x[0::2]でキーのリストを、x[1::2]でバリューのリストを生成する。 次に、zip()を利用してタプル(キー, バリュー)を要素に持つイテレータを生成する。 そして、そのイテレータを引数にしてdict()を呼び出し、ディクショナリを生成する。

ただし、キーの数とバリューの数が不揃いだった場合、zip()だと短い方に切り詰められるので、エラーハンドリングをしたいならばitertools.zip_longest()を活用する必要があるようだ。

参考

DockerコンテナでNFSのボリュームを利用する

はじめに

DockerコンテナでのNFSのボリュームを利用する手順を説明する。 ここでは、NFSサーバの情報は以下とする。

項目
IPアドレス 192.168.8.1
NFSのバージョン 4
公開ディレクト /shared

また、公開ディレクトリには、動作確認用にhello,world!という内容が書かれたファイルgreeting.txtを配置しておくことにする。

(1) ボリュームの作成

コンテナの起動に先立ち、「ボリューム」を作成しておく。

ボリュームとは、統一的なインタフェースで管理できるように、ストレージを抽象化したものである。 ボリュームの作成時に「ドライバ」を指定することで、様々なファイルシステムのボリュームを作成することができる。 Dockerがデフォルトで提供するlocalドライバは、実質的な処理をmountに委譲しているため、これを使えばNFSマウントは簡単に実現できる。

NFSのボリュームを作成するには、以下のコマンドを実行する。

$ docker volume create --driver local --opt type=nfs --opt o=addr=192.168.8.1,rw,nfsvers=4 --opt device=:/shared nfs-volume

上記のコマンドラインを見れば分かるように、オプション--optで指定できるドライバへのオプションはmountと同じである。

作成されたボリュームの情報を確認するには、以下のコマンドを実行する。

$ docker volume inspect nfs-volume
[
    {
        "CreatedAt": "2019-04-11T00:45:17+09:00",
        "Driver": "local",
        "Labels": {},
        "Mountpoint": "/var/lib/docker/volumes/nfs-volume/_data",
        "Name": "nfs-volume",
        "Options": {
            "device": ":/shared",
            "o": "addr=192.168.8.1,rw,nfsvers=4",
            "type": "nfs"
        },
        "Scope": "local"
    }
]

(2) ボリュームのコンテナへのアタッチ

ボリュームをコンテナにアタッチする方法は、オプション--mountを利用する方法と、オプション--volumeを利用する方法の、2つがある。

オプション--mountを利用する場合は、以下のようになる。

$ docker run --mount source=nfs-volume,target=/shared -it ubuntu

また、オプション--volumeを利用する場合は、以下のようになる。

$ docker run --volume nfs-volume:/shared -it ubuntu

動作確認

ボリュームがアタッチされているかどうか確認する。 先の(2)の手順でubuntuを実行しているので、まずはコンテナの中からマウントの状態を確認する。

$ df -h
Filesystem      Size  Used Avail Use% Mounted on
overlay          98G   17G   77G  18% /
tmpfs            64M     0   64M   0% /dev
tmpfs           2.0G     0  2.0G   0% /sys/fs/cgroup
:/shared         98G   17G   77G  18% /shared
/dev/sda2        98G   17G   77G  18% /etc/hosts
shm              64M     0   64M   0% /dev/shm
tmpfs           2.0G     0  2.0G   0% /proc/asound
tmpfs           2.0G     0  2.0G   0% /proc/acpi
tmpfs           2.0G     0  2.0G   0% /proc/scsi
tmpfs           2.0G     0  2.0G   0% /sys/firmware

次に、同様にコンテナの中から動作確認用に作成しておいた/shared/greeting.txtの内容を表示できるか確認する。

$ cat /shared/greeting.txt
hello,world!

最後に、コンテナの外側からコンテナの情報を確認する。 オプション--mountを利用した場合は、以下のような結果となる。

$ docker inspect c60aa2108cc5
(略)
        "Mounts": [
            {
                "Type": "volume",
                "Name": "nfs-volume",
                "Source": "/var/lib/docker/volumes/nfs-volume/_data",
                "Destination": "/shared",
                "Driver": "local",
                "Mode": "z",
                "RW": true,
                "Propagation": ""
            }
        ],
(略)

また、オプション--volumeを利用した場合は、以下のような結果となる。

$ docker inspect 98bfa1f78037
(略)
            "Mounts": [
                {
                    "Type": "volume",
                    "Source": "nfs-volume",
                    "Target": "/shared"
                }
            ],
(略)

補足: オプション--mount--volumeの違い

大雑把に言えば、アタッチしようとしているボリュームが存在しなかった時の挙動が違う。 オプション--volumeは古くからあるオプションであり、互換性を考慮すると、もはや挙動を変えることができないらしい。 そこで、オプション--volumeを残しつつも、オプション--mountを新設したとのこと。 そのため、今後は--mountを利用したほうが良い。

以降の説明では、NFSのボリュームについてのみ言及する。

オプション--mountの場合

オプション--mountの値には、ボリュームの作成に必要な各種情報を付け加えることができる。 ボリュームnfs-volumeがない状態で以下のようにコンテナを起動すると、NFSのボリュームが自動的に作成されて、それがコンテナにアタッチされる。

$ docker run --mount 'type=volume,source=nfs-volume,target=/shared,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/shared,"volume-opt=o=addr=192.168.8.1,rw,nfsvers=4"' -it ubuntu

作成されたボリュームの情報を表示すると、手順(1)で作成したものと同じであることが確認できる。

$ docker volume inspect nfs-volume
[
    {
        "CreatedAt": "2019-04-11T00:39:55+09:00",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/nfs-volume/_data",
        "Name": "nfs-volume",
        "Options": {
            "device": ":/shared",
            "o": "addr=192.168.8.1,rw,nfsvers=4",
            "type": "nfs"
        },
        "Scope": "local"
    }
]

オプション--volumeの場合

一方で、オプション--volumeにはボリュームを作成するための詳細情報を引き渡す手段がない。 ボリュームnfs-volumeがない状態で以下のようにコンテナを起動すると、NFSではなく「普通の」ホスト側のファイルシステムのボリュームが作成されて、それがコンテナにアタッチされる。

$ docker run --volume nfs-volume:/shared -it ubuntu

以下のように、コンテナの中からマウントの状況を確認すると、/sharedはマウントされているものの、NFSではないことが分かる。

$ df -h
Filesystem      Size  Used Avail Use% Mounted on
overlay          98G   17G   77G  18% /
tmpfs            64M     0   64M   0% /dev
tmpfs           2.0G     0  2.0G   0% /sys/fs/cgroup
/dev/sda2        98G   17G   77G  18% /shared
shm              64M     0   64M   0% /dev/shm
tmpfs           2.0G     0  2.0G   0% /proc/asound
tmpfs           2.0G     0  2.0G   0% /proc/acpi
tmpfs           2.0G     0  2.0G   0% /proc/scsi
tmpfs           2.0G     0  2.0G   0% /sys/firmware

そのため、コンテナの中には動作確認用のファイル/shared/greeting.txtは存在しない。

$ cat /shared/greeting.txt
cat: /shared/greeting.txt: No such file or directory

コンテナの外側から、以下のように作成されたボリュームの情報を確認すると、当然だがNFSの設定はなされていない。

$ docker volume inspect nfs-volume
[
    {
        "CreatedAt": "2019-04-14T23:36:16+09:00",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/nfs-volume/_data",
        "Name": "nfs-volume",
        "Options": null,
        "Scope": "local"
    }
]

参考

NFS Version 4のサーバとクライアントの構築手順

はじめに

CI環境を構築するにあたって、成果物を共有するための共有ストレージが必要になった。 いろいろ検討したが、ESXiのデータストアとして利用できるという点から、NFS Version 4を採用することにした。 ネット上の情報は古いものが多くて構築に苦労したので、NFSサーバとNFSクライアントの構築手順を残しておく。 試した環境はUbuntu 18.04。

NFSサーバ

NFSサーバをインストールする。

$ sudo apt install nfs-kernel-server

公開するディレクトリを準備する。 ここでは、以下に示すディレクト/public/shared/var/ci-artifactsを公開することにする。

/
|-- public
|   `-- shared
`-- var
    `-- ci-artifacts

NFS Version 4は個別にディレクトリを公開するのではなく、特定のディレクトリをルートとして公開する思想らしい。 そのため、公開したいディレクトリをあるひとつのディレクトリのもとに集約する必要がある。 ここではバインドマウントを利用して、以下のようにディレクト/exportのもとに集約する。

/
|-- export
|   |-- ci-artifacts ←●/var/ci-artifactsをバインドマウント
|   `-- shared       ←●/public/sharedをバインドマウント
|-- public
|   `-- shared
`-- var
    `-- ci-artifacts

推測だが、シンボリックリンクだと(chrootの場合のように)管轄外には辿れないので、バインドマウントでしっかりとファイルシステムに組み込む、ということなんだと思う。 バインドマウントするためには、/etc/fstabに以下のエントリを追記する。

/export/ci-artifacts  /var/ci-artifacts  none  bind  0  0
/export/shared        /public/shared     none  bind  0  0

公開するディレクトリのマウント先を作成する。

$ sudo mkdir /export/ci-artifacts
$ sudo mkdir /export/shared

マウントする。

$ sudo mount -a

マウントできているか確認する。

$ findmnt -l | grep "/export/"
/var/ci-artifacts               /dev/sda2[/export/ci-artifacts] ext4            rw,relatime,errors=remount-ro,data=ordered
/public/shared                  /dev/sda2[/export/shared]       ext4            rw,relatime,errors=remount-ro,data=ordered

/etc/exportsを編集して、公開するディレクトリの定義を行う。

/export               192.168.8.0/24(rw,sync,all_squash,no_subtree_check,fsid=0)
/export/ci-artifacts  192.168.8.0/24(rw,sync,all_squash,no_subtree_check,nohide)
/export/shared        192.168.8.0/24(rw,sync,no_root_squash,no_subtree_check,nohide)

ここではNFS Version 4のエントリだけを定義しているが、NFS Version 3などのエントリも混在させることができる。 fsid=0とあるのが、NFS Version 4のルートのエントリとなる。 色々調べてみた結果、複数のルート(fsid=1とか?)を公開することはできないように思うが、いまいち確証は得られなかった。 また、バインドマウントの単位ごとにエントリを作成する必要があり、ここでは/export/ci-artifacts/export/sharedのエントリを作成している。

/etc/exportsの編集が完了したら、以下のコマンドを実行してディレクトリを公開する。

$ sudo exportfs -r

あるいは、NFSサーバを再起動する事で公開してもよい。

$ sudo systemctl restart nfs-server

実際に公開できたか確認する。

$ sudo exportfs
/export         192.168.8.0/24
/export/ci-artifacts
        192.168.8.0/24
/export/shared  192.168.8.0/24

NFSクライアント

まずはNFSクライアントをインストールする。

$ sudo apt install nfs-common

マウントポイントを作成する。

$ sudo mkdir /ci-artifacts
$ sudo mkdir /shared

/etc/fstabを編集する。

192.168.8.1:/ci-artifacts /ci-artifacts nfs noauto,x-systemd.automount,rw  0  0
192.168.8.1:/shared       /shared       nfs noauto,x-systemd.automount,rw  0  0

マウントオプション的には、公開ディレクトリがどのバージョンのNFSなのかは、気にしなくていいようだ。 ただし、公開ディレクトリのパスのルートはfsid=0のパスが基準になる。 そのため、今回の場合は/exportを基準にするため、/export/ci-artifacts/ci-artifactsになる。

そして、マウントする。

$ sudo mount -a

ESXiのパスワードのポリシーを変更する

はじめに

デフォルトのESXiのパスワードのポリシーはちょっときつめで、今利用している開発環境のパスワードのポリシーと合わない。 そこで、ESXiのパスワードのポリシーを少し緩めることにした。

変更方法

ファイル/etc/pam.d/passwdにパスワードのポリシーの設定があるので、これを書き換えることで変更できる。 デフォルトの/etc/pam.d/passwdの内容は以下の通り。

#%PAM-1.0

# Change only through host advanced option "Security.PasswordQualityControl".
password   requisite    /lib/security/$ISA/pam_passwdqc.so retry=3 min=disabled,disabled,disabled,7,7
password   sufficient   /lib/security/$ISA/pam_unix.so use_authtok nullok shadow sha512
password   required     /lib/security/$ISA/pam_deny.so

ここで、min=disabled,disabled,disabled,7,7の部分がパスワードの最小文字数の設定になる。 passwdqcのドキュメントによると、minのフォーマットはmin=N0,N1,N2,N3,N4であり、各項目の意味は以下の通りである。

項目 対象 意味 既定値
N0 パスワード 文字クラス1の場合の長さ disabled
N1 パスワード 文字クラス2の場合の長さ 24
N2 パスフレーズ 全体の長さ 11
N3 パスワード 文字クラス3の場合の長さ 8
N4 パスワード 文字クラス4の場合の長さ 7

文字クラスとは、パスワードが何種類の文字で構成されているかを示すもの。 passwdqcは、文字を以下の4種類に分けている。

  • 数字
  • アルファベット小文字
  • アルファベット大文字
  • その他 (記号など)

例えば、helloは文字クラス1、hello,world!は文字クラス2、Hello,World!は文字クラス3となる。

各項目の値について、0の場合は最小文字数の制限なしとなり、disabledの場合はその文字クラスは利用できなくなる。

これらを踏まえて、min=disabled,6,6,6,6と書き換えることにした。 書き換え後のファイル/etc/pam.d/passwdの内容は以下の通り。

#%PAM-1.0

# Change only through host advanced option "Security.PasswordQualityControl".
password   requisite    /lib/security/$ISA/pam_passwdqc.so retry=3 min=disabled,6,6,6,6
password   sufficient   /lib/security/$ISA/pam_unix.so use_authtok nullok shadow sha512
password   required     /lib/security/$ISA/pam_deny.so

動作確認

普段のポリシーをもとにパスワードを変更してみる。

$ passwd
Changing password for root

You can now choose the new password or passphrase.

A valid password should be a mix of upper and lower case letters,
digits, and other characters.  You can use a 6 character long
password with characters from at least 2 of these 4 classes.
An upper case letter that begins the password and a digit that
ends it do not count towards the number of character classes used.

A passphrase should be of at least 3 words, 6 to 40 characters
long, and contain enough different characters.

Alternatively, if no one else can see your terminal now, you can
pick this as your password: "sweet+Jolt$Trip".

Enter new password: ←●パスワードを入力
Re-type new password:  ←●パスワードを入力
passwd: password updated successfully

無事、パスワードの変更が完了した。

補足 - N0N4の大小関係について

結論から言うと、以下のような大小関係を満たす必要がある。

N0 >= N1 >= N2 >= N3 >= N4

つまり、利用している文字の種類が少ない場合は長さでカバーしろ、という方針だと思われる。 読み落としやすいが、passwdqcのドキュメントには、以下の記載がある。

Each subsequent number is required to be no larger than the preceding one.

最初、N2パスフレーズのパラメータなので無関係と思い、min=disabled,6,disabled,6,6としていた。 この設定でパスワードを変更しようとすると、以下のようにInvalid parameter valueとエラーになった。

$ passwd
Changing password for root
pam_passwdqc: Error parsing parameter "min=disabled,6,disabled,6,6": Invalid parameter value.
passwd: Critical error - immediate abort
passwd: 

エラーメッセージが大雑把で原因が分からないので、passwdqcのソースコードを確認したところ、以下のようであった。

static int
parse_option(passwdqc_params_t *params, char **reason, const char *option)
{
    // ...

    if ((p = skip_prefix(option, "min="))) {
        for (i = 0; i < 5; i++) {
            if (!strncmp(p, "disabled", 8)) {
                v = INT_MAX;  ←●disabledの時の値はINT_MAX
                p += 8;
            } else {
                v = strtoul(p, &e, 10);
                p = e;
            }
            if (i < 4 && *p++ != ',')
                goto parse_error;
            if (v > INT_MAX)
                goto parse_error;
            if (i && (int)v > params->qc.min[i - 1])  ←●ひとつ前と大小関係を比較
                goto parse_error;
            params->qc.min[i] = v;
        }
        if (*p)
            goto parse_error;
    } else if ((p = skip_prefix(option, "max="))) {

ソースコードによると、disabledと指定された時の実際の値はINT_MAX(=環境にもよるが2147483647とか)になる。 なので、min=disabled,6,disabled,6,6min=2147483647,6,2147483647,6,6と解釈され、大小関係を満たせずにエラーになる、という挙動みたいだ。

参考

min=N0,N1,N2,N3,N4

(min=disabled,24,11,8,7) The minimum allowed password lengths for different kinds of passwords/passphrases. The keyword disabled can be used to disallow passwords of a given kind regardless of their length. Each subsequent number is required to be no larger than the preceding one.

N0 is used for passwords consisting of characters from one character class only. The character classes are: digits, lower-case letters, upper-case letters, and other characters. There is also a special class for non-ASCII characters, which could not be classified, but are assumed to be non-digits.

N1 is used for passwords consisting of characters from two character classes that do not meet the requirements for a passphrase.

N2 is used for passphrases. Note that besides meeting this length requirement, a passphrase must also consist of a sufficient number of words (see the passphrase option below).

N3 and N4 are used for passwords consisting of characters from three and four character classes, respectively.

When calculating the number of character classes, upper-case letters used as the first character and digits used as the last character of a password are not counted.

In addition to being sufficiently long, passwords are required to contain enough different characters for the character classes and the minimum length they have been checked against.

KVM環境のNATでポートフォワード

はじめに

以前の記事で書いたlibvirtのフックを利用して、NATでポートフォワードする方法を示す。 古いブログなどではiptables-save/iptables-restoreを利用してポートフォワードの設定を永続化している例もあるが、フックを使う方式が正しいと思われる。

サンプルのポートフォワードのフック

まずは、libvirtのドキュメントにサンプル掲載されている、ポートフォワードのフックを示す。 サンプルなので仕方がないが、このままだとポートフォワードの設定を変更するたびにスクリプトの変更が必要になる。

#!/bin/bash

# IMPORTANT: Change the "VM NAME" string to match your actual VM Name.
# In order to create rules to other VMs, just duplicate the below block and configure
# it accordingly.
if [ "${1}" = "VM NAME" ]; then

   # Update the following variables to fit your setup
   GUEST_IP=
   GUEST_PORT=
   HOST_PORT=

   if [ "${2}" = "stopped" ] || [ "${2}" = "reconnect" ]; then
    /sbin/iptables -D FORWARD -o virbr0 -d  $GUEST_IP -j ACCEPT
    /sbin/iptables -t nat -D PREROUTING -p tcp --dport $HOST_PORT -j DNAT --to $GUEST_IP:$GUEST_PORT
   fi
   if [ "${2}" = "start" ] || [ "${2}" = "reconnect" ]; then
    /sbin/iptables -I FORWARD -o virbr0 -d  $GUEST_IP -j ACCEPT
    /sbin/iptables -t nat -I PREROUTING -p tcp --dport $HOST_PORT -j DNAT --to $GUEST_IP:$GUEST_PORT
   fi
fi

Pythonでリライトしたポートフォワードのフック

前出のサンプルの欠点を埋めるために、ポートフォワードの設定部分を以下のようなYAMLで外だしするようにした。

"web-server":
  - bridge: virbr0
    vm_ip: 192.168.8.4
    forwardings:
      - host_port: 10022
        vm_port: 22
      - host_port: 10080
        vm_port: 80

"other-vm":
  - bridge: virbr0
    vm_ip: 192.168.8.8
    forwardings:
      - host_port: 20022
        vm_port: 22

さすがにbashYAMLはキビシイので、サンプルスクリプトPythonでリライトした。 以下のスクリプト/etc/libvert/hooks/qemuという名前で作成し、実行権を与えておく。 そして、このスクリプトと同じディレクトリに、上記のようなYAMLport-forwarding.ymlというファイル名で配置しておくと、これを読み込んで適切にiptablesを実行する。

#!/usr/bin/python3

import os
import os.path
import sys
import subprocess
import yaml


def run_command(command):
    print(command, file=sys.stderr)
    subprocess.run(command)


def entries(entries):
    for entry in entries:
        yield (entry, entry["bridge"], entry["vm_ip"])


def forwardings(entry):
    vm_ip = entry["vm_ip"]
    for forwarding in entry["forwardings"]:
        yield (str(forwarding["host_port"]), vm_ip + ":" + str(forwarding["vm_port"]))


def on_libvirt_hook(config, vm_name, operation, sub_operation, extra_argument):
    if vm_name in config:
        for entry, bridge, vm_ip in entries(config[vm_name]):
            if operation in ["stopped", "reconnect"]:
                run_command(["/sbin/iptables", "-D", "FORWARD" ,"-o", bridge, "-d", vm_ip,  "-j", "ACCEPT"])
                for host_port, vm_ip_and_port in forwardings(entry):
                    run_command(["/sbin/iptables", "-t", "nat", "-D", "PREROUTING", "-p", "tcp", "--dport", host_port, "-j", "DNAT", "--to", vm_ip_and_port])

            if operation in ["start", "reconnect"]:
                run_command(["/sbin/iptables", "-I", "FORWARD" ,"-o", bridge, "-d", vm_ip,  "-j", "ACCEPT"])
                for host_port, vm_ip_and_port in forwardings(entry):
                    run_command(["/sbin/iptables", "-t", "nat", "-I", "PREROUTING", "-p", "tcp", "--dport", host_port, "-j", "DNAT", "--to", vm_ip_and_port])


if __name__ == '__main__':
    config_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "port-forwarding.yml")
    with open(config_file) as f:
        config = yaml.load(f)

    on_libvirt_hook(config, sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])

動作確認

スクリプト/etc/libvert/hooks/qemuコマンドラインから直接実行して、iptablesの変化を見てみる。 まずは、スクリプト実行前のiptablesの状態を以下に示す。

$ sudo iptables -L
Chain INPUT (policy ACCEPT)
target     prot opt source               destination         
ACCEPT     udp  --  anywhere             anywhere             udp dpt:domain
ACCEPT     tcp  --  anywhere             anywhere             tcp dpt:domain
ACCEPT     udp  --  anywhere             anywhere             udp dpt:bootps
ACCEPT     tcp  --  anywhere             anywhere             tcp dpt:bootps

Chain FORWARD (policy ACCEPT)
target     prot opt source               destination         
ACCEPT     all  --  anywhere             192.168.8.0/24       ctstate RELATED,ESTABLISHED
ACCEPT     all  --  192.168.8.0/24       anywhere            
ACCEPT     all  --  anywhere             anywhere            
REJECT     all  --  anywhere             anywhere             reject-with icmp-port-unreachable
REJECT     all  --  anywhere             anywhere             reject-with icmp-port-unreachable

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination         
ACCEPT     udp  --  anywhere             anywhere             udp dpt:bootpc

$ sudo iptables -L -t nat
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination         

Chain INPUT (policy ACCEPT)
target     prot opt source               destination         

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination         

Chain POSTROUTING (policy ACCEPT)
target     prot opt source               destination         
RETURN     all  --  192.168.8.0/24       base-address.mcast.net/24 
RETURN     all  --  192.168.8.0/24       255.255.255.255     
MASQUERADE  tcp  --  192.168.8.0/24      !192.168.8.0/24       masq ports: 1024-65535
MASQUERADE  udp  --  192.168.8.0/24      !192.168.8.0/24       masq ports: 1024-65535
MASQUERADE  all  --  192.168.8.0/24      !192.168.8.0/24      

そして、以下のようにweb-serverというVMを起動した想定で、スクリプトを実行する。

$ sudo /etc/libvirt/hooks/qemu web-server start - -
['/sbin/iptables', '-I', 'FORWARD', '-o', 'virbr0', '-d', '192.168.8.4', '-j', 'ACCEPT']
['/sbin/iptables', '-t', 'nat', '-I', 'PREROUTING', '-p', 'tcp', '--dport', '10022', '-j', 'DNAT', '--to', '192.168.8.4:22']
['/sbin/iptables', '-t', 'nat', '-I', 'PREROUTING', '-p', 'tcp', '--dport', '10080', '-j', 'DNAT', '--to', '192.168.8.4:80']

スクリプト実行後のiptablesの状態は以下のようになり、適切にエントリが追加されていることが分かる。

$ sudo iptables -L
Chain INPUT (policy ACCEPT)
target     prot opt source               destination         
ACCEPT     udp  --  anywhere             anywhere             udp dpt:domain
ACCEPT     tcp  --  anywhere             anywhere             tcp dpt:domain
ACCEPT     udp  --  anywhere             anywhere             udp dpt:bootps
ACCEPT     tcp  --  anywhere             anywhere             tcp dpt:bootps

Chain FORWARD (policy ACCEPT)
target     prot opt source               destination         
ACCEPT     all  --  anywhere             192.168.8.4  ←●追加された
ACCEPT     all  --  anywhere             192.168.8.0/24       ctstate RELATED,ESTABLISHED
ACCEPT     all  --  192.168.8.0/24       anywhere            
ACCEPT     all  --  anywhere             anywhere            
REJECT     all  --  anywhere             anywhere             reject-with icmp-port-unreachable
REJECT     all  --  anywhere             anywhere             reject-with icmp-port-unreachable

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination         
ACCEPT     udp  --  anywhere             anywhere             udp dpt:bootpc

$ sudo iptables -L -t nat
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination         
DNAT       tcp  --  anywhere             anywhere             tcp dpt:amanda to:192.168.8.4:80  ←●追加された
DNAT       tcp  --  anywhere             anywhere             tcp dpt:10022 to:192.168.8.4:22  ←●追加された

Chain INPUT (policy ACCEPT)
target     prot opt source               destination         

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination         

Chain POSTROUTING (policy ACCEPT)
target     prot opt source               destination         
RETURN     all  --  192.168.8.0/24       base-address.mcast.net/24 
RETURN     all  --  192.168.8.0/24       255.255.255.255     
MASQUERADE  tcp  --  192.168.8.0/24      !192.168.8.0/24       masq ports: 1024-65535
MASQUERADE  udp  --  192.168.8.0/24      !192.168.8.0/24       masq ports: 1024-65535
MASQUERADE  all  --  192.168.8.0/24      !192.168.8.0/24      

参考

KVM環境のフックについて調査

はじめに

KVM環境でポートフォワードを行おうと思って調べてみたところ、どうもlibvirtのフックの仕組みを利用してiptablesを実行するのがよいらしい。 そのフックの概要を忘れないうちに残しておく。

フックのスクリプト

フックはスクリプトとして実装する。 言語はbashPythonなど、何でもよい。 機能別に呼び出されるスクリプトが分かれており、スクリプトのファイル名は固定である。

スクリプトファイル 対応する機能
/etc/libvirt/hooks/daemon libvirtデーモン
/etc/libvirt/hooks/qemu QEMUのゲストであり、KVMの普通のVM
/etc/libvirt/hooks/lxc LXCコンテナ
/etc/libvirt/hooks/libxl XenVM
/etc/libvirt/hooks/network ネットワーク

フックの呼び出しのタイミング

各対象の起動や停止などのライフサイクルの変化が発生した場合に呼び出される。 具体的なライフサイクルについては、各フックの仕様を参照。

フックのパラメータ

フックには、次のパラメータが渡される。

引数 内容 具体値
第1引数 object VM名, ネットワーク名, など
第2引数 operation "started", "stopped", など
第3引数 sub-operation "begin", "end", など
第4引数 extra argument "SIGHUP", など

各引数について、「値なし」の場合は"-"が引き渡される。 具体的な値の種類と組み合わせは、各フックの仕様を参照。

第1引数objectの詳細情報

第1引数objectの詳細情報は、標準入力からXML形式で取得できる。 例えば、/etc/libvirt/hooks/networkには以下のようなXMLが引き渡されてきた。

<hookData>
  <network>
    <name>default</name>
    <uuid>2cc82eef-28f4-463f-9324-a0f0f3d0578c</uuid>
    <forward mode='nat'/>
    <bridge name='virbr0' stp='on' delay='0'/>
    <mac address='52:54:00:3e:72:43'/>
    <ip address='192.168.8.1' netmask='255.255.255.0'>
      <dhcp>
        <range start='192.168.8.128' end='192.168.8.254'/>
      </dhcp>
    </ip>
  </network>
</hookData>

フックの戻り値

フックのスクリプトが戻り値として0を返却すると成功、0以外を返却すると失敗と判定される。

ログ出力

フックのスクリプト内で標準エラーに出力した内容は、ログファイルに記録される。

実験

フックの挙動を確認するため、引数と標準入力をダンプするスクリプトを作成してみた。 どのフックも処理内容は同じなので、スクリプトはひとつだけ作成して、シンボリックリンクを張ることにした。

フックのスクリプトの実体は/etc/libvirt/hooks/hook-logger.pyとし、次の内容で作成。

#!/usr/bin/python3

import datetime
import fcntl
import os
import os.path
import sys

logfile = os.path.join(os.path.dirname(os.path.abspath(__file__)), "log.txt")

with open(logfile, "a+") as f:
    fcntl.flock(f, fcntl.LOCK_EX)
    try:
        f.write(str(datetime.datetime.today()) + " " + str(sys.argv) + "\n")

        cr = True
        for line in sys.stdin:
            cr = line.endswith("\n")
            f.write(line)
        
        if not cr:
            f.write("\n")

    finally:
        fcntl.flock(f, fcntl.LOCK_UN)

このスクリプトに実行権限を付与して、各フックとしてシンボリックリンクを作成する。

$ sudo chmod a+x hook-logger.py
$ sudo ln -s hook-logger.py daemon
$ sudo ln -s hook-logger.py qemu
$ sudo ln -s hook-logger.py lxc
$ sudo ln -s hook-logger.py libxl
$ sudo ln -s hook-logger.py network

各対象のライフサイクルに変化があると、ファイル/etc/libvirt/hooks/log.txtに、例えば次のような情報が出力される。

2019-02-17 05:15:12.414193 ['/etc/libvirt/hooks/daemon', '-', 'shutdown', '-', 'shutdown']
2019-02-17 05:16:01.398895 ['/etc/libvirt/hooks/daemon', '-', 'start', '-', 'start']
2019-02-17 05:16:04.189531 ['/etc/libvirt/hooks/network', 'default', 'start', 'begin', '-']
<hookData>
  <network>
    <name>default</name>
(略)
2019-02-17 05:16:05.459755 ['/etc/libvirt/hooks/network', 'default', 'started', 'begin', '-']
<hookData>
  <network>
    <name>default</name>
(略)
2019-02-17 05:34:23.456336 ['/etc/libvirt/hooks/qemu', 'my-vm', 'prepare', 'begin', '-']
<domain type='kvm' id='2'>
  <name>my-vm</name>
  <uuid>8fee996e-99c0-4334-97be-07abe2d9e089</uuid>
(略)
2019-02-17 05:34:23.919914 ['/etc/libvirt/hooks/network', 'default', 'plugged', 'begin', '-']
<hookData>
  <interface type='network'>
    <mac address='52:54:00:a0:91:82'/>
(略)

参考