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 のイメージを使っておこうと思います。

CentOS 7 と CentOS 8 で Cron ジョブに /etc/environment の環境変数が渡るかどうかが異なる

TL;DR

  • CentOS 7 なら /etc/environment の環境変数が渡る
  • CentOS 8 で root でジョブを実行すると /etc/environment の環境変数は渡らない
  • CentOS 8 で root 以外でジョブを実行すると /etc/environment の環境変数が渡る

理由

cron のジョブで /etc/environment が有効になるのは crond が直接なにかしているわけではなく pam_env.so によるものだったと思うので、なにか違いがあるかと思って見てみたのですが・・

### CentOS 7

cat /etc/pam.d/crond
#=> account    required   pam_access.so
#=> account    include    system-auth
#=> session    required   pam_loginuid.so
#=> session    include    system-auth
#=> auth       include    system-auth

cat /etc/pam.d/system-auth | grep env
#=> auth        required      pam_env.so

### CentOS 8

cat /etc/pam.d/crond
#=> auth       include    password-auth
#=> account    required   pam_access.so
#=> account    include    password-auth
#=> session    required   pam_loginuid.so
#=> session    include    password-auth

cat /etc/pam.d/password-auth | grep env
#=> auth        required      pam_env.so

微妙に違いはあるもののどちらも pam_env.so は有効なようです。

ただ、Cron ジョブの開始時に CentOS 7 または CentOS 8 でも root 以外で実行するときは /var/log/messages に次のようなものが出力されますが、

Started Session 50 of user root.

CentOS 8 で root で実行するときはなにも出力されません。

CentOS 8 だと root で実行するときだけ pam がバイパスされているのでしょうか。

crond のバージョンはそれぞれ次のとおりでした。

  • CentOS 7 cronie-1.4.11-23.el7
  • CentOS 8 cronie-1.5.2-4.el8

GitHub の以下のリポジトリでホストされているようです。

差分を見てみたところ・・

このあたりが非常に怪しいです。

 #if defined(WITH_PAM)
-    if (cron_start_pam(pw) != PAM_SUCCESS) {
+    if (getuid() != 0 && cron_start_pam(pw) != PAM_SUCCESS) {
         fprintf(stderr,
             "You (%s) are not allowed to access to (%s) because of pam configuration.\n",
             User, ProgramName);
 #ifdef WITH_PAM
-    if ((ret = cron_start_pam(e->pwd)) != 0) {
+    /* PAM is called only for non-root users or non-system crontab */
+    if ((!u->system || e->pwd->pw_uid != 0) && (ret = cron_start_pam(e->pwd)) != 0) {
         log_it(e->pwd->pw_name, getpid(), "FAILED to authorize user with PAM",
             pam_strerror(pamh, ret), 0);
         return -1;
     }
 #endif

以下のコミットで変更されていました。

Call PAM only when it makes sense.

  • do not check PAM in crontab when uid is 0
  • do not call PAM at all in crond for system cron jobs that are run as uid 0

さいごに

CentOS というか cronie のバージョンの違いによるものでした。

Cron ジョブで pam_env.so によって /etc/environment が有効になっていたのは意図していない副作用のようなものだったということですね。

環境固有の設定値を /etc/environment に記述していて、かつ、root で実行される cron ジョブでその環境変数を参照している場合、CentOS 8 だと /etc/environment が有効にならないので注意が必要です。

実行するジョブの側で↓のように対策する必要があります。

set -a
source /etc/environment
set +a

PHP 7.4 で xhprof/xhgui プロファイリング

だいぶ前に xhgui 使ったときは、アプリ側にも xhgui のソースを入れて xhgui/external/header.php みたいなファイルを auto_prepend_file とかに設定していたと思うのですが、最新版だとだいぶ変わっていました。

xhprof

とりあえず tideways_xhprof 拡張 が必要です。Docker なら次のような感じでインストールできます。

RUN mkdir -p /tmp/tideways_xhprof &&\
    curl -fsSL https://github.com/tideways/php-xhprof-extension/archive/v5.0.2.tar.gz |\
        tar xzf - --strip-components=1 -C /tmp/tideways_xhprof &&\
    docker-php-ext-install /tmp/tideways_xhprof &&\
    rm -fr /tmp/tideways_xhprof

と思ったら xhprof 拡張も PHP 7 対応でメンテ続いていたんですね。こっちでも良いかも。

RUN apk add --no-cache --virtual .build-deps autoconf gcc g++ make &&\
    pecl install xhprof &&\
    apk del .build-deps &&\
    rm -fr /tmp/pear &&\
    docker-php-ext-enable xhprof

php-profiler

アプリ側には xhgui は必要無く、代わりに perftools/php-profiler が必要です。プロファイル結果をアプリ側から MongoDB に直接保存しようとすると xhgui-collector も必要です。

php-profiler の設定の save.handlerSAVER_UPLOAD を指定すればプロファイル結果を HTTP で xhgui へポストするようになるので楽ちんです。アプリ側に mongodb 拡張も必要ありません。

アプリケーションの index.php とか composer.json の autoload.files とかあるいは auto_prepend_file とかで次のようにプロファイラを開始します。

<?php
$config = [
    'profiler.enable' => function () {
        return true;
    },

    // xhprof や tideways_xhprof で有効にするフラグ
    'profiler.flags' => [
        // 実行時間以外に収集するメトリクス
        \Xhgui\Profiler\ProfilingFlags::CPU,
        \Xhgui\Profiler\ProfilingFlags::MEMORY,

        // ビルトイン関数をプロファイル結果煮含めない
        \Xhgui\Profiler\ProfilingFlags::NO_BUILTINS,

        // xhprof や tideways_xhprof では無意味(tideways 拡張ではサポートされているらしい)
        \Xhgui\Profiler\ProfilingFlags::NO_SPANS,
    ],

    // プロファイル結果の保存ハンドラ
    'save.handler' => \Xhgui\Profiler\Profiler::SAVER_UPLOAD,

    // プロファイル結果のアップロード先
    'save.handler.upload' => [
        // xhgui の URL を指定する
        'uri' => 'http://xhgui/run/import',
    ],
];

$profiler = new \Xhgui\Profiler\Profiler($config);
$profiler->start();

$profiler->start() でプロファイルが開始されつつ、プロファイルを停止して結果を保存するためのシャットダウンハンドラが登録されます。

シャットダウン関数の中で fastcgi_finish_request でリクエストを終了させたうえで保存ハンドラを呼ぶため、保存に時間が掛かったとしてもページの表示が遅延することはありません。ただ、別の用途でシャットダウンハンドラを利用していてシャットダウンハンドラからレスポンスを返している場合、それが機能しなくなります。その場合、$profiler->start(false) のようにプロファイルを開始すればシャットダウン関数で fastcgi_finish_request は実行されなくなります。

xhgui

xhgui は edyan/xhgui がオールインワンの Docker イメージなので楽です。docker-compose ならこれだけです。

version: '3.7'
services:
  xhgui:
    image: edyan/xhgui
    ports:
      - '8142:80'

xhgui/xhgui というイメージもありますが、これは nginx や mongodb を別に用意する必要があります。あとなぜか /var/www/xhgui/config に謎のコンフィグファイルが置かれているため、別にコンフィルファイルを用意してマウントするか、あるいは削除しないと環境変数で設定を指定できません。。。

xhgui: document to insert contains invalid key: keys cannot contain "."

追記 2020-12-16 アップストリームで 0.16 で対応されたものの edyan/xhgui は 0.14.0 なのでまだ必要

xhprof 拡張を使ったときだけ下記の問題で xhgui で mongodb への保存が失敗するようになりました。

tideways_xhprof 拡張では発生しないようです。たまたまかもしれません。

プロファイル結果のオブジェクトキーに "." が含まれていることが原因のようですが・・次のようにシャットダウン関数で修正して保存すればとりあえず大丈夫です。

<?php
$profiler = new \Xhgui\Profiler\Profiler([
    'profiler.enable' => function () {
        return true;
    },

    'profiler.flags' => [
        \Xhgui\Profiler\ProfilingFlags::CPU,
        \Xhgui\Profiler\ProfilingFlags::MEMORY,
        \Xhgui\Profiler\ProfilingFlags::NO_BUILTINS,
        \Xhgui\Profiler\ProfilingFlags::NO_SPANS,
    ],

    'save.handler' => \Xhgui\Profiler\Profiler::SAVER_UPLOAD,

    'save.handler.upload' => [
        'uri' => 'http://xhgui/run/import',
    ],
]);

$profiler->enable();

register_shutdown_function(function () use ($profiler) {
    ignore_user_abort(true);
    session_write_close();
    flush();
    fastcgi_finish_request();

    $data = $profiler->disable();
    $profile = [];
    foreach($data['profile'] as $key => $value) {
        $profile[strtr($key, ['.' => '_'])] = $value;
    }
    $data['profile'] = $profile;
    $profiler->save($data);
});

さいごに

どうやら xhgui に最近になってかなり大きな変更が入っているようです。そのため、まだちょいちょいおかしなところがあるようです。プロファイル結果のストレージに mongodb 以外に PDO も指定できるようになっているようなのですが PDO だとまともに動作しない・・とか。

ただ、以前のアプリ側にも mongodb 拡張が必要、とかと比べるとだいぶ仕込みやすくなっていると思います。

Terraform の AWS プロバイダのクレデンシャルの優先順が AWS CLI や AWS SDK と異なる

環境とか。

Terraform v0.13.4
+ provider registry.terraform.io/hashicorp/aws v3.9.0

Terraform でデプロイ対象の AWS アカウントが MFA 必須だったので aws-vault を使う前提で provider aws にはクレデンシャルの指定なし、一方で tfstate のバックエンドは MFA 無しの別の AWS アカウントの S3 バケットを利用するために profile を指定していました。

terraform {
  backend "s3" {
    profile = "ore"
    region  = "ap-northeast-1"
    bucket  = "ore-no-terraform"
    key     = "are.tfstate"
  }
}

provider "aws" {
  region  = "ap-northeast-1"
}

こんな変な構成にしていることが圧倒的に悪い気がしますが、これは期待通りにはなりません。次のように aws-vault で実行すると tfstate のバックエンドの S3 へも are のクレデンシャルでアクセスしてしまいます。

aws-vault exec are -- terraform plan

aws-vault が AWS STS から取得した一時的なクレデンシャルが AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY などの環境変数に設定されたうえで Terraform が実行されるのですが、環境変数のクレデンシャルが tf ファイルで指定している profile よりも優先されるためです。

原因

Terraform の AWS プロバイダは hashicorp/aws-sdk-go-base で AWS に接続します。

import (
    // ...snip...
    awsbase "github.com/hashicorp/aws-sdk-go-base"
    // ...snip...
)

// ...snip...

// Client configures and returns a fully initialized AWSClient
func (c *Config) Client() (interface{}, error) {
    // ...snip...
    sess, accountID, partition, err := awsbase.GetSessionWithAccountIDAndPartition(awsbaseConfig)
    // ...snip...
}

そして hashicorp/aws-sdk-go-base のこの辺りで profile よりも環境変数が優先されています。

   // build a chain provider, lazy-evaluated by aws-sdk
    providers := []awsCredentials.Provider{
        &awsCredentials.StaticProvider{Value: awsCredentials.Value{
            AccessKeyID:     c.AccessKey,
            SecretAccessKey: c.SecretKey,
            SessionToken:    c.Token,
        }},
        &awsCredentials.EnvProvider{},
        &awsCredentials.SharedCredentialsProvider{
            Filename: sharedCredentialsFilename,
            Profile:  c.Profile,
        },
    }

ちなみに環境変数 AWS_PROFILESharedCredentialsProviderprofile が指定されていないときのフォールバックになっているので、環境変数 AWS_PROFILE よりも直接指定された profile が優先されます。

// profile returns the AWS shared credentials profile.  If empty will read
// environment variable "AWS_PROFILE". If that is not set profile will
// return "default".
func (p *SharedCredentialsProvider) profile() string {
    if p.Profile == "" {
        p.Profile = os.Getenv("AWS_PROFILE")
    }
    if p.Profile == "" {
        p.Profile = "default"
    }

    return p.Profile
}

整理すると、次のような優先順になっています。

  • provider aws で指定した access_keysecret_key
  • 環境変数 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY
  • provider aws で指定した profile
  • 環境変数 AWS_PROFILE

AWS CLI はそうではありません。次のように実行すると ore のクレデンシャルが使用されていることがわかります。

aws-vault exec are -- aws sts --profile ore get-caller-identity --query Account

AWS SDK for Go も同じです。次のようなコードで確認できます。

package main

import (
    "flag"
    "fmt"
    "io/ioutil"
    "log"

    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/iam"
    "github.com/aws/aws-sdk-go/service/sts"
    awsbase "github.com/hashicorp/aws-sdk-go-base"
)

func printAccountIdAndAlias(sess *session.Session) {
    stsClient := sts.New(sess)
    identity, err := stsClient.GetCallerIdentity(&sts.GetCallerIdentityInput{})
    if err != nil {
        panic(err)
    }
    iamClient := iam.New(sess)
    aliases, err := iamClient.ListAccountAliases(&iam.ListAccountAliasesInput{})
    if err != nil && len(aliases.AccountAliases) == 0 {
        fmt.Println(*identity.Account)
    } else {
        fmt.Println(*identity.Account, *aliases.AccountAliases[0])
    }
}

func main() {
    log.SetOutput(ioutil.Discard)
    profile := flag.String("profile", "default", "profile")
    flag.Parse()

    fmt.Print("aws/aws-sdk-go: ")
    printAccountIdAndAlias(session.Must(session.NewSessionWithOptions(session.Options{
        Profile: *profile,
    })))

    fmt.Print("hashicorp/aws-sdk-go-base: ")
    printAccountIdAndAlias(session.Must(awsbase.GetSession(&awsbase.Config{
        Profile: *profile,
    })))
}

次のように実行すると違いがわかります。

aws-vault exec are -- go run main.go -profile ore

さいごに

下記の記述のよると意図されたもののようです(AWS CLI と異なっているのが意図されたものかどうかはさておき)。