餡子付゛録゛

ソフトウェア開発ツールの便利な使い方を紹介。

RustによるPostgreSQLのクライアント証明書認証(とパスワード認証)

PostgreSQLはかなり昔からTLS/SSL通信をサポートしていて、クライアント証明書認証を使うこともできます。ゼロトラストという面で高いポテンシャルがあるわけですが、アプリケーションが対応しないと活かせません。CやJavaのクライアント用ライブラリ(やフレームワーク)はクライアント証明書認証をサポートしているのですが、Rustはどうかなと思って調べて試してみました。

幸い、tokio_postgresクレートとrustlsクレートの組み合わせで、TLS/SSL通信もクライアント証明書認証も扱うことができました。Rustの練習用リポジトリーのpg-tls-cc/ディレクトリーに確認に使ったソースコードを置いておきました。プライベート認証局によるPostgreSQLのクライアント証明書認証のセットアップができているLinux環境で動きます。平文でのコネクションもあるので、RustでPostgreSQLの接続に悩んでいる人に役立つかもしれません。ただし、rustlsを使ったため秘密鍵パスフレーズには対応していません*1

コネクションをつないだ後は、平文と暗号通信で処理を同じにしたかったのですが、暗号通信クラスのスーパークラスが平文クラス…と言うようなオブジェクト指向設計では無いので、条件分岐が微妙にややこしい事になりました。戻り値の型も親子関係がないので、片方のエラーはそのまま戻り値とし、もう片方のエラーはstd::error::Errorのトレイトの構造体を新たにつくってBoxに入れて戻り値にするような処理になってしまい、やり方を間違っている気がかなりしています。Rustは難しいですね。

*1:opensslクレートを使えば解決できるようですが、rustls_pemfileのアップデートを待つことにします

プライベート認証局によるPostgreSQLのクライアント証明書認証

管理が煩雑なので流行っていないわけですが、PostgreSQLのクライアント証明書接続を試してみたのでメモ書きを残しておきます。

OpenSSLによる証明書の作成(と管理)

クライアント証明書接続とは、ユーザー・パスワードの代わりにデジタル証明書を用いる認証方式です。使いまわしからのパスワード漏洩や、フィッシング詐欺や中間者攻撃に強いです。ただし、管理するデジタル証明書と秘密鍵が多く必要で、秘密鍵が漏洩した場合は失効リストを作成することになるので、管理が煩雑です。手順を箇条書きにすると、

  1. 認証局(CA)が秘密鍵と証明書署名要求(ルートCSR)を作成し、自己署名してルート証明書を作成
  2. サーバー管理者がサーバー秘密鍵とサーバーCSRを作成し、サーバーCSRをCAに送る
  3. CAはサーバーCSRに署名、サーバー証明書を作成し、ルート証明書サーバー証明書をサーバー管理者に送る
  4. サーバー管理者はサーバーの設定ファイルに、ルート証明書とサーバー秘密鍵サーバー証明書の位置を書く
  5. DBユーザーはクライアントのクライアント秘密鍵とクライアントCSRを作成し、クライアントCSRをCAに送る
  6. CAはクライアントCSRに署名、クライアント証明書を作成し、ルート証明書とクライアント証明書をユーザーに送る
  7. DBユーザーは、ルート証明書とクライアント秘密鍵とクライアント証明書の位置を指定して、クライアントをサーバーに接続させる

となるわけです。認証局やサーバーはもちろん、DBユーザーもOpenSSLの使い方に習熟していないといけません。ここでのDBユーザーは業務アプリケーションを開発するプログラマーになるので、不可能ではないはずなんですが。

プライベート認証局秘密鍵CSRの作成

概念的には数ステップの作業があるのですが、opensslのコマンド一行でできます。途中で作られるCSRは隠蔽されて見えません。

export REALM=DevRealm
export CA_CN=ca_${REALM}
openssl req -x509 -newkey rsa:4096 -keyout ${CA_CN}-key.pem -out ${CA_CN}-crt.pem -days 400 -nodes -subj "/CN=${CA_CN}"
ls -l
合計 8
-rw-r--r-- 1 root root 1814  2月  2 22:56 ca_DevRealm-crt.pem
-rw------- 1 root root 3272  2月  2 22:56 ca_DevRealm-key.pem

証明書の中身を確認しましょう。

openssl x509 -text -noout -in ${CA_CN}-crt.pem | head
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            52:a6:2e:4b:d3:66:e0:64:fa:19:bb:7d:34:5d:12:20:21:c3:de:e3
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN = ca_DevRealm
        Validity
            Not Before: Feb  2 13:32:31 2026 GMT
            Not After : Mar  9 13:32:31 2027 GMT

秘密鍵

openssl rsa -text -noout -in ${CA_CN}-crt.pem | less

で中身を確認できますが、特段、見る必要はないと思います。

OSからルート証明書を見られるようにする

PostgreSQLの動作には必要ないのですが、OSからルート証明書を見られるようにしておくと便利かもしれません。

# OSの所定位置に証明書をコピーして、アップデート・スクリプトを走らせる(Ubuntu Linuxの例)
# sudo apt-get install -y ca-certificates # 多分インストールされている
sudo cp ${CA_CN}-crt.pem /usr/local/share/ca-certificates
sudo update-ca-certificates

サーバー秘密鍵とサーバーCSRの作成

サーバー管理者の作業です。Ubuntu LinuxのaptでPostgreSQLをインストールした場合は、ユーザーpostgresになります。

mkdir ~/ssl
cd ~/ssl
pwd
/var/lib/postgresql/ssl

ここではSERVER_CNにlocalhostを入れていますが、db.example.comのようなFQDN(Fully Qualified Domain Name)にします。

export SERVER_prefix=pgsql
export SERVER_CN=localhost
openssl req -new -newkey rsa:4096 -nodes -keyout ${SERVER_prefix}-key.pem -out ${SERVER_prefix}.csr -subj "/CN=${SERVER_CN}"
ls -l
合計 8
-rw------- 1 postgres postgres 3272  2月  2 22:57 pgsql-key.pem
-rw-r--r-- 1 postgres postgres 1586  2月  2 22:57 pgsql.csr

サーバーCSRを署名

CAの作業です。X509v3 Subject Alternative Nameを指定して署名します。これでIPアドレスでアクセスした場合も有効になります*1。また、バージョン1だとサーバーソフトウェアによっては拒絶するので、バージョン3にしておくのは悪いことではないです。

export REALM=DevRealm
export CA_CN=ca_${REALM}
export SERVER_prefix=pgsql
export SERVER_CN=localhost
export SERVER_IP=127.0.0.1
echo "subjectAltName = DNS:${SERVER_CN}, IP:${SERVER_IP}" > san.txt
openssl x509 -req -in ${SERVER_prefix}.csr -CA ${CA_CN}-crt.pem -CAkey ${CA_CN}-key.pem -CAcreateserial -days 365 -out ${SERVER_prefix}-cert.pem -extfile san.txt

中身は

openssl x509 -text -noout -in ${SERVER_prefix}-cert.pem | less

と確認できます。

クライアント秘密鍵とクライアントCSRの作成

DBユーザーの作業です。ここではpgusrになります。

CLIENT_CNはPostgreSQLのユーザー名にしておくのが簡便です。サーバーのmap設定を頑張れば、CNとDBユーザー名が不一致でも良いのですが、作業が多くなります。

mkdir ~/.ssl
cd ~/.ssl
export CLIENT_prefix=pgclient
export CLIENT_CN=pgusr # PostgreSQLのユーザー名(今回はLinuxユーザー名と同一)
openssl req -new -newkey rsa:4096 -nodes -keyout ${CLIENT_prefix}-key.pem -out ${CLIENT_prefix}.csr -subj "/CN=${CLIENT_CN}"
ls -l
合計 8
-rw------- 1 pgusr pgusr 3272  2月  2 23:00 pgsql-key.pem
-rw-r--r-- 1 pgusr pgusr 1586  2月  2 23:00 pgsql.csr

d;nodesはパスフレーズをつけないというオプションです。

秘密鍵パスフレーズの設定

パスフレーズを設定/更新したくなった場合は、

openssl rsa -des3 -in ${CLIENT_prefix}-key.pem -out ${CLIENT_prefix}-key.pem

でできます。やはり要らない場合は、

openssl rsa -in ${CLIENT_prefix}-key.pem -out ${CLIENT_prefix}-key.pem

で消せます。

クライアントCSRを署名

CAの作業です。特定のホスト名やIPアドレスがないものとして、バージョン1証明書にしておきます。PostgreSQLで使う分には問題ないです…と思っていたのですが、Rustのrustlsクレートのバージョン0.23以降が受け付けないので、バージョン3のものに修正しました。

export REALM=DevRealm
export CA_CN=ca_${REALM}
export CLIENT_prefix=pgclient
export CLIENT_CN=pgusr
openssl x509 -req -in ${CLIENT_prefix}.csr -CA ${CA_CN}-crt.pem -CAkey ${CA_CN}-key.pem -CAcreateserial -days 365 -out ${CLIENT_prefix}-cert.pem -extfile <(echo "basicConstraints=CA:FALSE")

証明書失効リスト(CRL)の作成

例もしくは練習として、シリアルナンバーの記録ファイルcrlnumber、失効証明書一覧index.txt、設定ファイルca_DevRealm.confを作成した後、pgsql-cert.pemを失効として失効証明書一覧を更新して、さらにCRLをファイル名ca_DevRealm-crl.pemで作成します。

touch index.txt
echo "01" > crlnumber
echo "[ ca ]
default_ca = CA_default

[ CA_default ]
database = index.txt
crlnumber = crlnumber
default_md = default
policy = policy_any

[ policy_any ]
domainComponent = optional
countryName = optional
stateOrProvinceName = optional
localityName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional" > ${CA_CN}.conf
openssl ca -keyfile ${CA_CN}-key.pem -cert ${CA_CN}-crt.pem -revoke ${SERVER_prefix}-cert.pem -config ${CA_CN}.conf
openssl ca -gencrl -out ${CA_CN}-crl.pem -keyfile ${CA_CN}-key.pem -cert ${CA_CN}-crt.pem -crldays 30 -config ${CA_CN}.conf

これでデジタル証明書の漏洩などへの心の準備はよいでしょう。なお、CRLはサーバーとクライアントに配布し、設定する必要があります。

PostgreSQLの設定

秘密鍵2つと証明書3つが揃ったので、DBMSPostgreSQL Server)とクライアント(今回はpsql)で使ってみましょう。

サーバー設定

Ubuntu LinuxのaptでインストールしたPostgreSQLを使います。

SSL関連ファイル配置

/var/lib/postgresql/ssl に pgsql-key.pem はあるので、CAから渡される ca_DevRealm-crt.pem と pgsql-cert.pem を配置します。

cd /var/lib/postgresql/ssl
ls -l
-rw-r--r-- 1 postgres postgres 1684  2月  3 01:57 ca_DevRealm-crt.pem
-rw-r--r-- 1 postgres postgres 1854  2月  3 02:00 pgsql-cert.pem
-rw------- 1 postgres postgres 3268  2月  3 01:57 pgsql-key.pem
-rw-rw-r-- 1 postgres postgres 1586  2月  3 01:57 pgsql.csr

pgsql-key.pemは他者に読まれてはいけない一方、PostgreSQLを動かすpostgresユーザーからは読めないといけないので、パーミッションには注意してください。

postgresql.confの設定

rootで作業になります。sudo -sしたあと、viか何かでpostgresql.confの4行を以下のように書き換えましょう。postgresql.conf は、PostgreSQL 16であれば、/etc/postgresql/16/main にあります。

ssl_ca_file = '/var/lib/postgresql/ssl/ca_DevRealm-crt.pem'
ssl_cert_file = '/var/lib/postgresql/ssl/pgsql-cert.pem'
ssl_key_file = '/var/lib/postgresql/ssl/pgsql-key.pem'
listen_addresses = '*'

listen_addressesは直接関係ないのですが、デフォルトではlocalhostのみになっており、SSLの運用設定とはあわないはずです。

pg_hba.confの設定

postgresql.conf と同じディレクトリーにあるpg_hba.confの一行をコメントアウトして、hostをhostssl(TLS必須)に、scram-sha-256をcert(証明書認証必須)にした行を加えます。127.0.0.1/32のところは運用にあわせて変えてください。

#host    all             all             127.0.0.1/32            scram-sha-256
hostssl    all             all             127.0.0.1/32           cert
接続用の設定

接続テスト用のデータベースempty_dbを用意します。

postgresユーザーになってPostgreSQLにログインします。

psql

pguserがまだPostgreSQLのユーザーでない場合は、ロールをつけておきましょう。

CREATE ROLE pguser LOGIN;
ALTER USER pguser with encrypted password 'u0b2u1n9t2s3u';

所有者がpguserのempty_dbを作成します。

createdb -E utf8 -O pguser empty_db
PostgreSQLの再起動

設定ファイルを書き換えたら、PostgreSQLを再起動します。serviceコマンドはメッセージが弱いので、おかしいなと思ったら /var/log/postgresql/*.log を参照しましょう。

service postgresql restart

psqlでクライアント証明書認証を使って接続

~/.sslに必要なファイルを集めます。CAから渡されるca_DevRealm-crt.pemとpgclient-cert.pemの配置を忘れないようにしましょう。

cd ~/.ssl
ls -l
-rw-r--r-- 1 pgusr pgusr 1684  2月  3 02:31 ca_DevRealm-crt.pem
-rw-r--r-- 1 pgusr pgusr 1688  2月  3 02:10 pgclient-cert.pem
-rw------- 1 pgusr pgusr 3272  2月  3 02:09 pgclient-key.pem
-rw-rw-r-- 1 pgusr pgusr 1590  2月  3 02:09 pgclient.csr

psqlは~/.sslと言うような指定を受け付けないので、環境変数を使って展開します。

export REALM=DevRealm
export CA_CN=ca_${REALM}
export CLIENT_prefix=pgclient
export CLIENT_CN=pgusr
export KD=~/.ssl
psql "host=localhost dbname=empty_db user=${CLIENT_CN} sslmode=verify-full sslcert=$KD/${CLIENT_prefix}-cert.pem sslkey=$KD/${CLIENT_prefix}-key.pem sslrootcert=$KD/${CA_CN}-crt.pem"
psql (16.11 (Ubuntu 16.11-0ubuntu0.24.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help.

上手く行きました。

秘密鍵パスフレーズがついている場合

コネクションストリングに、sslpassword=${PASSWORD}を追加します。

まとめ

ゼロトラストの実践になります。通信の暗号化とDBMSの認証*2だけで十分な気もしますが、DBMSとクライアントのネットワーク上の位置が離れている場合には現実的な意味が出てくるでしょう。また、TLSを使ったソリューションは色々とありますが、使い方はだいたい似ているので慣れておくとよいことはあるかもしれません。

*1:サーバ名がIPアドレスの場合の証明書作成 / サブジェクト代替名 (SAN) の設定方法 | 晴耕雨読

*2:pg_hba.confはhostsslとscram-sha-256の組み合わせで記述し、クライアントはsslmode=verify-fullで接続する。

Rustによるセッションを使わないWeb APIのKeycloak/OpenID Connectによる認証

昨年終盤に生成AIの助けを借りてRustでマイクロサービス(というかWeb API)を書こうという話をチラホラ見かけて、そのような方法で認証まわりまで書いたのでメモ書きとコードを残して起きたいと思います。

設計指針

機能確認と練習用のコードなのですが、

  • OIDCで認証することで、シングルサインオンSSO)に対応する
  • IAMのKeycloakを用いる
  • Web API(モドキ)とバニラJavaScript*1によるコード
  • ログインとログアウトをつける
  • セッションを使わない*2
  • XSSCSRF対策をする*3
  • ゼロトラスト(TLSSSL)での運用可能)
  • Rustを使う*4

と言う指針で書きました。パブリッククラウドを用いたちょっとしたコールドスタンバイしておく業務アプリケーションを念頭に置いています。Vibe Codingとまでは行きませんが、生成AIと相談しながら書いたので、あまりRustについては分かっていません。

Keycloakのインストールとセットアップ

KeycloakはJavaのサーバーアプリケーションで、アプリケーションサーバーとセットにしたバイナリーが配られています。JVMがインストールされていれば、付属のシェルスクリプトかバッチファイルで簡単に起動できます。

wget https://github.com/keycloak/keycloak/releases/download/26.5.2/keycloak-26.5.2.zip
unzip keycloak-26.5.2.zip
cd keycloak-26.5.2
./bin/kc.sh start-dev --http-host=127.0.0.1 # 開発者モードで起動

開発者モードで起動しないと、TSLの設定やら何やらとエラーになります。デフォルトは0.0.0.0ですが、127.0.0.1を指定しておくとネットワークの他の端末からのアクセスは拒否してくれます。

最初の起動時にウェブから管理者のIDとパスワードを設定できます。作成したら

  1. RealmとしてDevRealmをつくります。
  2. そうなっていなければ、設定対象のRealmをmasterからDevRealmに切り替えます(Manage realmsの一覧で、DevRealmをクリックすると、Current realm印がつきます)。
  3. Usersでbrowser-userをつくり、Crendentialsでパスワードを設定します。一時パスワードのチェックは外しておきましょう。
  4. Clientsでrust-web-appをつくります

rust-web-appの以下の項目を設定しましょう。

設定項目
Client authentication On*5
PKCE Method Choose...*6
Valid redirect URIs http://localhost:18080/callback

Web APIの開発であれば、以上3点以外はデフォルトで済むと思います。

登録した後、Clients → Client details → rust-web-app → CredentialsでClient Secretを確認できるのでコピーします。

Rustのコード

500行近くになったので、GitHubのリポジトリーにアップロードしました。ソースコード中のClient Secretの値を書き換え(るか環境変数をセットし)、axum-oidcディレクトリーでcargo runをするとRustで書いたサーバーが起動し、Keycloakを使って上でつくったbrowser-userでログインができます。

XSSCSRFへの対策

あまり変わったことはしていませんが、JWT Tokenの保存先問題は、TokenをHTTP-OnlyのCookieに保存しつつ、Token+秘密のSALTのハッシュ値JavaScriptに渡して、それをAuthorization: bearerにつけて送信させる事にしました*7

  • XSSでTokenが流出する可能性が低くなる(HTTP-OnlyなのでJavaScriptから読めない)
  • Authorizationヘッダーを使うので、CSRFで攻撃される可能性が無くなる
  • Tokenが流出しても、ハッシュ値が生成できないので悪用できない(Tokenはアプリごとに異なる)

ので、CookieeにTokenを入れたままでもほぼ安全になります。SHA256を突破してくる猛者がいるかもしれませんが、新宿でツキノワグマと遭遇するぐらいの確率なのであきらめましょう。

TLSSSL)への対応

コード整理のついでにTLSSSLの後継規格)に対応しました。

パスとファイル名と形式が決め打ちですが、秘密鍵(cert/key.pem)と対応する証明書(cert/cert.pem)を用意して、

CLIENT_PROTOCOL=https cargo run

と起動するとhttpsのサービスになります。Keycloakの設定のValid redirect URIsも修正がいるので御注意ください。

X.509証明書のバージョン

間接的に利用しているrustlsクレートは、Version 3のX.509証明書しか受け付けず、Version 1のものを読ませようとすると以下のエラーになります。

この練習アプリの場合は

called `Result::unwrap()` on an `Err` value: Custom { kind: Other, error: InvalidCertificate(Other(OtherError(UnsupportedCertVersion))) }

とエラーが出ます。このとき

openssl x509 -in cert/cert.pem -text -noout | head

とすれば、

Certificate:
    Data:
        Version: 1 (0x0)
        Serial Number:

となっていると思います。先人が問題解決してくれていなければ、ハマりどころでした。

残作業

PKCEに対応していないので*8、Keycloakの運用方針によっては問題が出ます。これは利用しているクレートが対応しているので、60行ぐらいで対応版がつくれます*9。PKCE_METHOD=S256 cargo runと言うように、環境変数でS256を指定して動くようにしました。

ログアウト処理がやや強引なので、可能ならば改善したいです。

バイナリーサイズ

デバッグ用の実行ファイルは101,436KB(→ TSL対応版129,748KB)で、リリース用のは3,268KB(→ TSL対応版6,172KB)でした。

感想

認証まわりが煩雑ですが、一度書いてしまえば使い回せる部分なので、開発上の障害としては大きく無いです。開発効率自体は慣れても良くなりそうにない*10ですが、JSON形式でデータを受け取ったり、JSON形式でデータを出力するのは、大きな労力なしにできるので、マルチページの大きなアプリケーションはともかく、Web APIの構築(もしくはマイクロサービスのバックエンド)には(利用者数がそう多くない業務アプリケーションでも)使えそうです。

*1:React.jsやVue.jsはもちろん、jQueryもよく知らないので。

*2:ここ数年、スケールアウトするようにサーバーがアクセス中の個々の端末の情報を管理するセッションを使うなと言う話をデジタル庁方面から聞くようになりました。

*3:セッションが使えないので、業務アプリでは一般的ではないコーディングが要ります。

*4:使った事がないとGoやJavaの方が良いとも言えないため。

*5:自動でClient Id and Secretが入る

*6:Choose...を選ぶと、PKCEを使わない設定になります

*7:この方法は多くのアプリケーションでは非推奨の方法です。Sessionを使う方が遥かにシンプルに安全なコードが書けます。

*8:PKCE MethodをS256もしくはplainにしたら、Failed to deserialize query string: missing field `code`とエラーメッセージがブラウザーに表示され、KeycloakのログにMissing parameter: code_challenge_methodと記録されると思います。

*9:PKCEなしとS256対応のコードは、コメントを入れて68行で書けました。

*10:Javaと比較して、Rustは所有モデルが〜ボローチェッカーが〜というところと、それ以上にクレート(パッケージ/ライブラリ)が使っているユーザー定義型がどうなっているのか追跡するのがジェネリック地獄で手間暇なので、気軽に関数を定義しづらいです。Rustの構造体にはJavaのクラスのクラスファイルのようなものがないためだと思いますが、JavaIDE(i.e. Eclipse)のようにRustのIDE(i.e. VS Code)がプロパティーやメソッドなどをコード補完してくれるかというと、そうでもないようです。

Rust/axumで書いたRESTful APIのメモリーとディスクの使用量

FaaS(i.e. AWS Lambda)にRustが向いているという話がぼちぼちとされています。学習難易度や開発効率はさておき、起動・実行時間とメモリー割当量に応じて課金されるサービスでは、高速処理で省メモリープログラミング言語に強みがあるのは確かです。

Rustの実行ファイルのコンテナーイメージのサイズが小さいことが指摘されていたので、どれぐらになるのか試してみました。データベースなどのI/OがないRESTful APIのRustコードがあったので拝借し、LinuxのDLLの部分も含めて静的リンクした実行バイナリーを作成して、Scratchからコンテナイメージをつくります。

Rustがインストールされた状態から開始します。

cargo new axum-example --vcs none
cd axum-example

参照サイトにCargo.tomlが無かったのでつくります*1

Cargo.toml

[package]
name = "axum-example"
version = "0.1.0"
edition = "2024"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

[profile.release]
lto = true # リンク時最適化(未使用関数を除去)を有効化
codegen-units = 1 # 積極的な最適化
panic = 'abort'  # パニック時は即座に終了
strip = "symbols" # stripコマンドによるデバッグ情報の削除
opt-level = "z" # サイズ重視

参照ページのソースコードをsrc/main.rsにコピペします。

Dockerfileをつくります。ソースコードが0.0.0.0:3000でリスニングするようになっているので、EXPOSE指定もします。

Dockerfile.scratch

FROM scratch
ADD target/x86_64-unknown-linux-musl/release/axum-example /
CMD ["/axum-example"]
EXPOSE 3000

あとは、

# gccの静的リンク用のツールをLinuxインストール
sudo apt install musl-tools
# musl版を利用するためにツールチェインを追加
rustup target add "$(uname -m)"-unknown-linux-musl
# 静的リンクでコンパイル
cargo build --release --target "$(uname -m)"-unknown-linux-musl
# コンテナイメージをつくる(Docker利用者はpodmanはdockerに置き換えてください)
podman build -t rust-restful-app -f Dockerfile.scratch .
# コンテナを起動する
podman run -d -p 8080:3000 rust-restful-app

とした後、

# コンテナイメージのサイズを確認
podman images

しました。

REPOSITORY                          TAG         IMAGE ID      CREATED         SIZE
localhost/rust-restful-app          latest      d2838b6ee6ec  32 seconds ago  955 kB

コンテナサイズは955KBです*2。小さいですね。

さらに、

# アプリケーションの利用メモリーを確認
ps xu | grep axum-example
uncorrelated     82033  0.0  0.0   9508   940 ?        Ssl  14:50   0:00 /axum-example
uncorrelated    82163  0.0  0.0  10208  2316 pts/1    S+   14:54   0:00 grep --color=auto axum-example

しました。予約9508KBで、確保済み物理メモリーは940KBです*3。これは待機状態で、同時アクセス数に応じて増えていくはずですが、それでも桁違いに小さい事が分かります。

*1:リリースビルド時のコンパイルオプションは、【備忘録】cargo build --releaseについてを参考にしました。

*2:Rust/actix-webの同様のアプリだと7.33 MBでした。

*3:同様のRust/actix-webのアプリだと、予約344MBで、確保済み物理メモリーは5324KBでした。

Go言語でWeb API練習アプリを書いてコンテナ化したらサイズが14MBだった件

何やら良さそうなプログラミング言語だなと思いつつJavaと用途が被るので触った事が無かったGo言語なのですが、Dockerイメージが作りやすいと言う事なので、GETパラメーターをJSONにして戻すコードを書いて試してみました。

準備

Goのインストールはされている状態から開始です。Go言語についてはA Tour of GoGo by Exampleを練習問題も含めて目を通しておきましょう。細かいところは検索と生成AIを用いて凌ぎます。

作業ディレクトリの作成とモジュールの初期化

Ubuntu Linux(と言うかLinux Mint)で作業します。

cd /var/tmp
export mod_name=example_rest 
mkdir $mod_name; cd $mod_name
go mod init $mod_name

モジュール名はGitHubのプロジェクトのURLとする事が慣習ですが、今日は練習と言うことで。

フレームワークのGinのインストール

Goのウェブアプリケーションフレームワークの代表はGinだそうで、インストールします。

go get -u github.com/gin-gonic/gin # -uはupdateの意味

依存しているパッケージも含めてインストールしてくれます。

Goアプリケーションの構築

a=1&b=2のようなGETパラメーターを、"a":["1"],"b":["2"]}のようなJSONに変換するだけのアプリケーションです。ルートにアクセスされた場合は、静的ファイルindex.htmlの内容を戻すようにします。

ソースコード

2ファイル構成です。

echo.go

package main

import (
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	r.StaticFile("/", "index.html")
	r.GET("/api", func(c *gin.Context) {
		params := c.Request.URL.Query()
		if 0 >= len(params) {
			c.JSON(200, gin.H{
				"usage": "Please add parameters such as ?a=1 to query!",
			})
		} else {
			c.JSON(200, params)
		}
	})
	r.Run(":8080")
}

index.html

<!DOCTYPE html>
<html>
<head>
<title>Echo</title>
</head>
<body>
<form action="./api">
<div>key: <input name = "key" value = "value"></div>
<div><input type = "submit" value = "click me!"></div>
</form>
</body>
</html>

Ginがパラメーターをmap型(連想配列; ハッシュ配列)にしてくれ、map型をJSONにしてくれるので、ほとんど何もしなくてよいです。ビジネスロジックとして、インプットがmap型で、アウトプットがmap型の関数をつくれば、組み合わせて、もう少し役立ちそうなものになります。環境変数から各種設定の取得やデータベースへの接続と総裁、あとはOAuthかOpenIDとJWTを使った認証を実装しておきたいところですが、後日に回します。

コンパイル

go run echo.goでコンパイルと実行をしてくれるのですが、コンテナ化のためにスリムな実行ファイルをつくります。

SRC=echo.go
DST=echo.exe
# -a -installsuffix cgo
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o $DST $SRC

CGO_ENABLED=0で、C拡張を使わない事を明示的に宣言します。C拡張を入れるとコンテナイメージをつくるときに、DLLを多数含める必要が出てきます。
GOOS=linux GOARCH=amd64で、Intel互換(というかamd64)互換プロセッサのLinuxシステムだと明示します。AWSで運用するときは、arm64のインスタンスの方が安くて速いそうで、GOARCH=arm64にすることになります。
d;ldflags "-s -w"はデバッグ情報を全削除するというおまじないです。実行ファイルがスリムになります。

コンテナ化

Dockerfileをつくってコンテナ化します。

Dockerfile.scratch

FROM scratch
ADD echo.exe /
ADD index.html /
CMD ["/echo.exe"]

Goは静的リンクの実行ファイルを生成するので、今回は2ファイルだけのコンテナイメージで済みます。参照したページでは、SSLで暗号化するときに必要なので/etc/ssl/certs/のコピーも行っていました。なお、CGOを使うのも難しくはないです。

podman build -t echo-go-app -f Dockerfile.scratch .

Dockerを利用している場合は、podmanのところをdockerに置換してください。

つくったイメージはPodman/Dockerの管理下に置かれます。

podman images
REPOSITORY                          TAG         IMAGE ID      CREATED        SIZE
localhost/echo-go-app               latest      41ac1e10578a  2 minutes ago  14 MB

14MBと小さいです。役立つアプリケーションになれば、もっと大きくなるでしょうが。なお、Hello Worldだと2MBを切りました。

コンテナ実行

環境変数GOMEMLIMITを指定しておくと、Goのメモリー利用量がそれ以下になるように、ガーベッジコレクターが動きます。厳密に指定容量以下で動くわけではないですが、パブリッククラウドに乗せるときに役立つでしょう。

podman run -e GOMEMLIMIT=100MiB -d -p 18080:8080 echo-go-app

コンテナーのTCP8080番を、ホストのTCP18080番につなぎます。ブラウザーhttp://localhost:18080/にアクセスして、動作確認します。

エクスポート

コンテナイメージをファイルにしたい場合は、以下のように出力できます。

podman save echo-go-app > echo-go-app.tar

気づかず使っているかも知れないプロファイル尤度信頼区間の計算

注意深いRユーザーはお気づきだと思いますが、glmでロジットモデルを推定したあと結果オブジェクトにconfintをかけると、waiting for profiling to be done...とメッセージが表示されます。

r_glm <- glm(ans ~ x + z, data = df02, family = binomial()) # df02の生成は後で説明
confint(r_glm)
Waiting for profiling to be done...
                2.5 %     97.5 %
(Intercept) -0.851407  0.4332838
x            1.263595  2.9298628
z           -5.110815 -2.1783024

profiling? — 意味がわかりませんよね。?confint.glmとしても、詳しい説明はありません。

左右非対称であったりします。

matrix(c(coef(r_glm), apply(confint(r_glm), 1, mean)),
    2, length(coef(r_glm)), byrow = TRUE,
    dimnames = list(c("coef", "mean(confint)"), names(coef(r_glm))))
Waiting for profiling to be done...
              (Intercept)        x         z
coef           -0.1865190 1.984496 -3.448283
mean(confint)  -0.2090616 2.096729 -3.644559

これ、プロファイル尤度信頼区間を計算していると言うメッセージです。教科書で説明してあるのは、だいたい標準誤差を元にした左右対称となるワルド信頼区間の計算方法です。

伊藤 (2019)に詳しい説明があるのでそちらを参照して頂きたいのですが、プロファイル尤度信頼区間は尤度比検定に対応した信頼区間です。プロファイル信頼区間を外れる値を帰無仮説に尤度比検定を行うと、帰無仮説は棄却されます。正規分布への漸近性を仮定しないで計算されるので、性質が良いとされています。安心して、デフォルトの値を用いましょう。

話の本題はこれで終わりですが、最尤法でワルド信頼区間とプロファイル尤度信頼区間の計算をしてみます。

データセット

DGPからデータセットを作成します。

set.seed(1234)
df02 <- with(new.env(), {
    n <- 100
    S <- matrix(c(2, 0.5, 0.5, 1), 2, 2)
    # X <- cbind(1, mvtnorm::rmvnorm(n, sigma = S))
    C <- chol(S)
    X <- cbind(1, t(t(C) %*% matrix(rnorm(ncol(C) * n), ncol(C), n)))
    beta <- c(0.5, 2, -3)
    y <- X %*% beta
    p <- 1/(1 + exp(-y))
    ans <- 1*(runif(n) < p)
    data.frame(ans, x = X[,2], z = X[,3])
})

最尤法に用いる関数

今回は後で使うのでグラディエントも用意します。

# 対数尤度関数が参照するデータ
mf <- model.frame(ans ~ x + z, df02)
X <- model.matrix(mf, mf)
ans <- model.response(mf)
# 対数尤度関数
llf <- function(beta){
    y <- X %*% beta
    p <- 1/(1 + exp(-y))
    min <- 1e-16
    r <- sum(ans*log(pmax(p, min)) + (1 - ans)*log(pmax(1 - p, min)))
    browser(expr = is.na(r) || !is.finite(r))
    r
}
# 対数尤度関数のグラディエント
llfg <- function(beta){
    sxp <- X %*% beta
    apply(sapply(1:nrow(X), \(i) X[i, ] * (ans[i] - 1 + exp(-sxp[i])/(1 + exp(-sxp[i])))), 1, sum)
}

ワルド信頼区間

まず、最尤法をかけます。

r_ml <- optim(rep(0, ncol(X)), llf, llfg, method = "L-BFGS-B", control = list(fnscale = -1), hessian = TRUE)
beta <- r_ml$par # 係数の推定量
se <- sqrt(diag(-solve(r_ml$hessian))) # 係数の標準誤差

次に、係数の標準誤差から信頼区間を求めます。

a <- 0.05 # 有意水準
lwrupr <- c(a/2, 1 - a/2)
# Wald型信頼区間(左右対象)
confint.wald <- t(sapply(1:length(beta), \(i) qnorm(lwrupr, mean = beta[i], sd = se[i])))
rownames(confint.wald) <- names(beta) <- names(se) <- colnames(X)
colnames(confint.wald) <- sprintf("%.1f%%", lwrupr*100)
print(confint.wald)
                  2.5%      97.5%
(Intercept) -0.8198741  0.4468386
x            1.1614836  2.8075079
z           -4.9013764 -1.9951880

プロファイル尤度信頼区間と微妙に異なる値が出ました。

制約付き最尤法に用いる関数

多次元の尤度関数を一次元にしたものがプロファイル尤度関数なのですが、尤度比検定に用いる制約付き最尤法に使う関数が必要になります。

pparam <- function(cp, i, v){
    # パラメーターのi番目をvとし、i番目以外はcpの値をcpの並びで使う
    p <- numeric(length(cp) + 1)
    if(1 < i) p[1:(i-1)] <- cp[1:(i-1)]
    p[i] <- v
    if(i < length(p)) p[(i + 1):length(p)] <- cp[i:length(cp)]
    p
}
pllf <- function(cp, i, v){
    # 制約をパラメーターに加えて対数尤度関数を呼ぶ
    llf(pparam(cp, i, v))
}
pllfg <- function(cp, i, v){
    # 制約をパラメーターに加えてグラディエントを計算し、制約部分以外に対応する要素を戻す
    llfg(pparam(cp, i, v))[-i]
}

さらに、ヘッシアンが必要になるので計算する関数を用意します。

# 対数尤度関数のヘッシアン
hessian <- function(beta){
    H <- matrix(NA, length(beta), length(beta))
    d <- 1e-12
    for(i in 1:length(beta)){
        b0 <- b1 <- beta
        b0[i] <- beta[i] - d
        b1[i] <- beta[i] + d
        H[i, ] <- (llfg(b1) - llfg(b0))/d/2
    }
    H
}

プロファイル尤度信頼区間の計算

まず、最尤法の結果から、プロファイル尤度信頼区間の端点における対数尤度を求めます。

lwrupr_q <- qchisq(1 - a, df = 1, lower.tail = TRUE)
lwrupr_llf <- r_ml$value - lwrupr_q/2 # 対数尤度がこの値の係数は、信頼区間の端点

ベイズ統計学で出てくる最高事後密度信用区間(HPD: highest posterior density)と同じような特性になります。

プロファイル尤度信頼区間の端点にはもう一つ条件があり、信頼区間を計算するパラメーター以外のパラメーターのグラディエントはゼロです。

パラメーターを、対数尤度と信頼区間を計算する変数以外の変数のグラディエントに移すベクトル値関数を考え、これをニュートン・ラフソン法で推定することになります。信頼区間の上限と下限だけに解は二つあるのですが、最尤推定量のグラディエントはゼロで領域が分割されるため、初期値を最尤推定量の左側にするか右側にするかで、上限と下限をそれぞれ推定できます。

lsearch <- function(i, init.p){
    r_optim <- optim(rep(0, ncol(X) - 1), pllf, pllfg, i = i, v = init.p, method = "BFGS", control = list(fnscale = -1))
    b <- pparam(r_optim$par, i, init.p)
    for(j in 1:10){ # 最大ループ回数10は一般には小さい
        b0 <- b
        # グラディエントを一行変えて、ベクトル値関数の値にする
        f <- llfg(b)
        f[i] <- llf(b) - lwrupr_llf
        # ヘッシアンを一行変えて、ベクトル値関数のヤコビアンにする
        J <- hessian(b)
        J[i, ] <- llfg(b)
        b <- b - solve(J, f) # = solve(J) %*% f
        if(any(is.na(b))) stop("b has NA!")
        if(all(b - b0 < 1e-6)) break
    }
    b[i]
}

confint.lr <- matrix(NA, length(beta), 2)
for(i in 1:length(beta)){
    confint.lr[i, ] <- c(lsearch(i, beta[i] - 2*se[i]), lsearch(i, beta[i] + 2*se[i]))
}
print(confint.lr)
          [,1]       [,2]
[1,] -0.851403  0.4338161
[2,]  1.263573  2.9297657
[3,] -5.110678 -2.1783299

微妙に違いますが、概ねconfint.glmと同じ値が出てきました。

パラメーターの数×2回のラインサーチがいるわけですが、秒もかかりませんね。なお、ニュートン=ラフソン法ではなく、最適化関数を複数組み合わせて計算しようとするともっさりとした動きになります。

固定効果モデル×一般化最小二乗法

パネルデータで固定効果モデルを推定をするときは、クラスター頑強標準誤差を求めるのがすっかり一般的になっているのですが、色々と困難を抱えた規範です。

まず、クラスター頑強標準誤差は様々な手法が提案されていて、どれを使えば良いのかわかりません*1。過剰推定バイアスがあるHC₃もしくはCR₃を使っておけば偽陽性は弾けそうですが、偽陰性が増えそうです。次に、不均一分散があることを認めているのに、一致推定量であるからと言って有効ではない均一分散を前提とした推定量を使っていてよいのかと言う問題があります。

実はクラスター頑強標準誤差を使わない方法があります。固定効果一般化最小二乗法(FE-GLS)です。Wooldridge (2002)の§10.5で紹介されていたのですが、その説明とちょっと違う方法がplmパッケージで提供されているので試してみましょう。また、系列相関ではなくクラスター(もしくは同一個体)ごとに誤差項の大きさが異なることを仮定したWLSとの組み合わせも試してみます。

データ生成関数

DGPとなる関数を作成します。

genPanelData <- function(N = 20, T = 5, coef = c(1, -2), sd = 5, type = "fegls"){
    numberToLetters <- function(ns, base = "A"){
        sapply(ns, function(n){
            s <- character(2)
            i <- 1
            repeat {
                m <- n %% 26
                n <- n %/% 26
                s[i] <- rawToChar(as.raw(as.integer(charToRaw(base)) + m - 1*(i>1)))
                i <- i + 1
                if(0 >= n || i > length(s)) break
            }
            return(paste(s[(i-1):1], collapse = ""))
        })
    }
    names <- numberToLetters(0:(N-1))
    id <- rep(names, each = T)
    id <- ordered(id, levels = names) # IDは順番をつけておく
    t <- rep(1:T, N)
    a <- rep(runif(N, min = -5, max = 5), each = T)
    x <- runif(N*T, min = 0, max = 5)
    z <- runif(N*T, min = -5, max = 0)
    e <- NULL
    if("fegls" == type){
        B <- matrix(0.5, T, T)
        diag(B) <- 1
        Gamma <- diag(N) %x% B
        e <- Gamma %*% rnorm(N*T, sd = sd)
    } else if("fewls" == type) {
        s <- runif(N, min = 0.1*sd, max = 9.9*sd)
        e <- rnorm(N*T, sd = rep(s, each = T))
    } else stop("unknown type!")
    y <- a + coef[1]*x + coef[2]*z + e
    data.frame(id, t, y, x, z)
}

Within変換

何度も処理するので、関数にします。

within_transfer <- function(x, i, IsStata = FALSE){
  m <- tapply(x, i, mean)
  if(IsStata) x <- x + mean(x)
  as.numeric(x - m[i])
}

FEGLSの推定

準備が済んだので、実際に試してみましょう。

データセット

データセットを作成します。

set.seed(1002)
T <- 4
N <- 30
coef_DGP <- c(1, -2)
df01 <- genPanelData(N, T, coef = coef_DGP, type = "fegls")

plmパッケージによる推定

固定効果モデル(within)とFE-GLSをそれぞれ推定し、比較します。クラスター頑強標準誤差も並べましょう。

FE-GLSはpggls関数で推定できます。

frml <- y ~ x + z

library(plm)
r_plm <- plm(frml, effect = "individual", model = "within", index = "id", data = df01)
r_pggls <- pggls(frml, data = df01, effect = "individual", model = "within", index = "id")
(CMP.beta <- matrix(c(coef_DGP, coef(r_plm), coef(r_pggls)), 2, 3, dimnames = list(names(coef(r_plm)), c("DGP/True", "within", "FE-GLS"))))
(CMP.se <- matrix(c(
    sqrt(diag(vcov(r_plm))),
    sqrt(diag(vcovHC(r_plm, type = 'HC0', cluster = "group"))), # cluster = "group"とtype = 'HC0'で、CR₀になる
    sqrt(diag(vcovHC(r_plm, type = 'sss', cluster = "group"))),
    sqrt(diag(vcovHC(r_plm, type = 'HC2', cluster = "group"))),
    sqrt(diag(vcovHC(r_plm, type = 'HC3', cluster = "group"))),
    sqrt(diag(vcov(r_pggls)))
    ), 2, 6, dimnames = list(names(coef(r_plm)), c("FE(within)", "HC0", "Stata-Like", "HC2", "HC3", "FEGLS"))))
  DGP/True    within    FE-GLS
x        1  1.295708  1.200888
z       -2 -2.337620 -2.195275
  FE(within)       HC0 Stata-Like       HC2       HC3     FEGLS
x  0.1835463 0.2405331  0.2456795 0.2438253 0.2471791 0.1665037
z  0.1889271 0.1653435  0.1688812 0.1679483 0.1706144 0.1618677

表記はplmパッケージにあわせてHCにしていますが、計算はCRです*2

今回は、FEGLSの方がより真の係数に近い推定量を出し、また標準誤差も小さくなっています。

ただし、set.seedをコメントアウトして何回も試してもらうとわかりますが、FEの方がよい推定量になっている事も多いです。系列相関の影響は固定効果の推定量に吸収されているわけで、改善余地はそんなに無いのでしょう。

行列計算による推定

plmパッケージの挙動を再現してみましょう。ただし、plm::pgglsでは疎行列をサポートするbdsmatrixパッケージを利用していましたが、密行列としてコレスキー分解で凌ぎます。単純に逆行列を求めないのは、特異値になるからです。

df_sorted <- df01
# Wooldridge (2002) §10.5の説明通りであれば以下だが、pgglsの実装はそうではない
# df_sorted <- subset(df01, t != T)
df_sorted <- df01[with(df_sorted, order(id, t)), ]
T_sorted <- length(levels(factor(df_sorted$t)))
mf <- model.frame(update(frml, . ~ . + 0), df_sorted)
X <- model.matrix(mf, mf)
y <- model.response(mf)
X <- apply(X, 2, within_transfer, df_sorted$id)
y <- within_transfer(y, df_sorted$id)
beta_within <- solve(t(X) %*% X) %*% t(X) %*% y
residuals <- y - X %*% beta_within
# idごとにデータを切り出し、tの分散共分散行列をつくる
M <- matrix(0.0, T_sorted, T_sorted)
for(E in tapply(residuals, df_sorted$id, \(x) x %*% t(x))) M <- M + E
M <- M / N
# Omega <- diag(N) %x% Mと描きたいが、それでは数値が不安定で逆行列の乗算ができない
# コレスキー分解を用いる
C <- chol(M) # M == t(C) %*% C
# クロネッカー積で拡大すれば、Omega = diag(N) %x% Mのコレスキー分解
Cx <- diag(N) %x% C
# beta_fegls <- solve(t(X) %*% solve(Omega) %*% X) %*% t(X) %*% solve(Omega) %*% y を行う
A <- t(X) %*% backsolve(Cx, forwardsolve(t(Cx), X)) # = t(X) %*% solve(Omega) %*% y
B <- t(X) %*% backsolve(Cx, forwardsolve(t(Cx), y)) # = t(X) %*% solve(Omega) %*% X
(beta_fegls <- as.numeric(solve(A, B)))
vcov_fegls <- solve(A)
(sqrt(diag(vcov_fegls)))
[1]  1.200888 -2.195275
[1] 0.1665037 0.1618677

係数も標準誤差もplm::pgglsと一致する値が得られました。

FE-WLSの推定

FE-WLSは勝手につけた用語です。

データセット

データセットを作成します。

set.seed(2451)
df02 <- genPanelData(N, T, coef = coef_DGP, sd = 3, type = "fewls")

行列計算による推定

パッケージでサポートされていないようなので、行列で計算します。

frml <- y ~ x + z

# applyはIDで整列した順番に値を返すのでソートしておく
df02_sorted <- df02[with(df02, order(id, t)), ]
# データフレームを行列に展開
mf <- model.frame(frml, df02_sorted)
X <- model.matrix(mf, mf)
y <- model.response(mf)

# within変換をかける
X <- apply(X, 2, within_transfer, df02_sorted$id, IsStata = T)
y <- within_transfer(y, df02_sorted$id, IsStata = T)

# Stata互換切片項あり固定効果モデル
coef_within_stata <- solve(t(X) %*% X) %*% t(X) %*% y
residuals <- X %*% coef_within_stata - y

# 切片項なし固定効果モデル
X0 <- apply(model.matrix(mf, mf)[,-1], 2, within_transfer, df02_sorted$id)
y0 <- within_transfer(y, df02_sorted$id)
coef_within <- solve(t(X0) %*% X0) %*% t(X0) %*% y0

# クラスター間で誤差の分散が異なる一方、クラスター内の系列相関が無いモデル
sigma2_i <- tapply(residuals, df02_sorted$id, \(x) sum(x^2)/length(x))
W <- diag(1/sqrt(sigma2_i[df02_sorted$id]))
Omega <- diag(1/sigma2_i[df02_sorted$id])
coef_fewls <- solve(t(X) %*% Omega %*% X) %*% t(X) %*% Omega %*% y

df <- nrow(X) - ncol(X) - N + 1 # 自由度をdummy variable estimatorにあわせる
sigma_fewls <- sqrt(sum((W %*% (y - X %*% coef_fewls))^2) / df)
vcov_fewls <- sigma_fewls^2 * solve(t(X) %*% Omega %*% X)
se_fewls <- sqrt(diag(vcov_fewls))
print(CMP <- matrix(c(NA, coef_DGP[1], coef_DGP[2], NA, coef_within, coef_within_stata, coef_fewls), 3, 4,
    dimnames = list(c("Const.", "x", "z"), c("DGP/True", "within", "Stata-Like", "FE-WLS"))))
       DGP/True    within Stata-Like    FE-WLS
Const.       NA        NA  -7.590897 -4.948839
x             1  1.740772   1.740772  1.118976
z            -2 -3.653774  -3.653774 -3.184559

係数が真の値に近づいていますね。set.seedを消して何回か試してみましたが、こちらは概ね、推定結果を改善できるようです。

クラスター(もしくは同一個体)ごとに誤差の分散が異なる不均一性があると仮定していますが、他の変数に比例するように仮定してWSLSをかけるのも、WとOmegaの計算を教科書的なFGLSと同様にすればよいので難しくはないです。

Dummy Variable Estimator

クラスター(や個体)の数Nが小さい場合は、OLSにダミーを入れてしまうのも手です。

r_lm <- lm(y ~ x + z + id, df02) # 切片項以外はwithin変換による推定と一致
r_lm_r <- lm(residuals(r_lm)^2 ~ id, df02)
r_lm_w <- lm(y ~ x + z + id, df02, weight = 1/predict(r_lm_r))

注意

標準誤差が小さくなるという意味でp-hacking的なところがあるので、仮定において有効な推定量であることを強調しないと、分析結果が疑われがちかも知れません。

CRではなくHCが欲しいときは

余談ですが、クラスター(や個体)ではなく説明変数と誤差の分散が相関するようなことを考え、クラスター頑強標準誤差(CR)ではなく不均一分散頑健標準誤差(HC)が欲しいときは、クラスター(や個体)ダミーを入れたOLSを行ってからHCを計算するのが手軽です。クラスター(や個体)の数Nが(計算機のスペックに対して)小さいときは機能します。

例えばホワイトの推定量(HC₀)を得たい場合は、

library(sandwich)
r_lm <- lm(y ~ x + z + id, df01)
sqrt(diag(vcovHC(r_lm, type = 'HC0')))[1:3]

と言う風にします。説明変数にダミーが含まれるので、within変換後にHCをかけるのと結果が異なるのには注意してください。within変換をかけない場合の方が、目的に沿うと思いますが。

lmオブジェクトにはsandwich::vcovHCが用いられ、plmオブジェクトにはplm::vcovHCが用いられるので、ヘルプを見るときには注意してください。また、summary.lmにはvcovオプションがないので、summaryでは標準誤差を補正できません。

*1:手法ごとの特性の説明は右が参考になると思います:末石 (2025) 「ミクロ計量分析講義資料 スライド2: 不均一分散とクラスターに頑健な統計的推測

*2:cluster = "group"とtype = "HC0"で、CR₀が計算されることは確認しました。