PrometheusでSSL証明書の有効期限をYYMMDDの年月日形式に変換するPromQL

監視の一環として、PrometheusでSSL証明書の有効期限を追うようにしている。
blackbox_exporterでSSL証明書の有効期限が出せるので、元々死活監視だけをするつもりだったがついでにSSL証明書の有効期限も監視することにした。
blackbox_exporter内の設定はこんな感じ(一部省略)。

module
  cert_expires:
    prober: http
    http:
      method: GET

Prometheus内の設定はこんな感じ(一部省略)。

rule_files:
  - "/etc/prometheus/conf.d/notifiers/slack_notifications.yml"

scrape_configs:
  - job_name: 'cert_expires'
    scrape_timeout: 10s
    scrape_interval: 5m
    metrics_path: /probe
    params:
      module: ['cert_expires']
    file_sd_configs:
    - files:
      - conf.d/cert_expires/*.yml
    relabel_configs:
    - source_labels: [__address__]
      target_label: __param_target
    - source_labels: [__param_target]
      target_label: url
    - target_label: __address__
      replacement: 127.0.0.1:9115

file_sd_configsconf.d/cert_expires/ 以下のymlファイルを全て読むようにしている。
読み込んでいるymlファイルの中身はこんな感じ(一部省略)。

- targets:
  - https://example.com

rule_files で読み込んでいる設定ファイルの中身はこんな感じ(一部省略)。

groups:
- name: blackbox_exporter
  rules:
  - alert: SSLCertificateExpiresIn30days
    expr: round(sort((probe_ssl_earliest_cert_expiry{job="cert_expires"} - time()) / 86400), 1)
    for: 0m
    labels:
      severity: info
    annotations:
      summary: "SSL Certificate expires in 30 days"
      description: "{{ $labels.url }} expires: {{ $value }}"

通知に使っているalertmanagerの設定はこんな感じ(一部省略)。

mute_time_intervals:
- name: daily_mute_1000-0900
  time_intervals:
  - times:
    - start_time: 01:00 # JST 10:00
      end_time:   24:00 # JST 09:00

route:
  - receiver: 'cert_expires'
    continue: false
    group_by:
    - alertname
    matchers:
    - severity="info"
    - job="cert_expires"
    mute_time_intervals:
    - daily_mute_1000-0900

上では30日の設定しか記載していないが、60日前、30日前、14日前、10日前、7日前、3日以内とあり、残り有効期限がどれかに引っかかったら一日に一回、日本時間で9時〜10時の間にSlackに通知を出してくれる。
のだが、これだと有効期限が相対日数でしか出せない。
「あとn日で期限切れになるよ」というのは出せるが、「n年n月n日に期限切れになるよ」というのが出せない。

blackbox_exporterで取得できる有効期限はUnixtimeなので、 有効期限 - 現在時刻 * 86400 をしてやれば「あとn日」は出せる。
最初はこれで出していたのだが、相対日数は実際いつ期限切れになるのかわかりづらい…わかりづらくない…?
「n日後」の実際の月日がパッと見でどうしてもわかりづらいのだ。
ちょっと前に来た通知で14日後とか言われても、じゃあ実際いつ期限切れるんだっけ…?みたいな感じである。
そんなこんなで調べてみたところ、Unixtimeを直接年や月、日、時間に変える関数が存在するらしいことに気付いた。

https://prometheus.io/docs/prometheus/latest/querying/functions/

よし、これを使おう。と思い立ったのが発端で、タイトルのPromQLを作り始めた。

計算式は至って単純で、 year() に100の4乗、 month() に100の3乗、 day_of_month() に100の2乗、 hour() に100を掛け、 minute() はそのままにしておく。

で、これらをそれぞれを足すと YYYYMMDDhhmm の形式になるというもの。1
例えば有効期限日時が 2023年1月1日8時59分なら 202301010859 となる。
ただこれだと桁数が多すぎてやっぱりパッと見が分かりづらい。
で、考えて時間の情報と西暦の3桁目4桁目はいらないよね。ということで、 YYMMDD の形式にすることに。

そのために、2000に100の4乗した値を引いて、100の2乗した値で割り、余りを丸めるという処理を入れる。
たとえば 202301010859 なら -200000000000 すると 2301010859 となり、そこから更に / 10000 すると 230101.0859 となる。
更に余りを丸めることで 230101(YYMMDD) となるというわけである。

また、そのままだとUTC表記となるのだが、日本で仕事しているしUTCのままだと日にちの認識を誤ってミスる未来が見えたので、JST表記にすることにした。
これも単純に9時間プラスするだけではだめで、日付を超えた際の処理を入れる必要がある。
例えば有効期限を迎える時間が 20:59 だった場合、+9時間すると 29:59 という表記になってしまうのだ。
…まあ今現在使っている証明書は有効期限が切れる時間が08:59だから+9時間するだけでもいいのだけど…。
それだと柔軟性に欠けるので日付を超えた際の処理を入れることに。

とりあえず hour() の出力に +9 をし、その値が24を超えなければそのままとし、24を超えてしまう場合は -24 をして正しい値に直す。
そしたら日にち部分に+1するため、+100をすることにした。
文章だと非常にわかりづらいが、そういうことだ。

で、最初はこういう形でPromQLを書いてみた。

round(
  sort(
    (
      # 年を出して * 100000000 する (2022年なら 202200000000 になる)
      year(probe_ssl_earliest_cert_expiry{job="cert_expires"}) * (100 ^ 4) + 
      # 月を出して * 1000000 する (1月なら 202201000000 になる)
      month(probe_ssl_earliest_cert_expiry{job="cert_expires"}) * (100 ^ 3) + 
      # 日を出して * 10000 する (1日なら 202201010000 になる)
      day_of_month(probe_ssl_earliest_cert_expiry{job="cert_expires"}) * (100 ^ 2) + 
      (
        # +09(JST)にした際に24以上になってしまう値は調整(-24)して繰り上げる(前月最終日の場合は次月1日にならずに日に+1されてしまう)
        ((hour(probe_ssl_earliest_cert_expiry{job="cert_expires"}) + 9 > 24) - 24 + 100) * 100 or
        # +09(JST)にした際に24に達さない値には何もしない(-0)
        ((hour(probe_ssl_earliest_cert_expiry{job="cert_expires"}) + 9 < 24) - 0) * 100
      ) + 
      minute(probe_ssl_earliest_cert_expiry{job="cert_expires"})
    ) - (2000 * 100 ^ 4)
  ) / (100 ^ 2), 1
) and (round((probe_ssl_earliest_cert_expiry{job="cert_expires"} - time()) / 86400, 1))

これでヨシ!ということで一番最初はこれで動かしていたが、運用しているうちに月を跨ぐ場合におかしくなってしまうことに気が付いた。
時間が24を超えてしまう場合、前述の通り-24(元の時間に戻す)をして+100(日を+1する)をするようにしていたが、月の最終日の場合を考慮していなかった。
月の最終日にこの処理が実行されると、24を超えた場合次月に行かずに存在しない日にちで出力されてしまうのだ。

例えば2022年8月31日23時59分(UTC)が有効期限の証明書の場合、単純に上記処理を通すと、 220832 と出力されてしまう。
年を跨ぐ場合も同様の問題が起こる。
これはまずい。ミスの温床になる可能性がある。
32日と出る月ならいいが、それ以外は有り得そうな日が出てしまう。
ニシムクサムライをわざわざ思い浮かべて頭で日にちの処理をするのは嫌すぎるし、閏年とか多分普通に間違える。

…というわけで、ない頭を必死に絞って辿り着いたPromQLが以下。

round(
  sort(
    (
      # 12月 && +JST時に値が24を超える && 当月最終日 の処理
      (
        (
          (
            # hour に + 9 して 24 を超えてたら - 24 する(これでJSTの正しい時間が出る)
            (
              hour(
                probe_ssl_earliest_cert_expiry{job="cert_expires"}
              ) + 9 > 24
            ) - 24
            # 出した時に * 100 する (JSTで8時なら 800 になる)
          ) * 100
          and (
            # day_of_month と days_in_month を突き合わせて当月最終日か確認
            days_in_month(
              probe_ssl_earliest_cert_expiry{job="cert_expires"}
            ) == day_of_month(
              probe_ssl_earliest_cert_expiry{job="cert_expires"}
            )
          )
          and (
            # month が12月か確認
            month(
              probe_ssl_earliest_cert_expiry{job="cert_expires"}
            ) == 12
          )
        # month と day_of_month を強制的に1として計算する
        ) + 1 * (100 ^ 3) + (100 ^ 2)
        # year に + 1 して * 100000000 する(2022年なら2023年になる)
        + (year(probe_ssl_earliest_cert_expiry{job="cert_expires"}) + 1) * (100 ^ 4)
        # minute を出す
        + minute(probe_ssl_earliest_cert_expiry{job="cert_expires"})
      )
      # 12月以外 && +JST時24を超える && 当月最終日 の処理
      or (
        (
          (
            # hour に + 9 して 24 を超えてたら - 24 する(これでJSTの正しい時間が出る)
            (
              (
                hour(
                  probe_ssl_earliest_cert_expiry{job="cert_expires"}
                ) + 9 > 24
              ) - 24
            # 出した時に * 100 する (JSTで8時なら 800 になる)
            ) * 100
            and (
              # day_of_month と days_in_month を突き合わせて当月最終日か確認
              days_in_month(
                probe_ssl_earliest_cert_expiry{job="cert_expires"}
              ) == day_of_month(
                probe_ssl_earliest_cert_expiry{job="cert_expires"}
              )
            )
            and (
              # month が12月以外か確認
              month(
                probe_ssl_earliest_cert_expiry{job="cert_expires"}
              ) != 12
            )
          # month と day_of_month を強制的に1として計算する
          ) + 1 * (100 ^ 3) + (100 ^ 2)
        )
        # year を出して * 100000000 する
        + year(probe_ssl_earliest_cert_expiry{job="cert_expires"}) * (100 ^ 4)
        # month を出して * 1000000 する (既に1000000が入っているため、+1された値がはいる 例:8月なら9月となる)
        + month(probe_ssl_earliest_cert_expiry{job="cert_expires"}) * (100 ^ 3)
        # minute を出す
        + minute(probe_ssl_earliest_cert_expiry{job="cert_expires"})
      )
      or (
        (
          (
            # hour に + 9 して 24 を超えてたら - 24 する(これでJSTの正しい時間が出る)
            # 当月最終日かどうかは前段で処理が終わっているためここでは不要
            (
              (
                hour(
                  probe_ssl_earliest_cert_expiry{job="cert_expires"}
                ) + 9 > 24
              ) - 24 + 100
            ) * 100
            # year を出して * 100000000 する (2022年なら 202200000000 になる)
            + year(probe_ssl_earliest_cert_expiry{job="cert_expires"}) * (100 ^ 4)
            # month を出して * 1000000 する (1月なら 202201000000 になる)
            + month(probe_ssl_earliest_cert_expiry{job="cert_expires"}) * (100 ^ 3)
            # day_on_month を出して * 10000 する (1日なら 202201010000 になる)
            + day_of_month(probe_ssl_earliest_cert_expiry{job="cert_expires"}) * (100 ^ 2)
            # 分を出す (59分なら 202201010059 になる)
            + minute(probe_ssl_earliest_cert_expiry{job="cert_expires"})
          )
        )
      )
      or (
        (
          (
            (
              # +09(JST)にした際に24に達さない値には何もしない(-0)
              # 当月最終日かどうかは前段で処理が終わっているためここでも不要
              (
                hour(
                  probe_ssl_earliest_cert_expiry{job="cert_expires"}
                ) + 9 < 24
              ) - 0
            ) * 100
            # year を出して * 100000000 する (2022年なら 202200000000 になる)
            + year(probe_ssl_earliest_cert_expiry{job="cert_expires"}) * (100 ^ 4)
            # month を出して * 1000000 する (1月なら 202201000000 になる)
            + month(probe_ssl_earliest_cert_expiry{job="cert_expires"}) * (100 ^ 3)
            # day_of_month を出して * 10000 する (1日なら 202201010000 になる)
            + day_of_month(probe_ssl_earliest_cert_expiry{job="cert_expires"}) * (100 ^ 2)
            # minute を出す (59分なら 202201010059 になる)
            + minute(probe_ssl_earliest_cert_expiry{job="cert_expires"})
          )
        )
      )
    # YYYYMMDDhhmm を YYMMDDhhmm に
    ) - (2000 * 100 ^ 4)
    # YYMMDDhhmm を YYMMDD に
  ) / (100 ^ 2), 1
)
# 相対日数の処理
and (
  # round で小数点の丸め処理をする
  round(
    (
      # 証明書の有効期限を現在時刻で引く(どちらもUnixTime)
      probe_ssl_earliest_cert_expiry{job="cert_expires"} - time()
    # 86400 で割ることで日数に変換できる
    # round 関数で正数で丸めるために 1 を指定している
    ) / 86400, 1
  )
# ここはあと何日かを指定する 60で指定すればあと60日で期限の切れるURLと、YYMMDD形式の日付が出力される
) == 60

長すぎて書いた自分でも何の処理をしてんだこれ…みたいになっているので説明はしません(できません)。
とりあえず日またぎの繰り上げ以外にも月またぎと年またぎの繰り上げ処理を追加したものがこれです。

少しでもわかりやすいようにインデントと改行を駆使してコメントも入れてはみた…が、何これ?って感じ。
分かりづらすぎてゲロ吐きそう。こんなん読み解くの苦行ですよ。

でもとりあえず目的は達成できた…からいいか…。
なんか共通してる処理とかあるから一緒くたにできないかなとか思うんだけど、今はもうこれ以上は無理…。

…というのを2022年頃に備忘録として書いていた。
結局この形から変えていないが、2024年現在問題なく動いてはいるのでまあいいかーって思っている。
一部共通化できそうな雰囲気はあるけどなんか複雑すぎて触りたくないし…。
こんなもの書くなら多分自前で何かしらのexporter作った方が良いと思う…。 カテゴリは一応技術にしておくか…(こんな程度だと雑記と迷う)。


  1. 最初は小数点を使い YYYYMMDD.hhmm の形にしようとしたが、Slackでは浮動小数点の桁数が多いと e+n で小数点を勝手に動かして変えられてしまうらしい。 例えば 20220805.0859 にしようと設定しても、 2.02208050859e+07 となって通知されてしまうため、小数点を使う方法は諦めた。