Cacti の 2020-09-13 12:26:40 問題

だいぶ昔(4年くらい前)にセットアップして塩漬けになっていた Cacti で、グラフをズームして任意の開始日時・終了日時を指定して表示しようとしても、何故かその指定が効かず、常に Daily のグラフしか出せなくなることがありました。

普段は Prometheus/Grafana を使っていて、ただ Prometheus だと1年とかの長期のメトリクス保持が簡単ではないので Cacti を併用していたという状況で、Cacti をじっくり見る機会があまりなかったのでずっと気づいていませんでした。

詳しく見ていたところ、開始日時が Unix タイムスタンプで 1600000000 を超えると指定が効かなくなることがわかりました。1600000000、UTC で 2020-09-13 12:26:40 ですね、ググるとすぐ下記の記事が見つかりました。

以下の通り Issue もあげられていて、

下記のコミットでもっと大きな値(2088385563)に変更されています。

ところでこの 1600000000 とか 2088385563 とかはどういう基準の値なのでしょうか。1600000000 を閾値とする変更は下記のコミットで入れられたようです。

極端に大きな期間を指定することで rrdtool の CPU 使用率を高騰させる DoS に対する対策だったようですが、2007年のものなので・・この当時は 2020-09-13 が遠い遠い未来だった、ということでしょうか。

なお、これは CVE も登録されていたようです。

参照されている Cacti の Issue Tracker は現在は Github に移行されたためかサイトが存在しなくなっていますが、Internet Archive に残っていました。

1600000000 になった根拠とかは特に残って無さそうです。

2088385563 の方は・・ 2036-03-06 03:06:03 ・・なんとなく 3 と 6 が繰り返されている? 単に問題を先送りにしたというだけなのでしょうか。

PHPUnit の Clover XML を Cobertura XML に変換して Gitlab の MR で コードカバレッジの可視化

以下で説明される通り、Gitlab では MR のコード差分画面で行ごとのコードカバレッジを可視化できます。

この機能を利用するためには Cobertura 形式の XML をカバレッジレポートが必要です。PHP の場合 PHPUnit 9.4 から Cobertura 形式のカバレッジレポートが出力できるので、そのまま利用できます。

ただ・・PHPUnit 9.4 って結構最近ですね。諸般の事情により PHPUnit のバージョンを上げられない場合は Clover 形式の XML を出力して Cobertura 形式に変換すれば良さそうです。

Cobertura 形式や Clover 形式がどういうものなのか詳細は知りませんが、Gitlab の Cobertura XML を処理するコードを見た感じ、Cobertura XML の一部の要素しか抽出していないので、これぐらいなら雰囲気で Clover 形式から変換できそうです。

というわけで、雑に作ってみました。

.gitlab-ci.yml で次のように設定します。

image: ngyuki/php-dev

test:
  stage: test
  only:
    - merge_requests
  script:
    - composer install --prefer-dist --no-progress --no-suggest --ansi
    - phpdbg -qrr vendor/bin/phpunit --coverage-clover=clover.xml
    - vendor/bin/clover-to-cobertura < clover.xml > cobertura.xml
  cache:
    paths:
      - vendor/
  artifacts:
    reports:
      cobertura: cobertura.xml

MR のコード差分で次のように表示されます(行番号の右の緑と赤の線)。

f:id:ngyuki:20210117140857p:plain

Gitlab のマージリクエストで PHPUnit のコードカバー率の差分を表示する

PHPUnit のコードカバー率がマージリクエスト(MR)の前後でどのように変化したかの差分を MR の画面に表示するようにしてみたメモ。

.gitlab-ci.yml は次のような内容になります。phpunit でテストを実行するジョブと phpcov でコードカバレッジを計測するジョブが分かれていますがこれは本題とは関係ありません。実際のプロジェクトでそうしていることが多いため、この例でも同じようにしているだけです。

image: ngyuki/php-dev

stages:
  - test
  - coverage

test:
  stage: test
  only:
    - master
    - merge_requests
  script:
    - composer install --prefer-dist --no-progress --no-suggest --ansi
    - mkdir -p phpcov/
    - phpdbg -qrr vendor/bin/phpunit --coverage-php=phpcov/test.cov
  cache:
    paths:
      - vendor/
  artifacts:
    paths:
      - phpcov/
    expire_in: 1 days

.coverage:
  stage: coverage
  needs:
    - test
  script: &coverage_script
    - composer install --prefer-dist --no-progress --no-suggest --ansi
    - vendor/bin/phpcov merge phpcov/ --text=coverage.txt
    - sed -r '/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}/d' -i coverage.txt
  cache:
    paths:
      - coverage.txt
      - vendor/

coverage:
  extends: .coverage
  only:
    - master
  cache:
    key: coverage--$CI_COMMIT_REF_NAME
    policy: push

coverage-mr:
  extends: .coverage
  only:
    - merge_requests
  cache:
    key: coverage--$CI_MERGE_REQUEST_TARGET_BRANCH_NAME
    policy: pull
  script:
    - test -e coverage.txt && mv -f coverage.txt coverage.orig.txt || true
    - *coverage_script
    - test -e coverage.orig.txt && diff -u -w coverage.orig.txt coverage.txt

コードカバレッジを計測するジョブで phpcov merge--text でテキストのコードカバレッジをファイルに書き出します。そのファイルを diff で比較してその結果をジョブのログに出力します。

ポイントは、master で実行されるジョブのキャッシュと MR で実行されるジョブのキャッシュを同名にすることで、master のジョブで作成された coverage.txt を MR のジョブで参照する、ところです。

master のジョブではキャッシュ名を coverage--$CI_COMMIT_REF_NAME としています。$CI_COMMIT_REF_NAME はブランチ名になるのでキャッシュ名は coverage--master となります。

MR のジュブではキャッシュ名を coverage--$CI_MERGE_REQUEST_TARGET_BRANCH_NAME としています。$CI_MERGE_REQUEST_TARGET_BRANCH_NAME はマージリクエストのマージ先ブランチなので、master へのマージリクエストならキャッシュ名は coverage--master となり、master のジョブのキャッシュ名と同名になります。

さらに master のジョブでは policy: push でキャッシュを更新するのみ、MR のジョブでは policy: pull を取得のみとします。

これで MR のジョブの実行時に master のジョブによって作成されたキャッシュを取得できます。キャッシュに phpcov --text の出力ファイルを入れてやれば、MR のジョブで次のような内容をログに出力することができます。

--- coverage.orig.txt
+++ coverage.txt
@@ -3,11 +3,11 @@
 Code Coverage Report:

  Summary:
-  Classes:  0.00% (0/2)
-  Methods: 50.00% (3/6)
-  Lines:   50.00% (3/6)
+  Classes: 50.00% (1/2)
+  Methods: 83.33% (5/6)
+  Lines:   83.33% (5/6)

 App\Sample1
-  Methods:  66.67% ( 2/ 3)   Lines:  66.67% (  2/  3)
+  Methods: 100.00% ( 3/ 3)   Lines: 100.00% (  3/  3)
 App\Sample2
-  Methods:  33.33% ( 1/ 3)   Lines:  33.33% (  1/  3)
+  Methods:  66.67% ( 2/ 3)   Lines:  66.67% (  2/  3)

MR のコメントにカバー率の差分を追記する

↑だけだとジョブのログに出力されるだけなので、MR の画面からパッと確認できなくて不便です。ので、カバー率の差分を MR のコメントとして追記するようにします。

まず、次のようなスクリプトを ci/coverage-reporter.sh のようなファイル名で用意します。

#!/bin/sh

: ${COVERAGE_REPORTER_TOKEN:?}
: ${CI_API_V4_URL:?}
: ${CI_PROJECT_ID:?}
: ${CI_MERGE_REQUEST_IID:?}

user_id=$(
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" "$CI_API_V4_URL/user" \
  | jq .id -r
)

note_id=$(
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" \
    "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \
  | jq '.[] | select(.author.id|tostring == $user_id).id' --arg user_id "$user_id" -r
)

diff=$(cat -)

if [ -z "$diff" ]; then
  diff="no difference in code coverage"
fi

body=$(printf '```diff\n%s\n```\n' "$diff")
data=$(jq -n '{body:$body}' --arg body "$body")

if [ -z "$note_id" ]; then
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" \
    "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \
    -X POST -H content-type:application/json --data-raw "$data" \
  > /dev/null
else
  curl -s -H "PRIVATE-TOKEN: $COVERAGE_REPORTER_TOKEN" \
    "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes/$note_id" \
    -X PUT -H content-type:application/json --data-raw "$data" \
  > /dev/null
fi

標準入力から入ってきたテキストを、まだ MR にコメントしていなければ新規コメントを、既に MR にコメントしているならそのコメントの修正を、行うスクリプトです。

MR へのコメントのためにアクセストークンが必要なので、プロジェクトレベルのアクセストークンなどを作成の上、Gitlab CI の Variable で COVERAGE_REPORTER_TOKEN という名前で設定しておく必要があります。

そして、coverage-mr ジョブの script の最後を次のように修正します。

coverage-mr:
  # ...snip...
  script:
    - if test -e coverage.txt; then mv -f coverage.txt coverage.orig.txt; fi
    - *coverage_script
    #- if test -e coverage.orig.txt; then diff -u -w coverage.orig.txt coverage.txt || true; fi
    - if test -e coverage.orig.txt; then (diff -u -w coverage.orig.txt coverage.txt || true) | ci/coverage-reporter.sh; fi

これで次のようなコメントが MR に追記されるようになります。

f:id:ngyuki:20210116141411p:plain

さいごに

Github で coverallscodecov などの SaaS を組み合わせればテストのカバレッジの可視化やカバー率の差分が記録できるらしいです。

例えば PHPUnit だと次のように codecov でカバー率の差分が PR に追記されています。

Gitlab 単体でも PHPUnit のコードカバレッジの HTML レポートをアーティファクトに保存したり Gitlab Pages で公開したりすればカバレッジの可視化には十分なのですが・・・コードカバー率の差分も表示できると有用かもー、と思ったので簡易的にやってみました。

欠点としては・・・MR のコメントに追記すると、カバー率が減っていても増えていても変わらなくても、最初の1回だけは常にメールで通知されてきます。レビュー時の参考情報ぐらいの位置づけにしたかったので通知は不要なのですが。

なお、今回はキャッシュを使って master のジョブの実行結果を MR のジョブで取得するようにしましたが、下記の API を使えばアーティファクト経由でもできそうです。

tmpfile で削除されたファイルのストリームリソースを返していて一見ダメそうだけど実は大丈夫なメモ

tmpfile の使い方の問題でぱっと見うまく動かないように見えて、でも実はうまく動くメモ。

問題のコード

要約すると次のような処理でした。

  1. tmpfile で一時ファイルを作成
  2. stream_get_meta_data でファイル名を得る
  3. そのファイル名に ZipArchive で書き込み
  4. 同じファイル名を fopen で開いてメソッドから返す(実際には Response オブジェクトだったけど)

コードにすると次のような感じです(実際のコードをかなり簡略化しています)。

<?php
function f()
{
    // tmpfile で一時ファイルを作成
    $tmp = tmpfile();

    // stream_get_meta_data でファイル名を得る
    $filename =  stream_get_meta_data($tmp)['uri'];

    // そのファイル名に ZipArchive で書き込み
    $zip = new ZipArchive();
    $zip->open($filename, ZipArchive::CREATE);
    $zip->addFromString('a.txt', 'A');
    $zip->close();

    // 同じファイル名を fopen で開いてメソッドから返す
    return fopen($filename, 'r');
}

うまく動かない気がした理由

tmpfile はとても便利で、これで作成された一時ファイルは自動で削除されるので後始末の必要がありません。

この削除は tmpfile が返すストリームリソースのデストラクタ的なものによって行われます(オブジェクトではないのでデストラクタとは呼ばないとは思うけれども)。PHP は参照カウント式の GC によって $tmp に入っているリソースはメソッドのスコープを抜けたタイミングで直ちに破棄されます。なのでこのファイルはメソッドから抜けたときには削除されており、fopen で同名のファイルを開いてメソッドから返したとしてもそのファイルは既に削除されていて存在しません。

ので、一見ダメそうですが・・・実は大丈夫です。

うまく動く理由

ファイルシステム上にファイル名として存在するファイルは消えていたとしても、そのファイルの実体はそのファイルのディスクリプタが全部無くなるまで存在しているので、ファイルは削除されてしまっているけれども、fopen で開いたファイルの実体は残っていて読み書きできます。

<?php
file_put_contents('a.txt', 'abc');
$stream = fopen('a.txt', 'r+');
unlink('a.txt');
clearstatcache();
var_dump(file_exists('a.txt')); //=> false
fwrite($stream, 'A');
rewind($stream);
var_dump(fgets($stream)); //=> Abc

このようなコードで、削除して存在しないはずのファイルへの読み書きが出来ることがわかります。なお、このとき lsof で見てみると /path/to/a.txt (deleted) などと表示されて、もう削除されてることがわかります。

tmpfile をそのまま返す → ダメ

なお、同名のファイルを fopen しなくても tmpfile が返したストリームリソースをそのまま返せばよいのでは・・・

<?php
function f()
{
    $tmp = tmpfile();
    $filename =  ($tmp)['uri'];
    $zip = new ZipArchive();
    $zip->open($filename, ZipArchive::CREATE);
    $zip->addFromString('a.txt', 'A');
    $zip->close();
    return $tmp;
}

と思ったのですがこれはうまく動作しません。ZipArchive がファイルを上書きしたときに i-node が変わるため、元の tmpfile が返したストリームリソースとは別の実体になってしまうためです。

strace で見た感じ、ZipArchive(の中の libzip ?) はファイルを直接上書きするのではなく、サフィックスにランダムな文字列を付け足したファイル名で保存した上で rename で元のファイルを上書くようになっています。

そのため tmpfile で作成されたファイルと ZipArchive によって書き込まれたファイルは別になるので、これはうまく動きません。メソッドが返したストリームリソースの中身は空っぽです。

tmpfile の代わりに tempnam を使う

tmpfile の代わりに tempnam を使っても似たようなことができます。tmpfile のように自動では削除されないので finally で unlink する必要がありますけど。

<?php
function f()
{
    $filename = tempnam(sys_get_temp_dir(), 'php-zip');
    try {
        $zip = new ZipArchive();
        $zip->open($filename, ZipArchive::CREATE);
        $zip->addFromString('a.txt', 'A');
        $zip->close();
        return fopen($filename, 'r');
    } finally {
        unlink($filename);
    }
}

さいごに

最初のコードをぱっと見たときにはギョッとしましたが、よくよく考えてみれば問題なく、なるほどなーと思った事例でした(自分が書いたコードではない)。

なお、最後の tempnam の例は tmpfile よりも記述量は増えますが、stream_get_meta_data($tmp)['uri'] のような知らなければなんのこっちゃなコードと比べるとなにをやっているか明白です。もちろん tmpfile によってメソッドから抜けたタイミングでファイルが削除されることを理解して使う分には tmpfile でも良いと思います。

CloudWatch に SSL 証明書の有効期限をメトリクスとして入れて有効期限を監視する

クライアント認証のために EC2 インスタンス上で Let's Encrypt の証明書で https しているサーバがあり、CloudWatch Alarm でその証明書の有効期限の監視をしたかったのでそのメモ。

CLI で証明書の有効期限のチェック

openssl s_client-attime でエポック秒を指定すれば現在日時ではなくその日時で有効期限がチェックされます。さらに -verify_return_error を付ければ検証失敗時は終了コードが非0になるので、日時指定の有効期限チェックだけなら下記でできます。

# 有効期限を10日でチェック
openssl s_client -connect localhost:443 \
  -attime $(( $(date +%s) + 24*60*60*10)) \
  -verify_return_error </dev/null

ネットワーク越しではなくローカルの証明書をチェックするなら openssl verify-attime でも同じです。ただし証明書のチェーンも検証されるため、次のように -untrusted で中間証明書を指定する必要があります。certbot などでローカルに保存された証明書をチェックするならこれでも良いかもしれません。

# 中間証明書を -untrusted に指定して検証
openssl verify \
  -untrusted intermediate.crt \
  -attime $(( $(date +%s) + 24*60*60*10)) \
  server.crt

openssl x509-checkend でも有効期限のチェックができます。この場合は現在日時からの相対で指定します。

# ネットワーク越しにチェック
openssl s_client -connect localhost:443 </dev/null | openssl x509 -checkend $((24*60*60*10))

# ローカルの証明書をチェック
openssl x509 -in server.crt -checkend $((24*60*60*10))

CLI で証明書の有効期限の残日数を取得

前述の方法で日付指定の有効期限のチェックはできましたが、残りの有効期限の日数をメトリクスとして CloudWatch に保存したかったので、次のように openssl x509-enddate で終了日時を抜き出して date -d で日時をパースして現在日時からの差分を得るようにしました。

notAfter=$(
  openssl s_client -connect localhost:443 < /dev/null 2> /dev/null |
    openssl x509 -noout -enddate |
    sed -n -e '/^notAfter=/{
      s/^notAfter=//
      p
    }'
)
expire=$(date -d "$notAfter" +%s)
now=$(date +%s)
days=$(bc -l <<< "scale=4; ($expire - $now) / 24 / 60 / 60")

あとは次のように cloudwatch に放り込めば OK です。

aws cloudwatch put-metric-data \
  --region "$region" \
  --namespace 'Certificate' \
  --metric-name 'ServerCertificateExpiration' \
  --value "$days" \
  --dimensions "InstanceId=$instance_id"

さいごに

こんな感じのメトリクスが保存されます。

f:id:ngyuki:20210107205009p:plain

Let's Encrypt の証明書で certbot で自動更新しているのなら有効期限を監視してもあまり意味がない気もする。。。

CentOS 8 を CentOS Stream 8 に切り替えてみた

CentOS 8 が2021年末に終了し、それ以降も使うなら CentOS Stream に切り替える必要があるとのことなので、適当なサーバを CentOS 8 から CentOS Stream に切り替えてみました。

まずは元の CentOS 8 を最新に更新しておきます。セットアップ後にしばらく放置していたのと、丁度 8.2 から 8.3 へのアップデートも入っていたので、結構な量の更新がありました。

cat /etc/centos-release
#=> CentOS Linux release 8.2.2004 (Core)

dnf check-update
dnf update -y
#=> :
#=> Install   15 Packages
#=> Upgrade  223 Packages
#=> :

cat /etc/centos-release
#=> CentOS Linux release 8.3.2011

reboot

次に、このへんに書かれている手順の通りに作業します。

dnf install centos-release-stream
dnf swap centos-{linux,stream}-repos
dnf distro-sync
#=> :
#=> Install    12 Packages
#=> Upgrade    88 Packages
#=> Downgrade   3 Packages
#=> :

更新されるパッケージの数は 8.2 から 8.3 の数よりも少ないようですが・・Downgrade が 3 つあるのが気になる・・以下のパッケージでした。

kernel-tools       4.18.0-240.el8
kernel-tools-libs  4.18.0-240.el8
python3-perf       4.18.0-240.el8

dnf distro-sync する前は次の通りだったので確かにダウングレードのようですけど、

kernel-tools       4.18.0-240.1.1.el8_3
kernel-tools-libs  4.18.0-240.1.1.el8_3
python3-perf       4.18.0-240.1.1.el8_3

よく見ると Install で以下のカーネルが入っていました。

kernel          4.18.0-240.el8
kernel-core     4.18.0-240.el8
kernel-modules  4.18.0-240.el8

これも dnf distro-sync の前は 4.18.0.240.1.1.el8_3 だったので実質ダウングレード?

CentOS 8.3 のと CentOS Stream 8 のとでカーネルのバージョン自体は同じですけど・・うーん、どういうことなの。

何が違うのかを調べてみます。

wget https://vault.centos.org/8.3.2011/BaseOS/Source/SPackages/kernel-4.18.0-240.el8.src.rpm
wget https://vault.centos.org/8.3.2011/BaseOS/Source/SPackages/kernel-4.18.0-240.1.1.el8_3.src.rpm

rpm -ivh kernel-4.18.0-240.el8.src.rpm
mv rpmbuild kernel-4.18.0-240.el8

rpm -ivh kernel-4.18.0-240.1.1.el8_3.src.rpm
mv rpmbuild kernel-4.18.0-240.1.1.el8_3

diff -ru kernel-4.18.0-240.el8 kernel-4.18.0-240.1.1.el8_3

下記のパッチの有無の差があるようです。

  • debrand-rh-i686-cpu.patch
  • debrand-rh_taint.patch
  • debrand-single-cpu.patch

パッチの内容を見てみると・・RHEL や Red Hat という文言を CentOS Linux に書き換えてるだけのパッチでした。例えば debrand-rh-i686-cpu.patch は次のような内容でした。

--- a/arch/x86/boot/main.c      2019-03-13 04:04:53.000000000 -0700
+++ b/arch/x86/boot/main.c      2019-05-25 14:31:21.043272496 -0700
@@ -147,7 +147,7 @@ void main(void)

        /* Make sure we have all the proper CPU support */
        if (validate_cpu()) {
-               puts("This processor is not supported in this version of RHEL.\n");
+               puts("This processor is not supported in this version of CentOS Linux.\n");
                die();
        }

CentOS では RHEL 用に作られた kernel のソースから Red Hat の痕跡を消す必要があるのに対して、CentOS Stream ではその必要はない、ということでしょうかね。

CentOS 8 を virt-builder や cloud.centos.org のイメージを使って KVM に手っ取り早く入れる

CentOS 8 を virt-install でサクッと入れる - ngyukiの日記 のような Kickstart を使う方法はカスタマイズが柔軟ですが、その代わりやたら時間がかかります。

あり物のイメージを使って構築する方が手っ取り早いので、以下の2つのイメージで構築してその手順とかを比べてみました。

virt-builder (builder.libguestfs.org)

virt-builder centos-8.2 \
  --output /var/lib/libvirt/images/centos-8.2-builder.img \
  --arch x86_64 \
  --hostname centos-8-2-builder \
  --root-password password:password \
  --timezone Asia/Tokyo \
  --selinux-relabel

virt-install \
  --name centos-8.2-builder \
  --hvm \
  --virt-type kvm \
  --ram 2048 \
  --vcpus 1 \
  --arch x86_64 \
  --os-type linux \
  --os-variant centos8 \
  --boot hd \
  --disk path=/var/lib/libvirt/images/centos-8.2-builder.img \
  --network network=default \
  --graphics none \
  --serial pty \
  --console pty \
  --import
parted -l
# Model: Virtio Block Device (virtblk)
# Disk /dev/vda: 6442MB
# Sector size (logical/physical): 512B/512B
# Partition Table: gpt
# Disk Flags: pmbr_boot
#
# Number  Start   End     Size    File system     Name  Flags
#  1      1049kB  2097kB  1049kB                        bios_grub
#  2      2097kB  1076MB  1074MB  ext4
#  3      1076MB  1721MB  645MB   linux-swap(v1)        swap
#  4      1721MB  6441MB  4721MB  xfs

df -h
# Filesystem      Size  Used Avail Use% Mounted on
# /dev/vda4       4.4G  1.3G  3.2G  29% /
# /dev/vda2       976M  134M  776M  15% /boot

getenforce
# Enforcing

cat /proc/cmdline | tr ' ' '\n'
# BOOT_IMAGE=(hd0,gpt2)/vmlinuz-4.18.0-193.6.3.el8_2.x86_64
# root=UUID=5e65e2b1-bd66-4404-9403-b2a5825a2c14
# ro
# console=tty0
# rd_NO_PLYMOUTH
# crashkernel=auto
# resume=UUID=abefcabc-5e1d-41cb-92f6-cc0230dad69d
# console=ttyS0,115200

cloud.centos.org

cd /var/lib/libvirt/images/
curl https://cloud.centos.org/centos/8/x86_64/images/CentOS-8-GenericCloud-8.2.2004-20200611.2.x86_64.qcow2 -O

virt-install \
  --name centos-8.2-generic \
  --hvm \
  --virt-type kvm \
  --ram 2048 \
  --vcpus 1 \
  --arch x86_64 \
  --os-type linux \
  --os-variant centos8 \
  --boot hd \
  --disk path=/var/lib/libvirt/images/CentOS-8-GenericCloud-8.2.2004-20200611.2.x86_64.qcow2 \
  --network network=default \
  --graphics none \
  --serial pty \
  --console pty \
  --import \
  --noreboot

virt-customize -d centos-8.2-generic \
  --hostname centos-8-2-generic \
  --root-password password:password \
  --timezone Asia/Tokyo \
  --selinux-relabel

virsh start centos-8.2-generic
virsh console centos-8.2-generic

parted -l
# Model: Virtio Block Device (virtblk)
# Disk /dev/vda: 10.7GB
# Sector size (logical/physical): 512B/512B
# Partition Table: msdos
# Disk Flags:
#
# Number  Start   End     Size    Type     File system  Flags
#  1      1049kB  8390MB  8389MB  primary  xfs          boot

df -h
# Filesystem      Size  Used Avail Use% Mounted on
# /dev/vda1       7.9G  1.3G  6.6G  16% /

getenforce
# Enforcing

cat /proc/cmdline | tr ' ' '\n'
# BOOT_IMAGE=(hd0,msdos1)/boot/vmlinuz-4.18.0-193.6.3.el8_2.x86_64
# root=UUID=7295907d-61c6-49b5-8687-5a6ae8855f6b
# ro
# console=ttyS0,115200n8
# no_timer_check
# net.ifnames=0
# crashkernel=auto

さいごに

cloud.centos.org なら cloud-init が有効なので meta-data と user-data の ISO を用意できるのならカスタマイズは柔軟です。ただ KVM なら virt-builder や virt-customize でカスタマイズするほうが手っ取り早いので cloud-init は別になくても良いかもしれません。

cloud.centos.org のイメージのほうがパーティション 1 つだけで男前ですが、サイズが 10GB もあって大きすぎ感あります。小さいものを大きくするのは簡単でもその逆は難しいので、イメージのサイズはもっと小さく作成されているほうが嬉しいです。

virt-builder の方はパーティションがいくつか分かれています。ベアメタルサーバならともかく仮想サーバならスワップ用にパーティションを切らなくてもスワップ用のボリュームをアタッチすれば良いと思うし、LVM するわけでもないので /boot 分けなくても良いと思うし、ルートボリュームがそんなでかいサイズになることはまず無いので GPT じゃなくせば bios_grub も必要ないと思うし(でかいディスクがほしければ別途でかいデータボリュームをアタッチする)、個人的には 1 パーティションだけになっている方が好みです。

オフィシャルのネームバリューもあるので当面は cloud.centos.org のイメージを使っておこうと思います。