🐡

SSL証明書の有効期限をYYMMDDで出力してくれるPromQLを書く

に公開

SKIYAKIでは運用しているWebサイトの監視の一環として、PrometheusでSSL証明書の有効期限を追うようにしています。
有効期限の監視には、外形監視用のExporterであるblackbox_exporterを使っています。
最初はWebサイトの死活監視のみに使うつもりだったのですが、SSL証明書の有効期限も出せることを知り、だったらついでだし…という感じでSSL証明書の有効期限も監視することにしました。

監視している対象の有効期限がn日前になるとSlackに通知をしてくれます。
↓は実際の通知です(一部隠してはいます)。

この記事ではSSL証明書の有効期限をどう監視してどんな値を出しているのかを書いていきます。
なお、この設定自体は2022年頃に仕込んだものになるため、冗長であったり「なんでこうしないんだろう」という箇所があるかもしれません。

設定周り諸々

blackbox_exporter内の設定はこんな感じです。
GETでリクエストを送っているだけですね。

module
  cert_expires:
    prober: http
    http:
      method: GET

Prometheus内の設定はこんな感じですね。
file_sd_configsconf.d/cert_expires/ 以下のymlファイルを全て読むようにしています。

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

読み込んでいるymlファイルの中身はこんな感じで、- targets: 以下にURLをリストしている形です。

- targets:
  - https://example.com

rule_files で読み込んでいる設定ファイルの中身はこんな感じです。
期限日n日前というのを出すようにしています(この設定では30日前)。

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

expr: に書いてあるのが残日数を出すための計算式です。
probe_ssl_earliest_cert_expiry{job="cert_expires"} から time() で現在時刻を引くと、残期限の秒数が出ます。
更にそれを 86400 で割ると残日数が出ます。
更にそのままだと小数点以下の値も出てしまうので、 round で四捨五入をしています。
round の外側に sort も付けていますが、Prometheusのコンソールで見た時にわかりやすくするために付けているだけで、なくても機能します。
最後に「残り30日」を意味する == 30 を付与すると、期限日30日前のSSL証明書一覧が表示される…という仕組みです。

これ以外にも、60日前、14日前、10日前、7日前、3日以内と、それぞれ残日数に応じて設定をしています。

通知に使っているalertmanagerの設定はこんな感じです。
UTCで01:00〜24:00をミュートにすることで、日本時間で10:00〜11:00の間にSlackに通知を出してくれます。

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

この設定の問題点

これでも全然良いと思うのですが、ちょっとした問題が…。
この設定だと有効期限が相対日数でしか出せないのです。

「あとn日で期限切れになるよ」というのは出せるのですが、「n年n月n日に期限切れになるよ」というのが出せないのですね。
最初はこれで十分だろうとこれで出していたのですが、相対日数は実際いつ期限切れになるのかわかりづらいのですよね…。
「n日後」の実際の月日がパッと見でどうしてもわかりづらいのです。
ちょっと前に来た通知で「14日後」とか言われても、じゃあ実際いつ期限切れるんだっけ…?となってしまいます。
そんなこんなで調べてみたところ、PrometheusにはUnixtimeを直接年や月、日、時間に変える関数が存在することがわかります。

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

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

有効期限を絶対日数で出力するためのPromQLを書く

Prometheusでは、値はmetricsとして扱います。全て数値です。
相対日数として出す値もmetricsなので数値です。
なので相対日数を加工して絶対日数を出す場合、絶対日数もmetricsの扱いとなるため数値で出す必要があります[1]

計算式は至って単純で、以下のようにして整数として出すだけです。

(year() * 100^4)
+ (month() * 100^3)
+ (day_of_month() * 100^2))
+ (hour() * 100)
+ minute()

これらを全部合計すると、 YYYYMMDDhhmm の形式になります。[2]
例えば有効期限日時が 2023年1月1日8時59分なら 202301010859 となります。

ただこれだと桁数が多すぎてやっぱりパッと見が分かりづらいですね。
なので、時間の情報と西暦の3桁目4桁目はいらないね。ということで、 YYMMDD の形式にすることに。

そのために、2000に100の4乗した値を引いて、100の2乗(10000)で割り、余りを丸めるという処理を入れてみます。

round(
  (
    (
      (year() * 100^4)
      + (month() * 100^3)
      + (day_of_month() * 100^2))
      + (hour() * 100)
      + minute()
    )
    - (2000 * 100^4)
  )
  / (100^2), 1
)

たとえば 202301010859 なら - (2000 * 100^4) すると、 -200000000000 されることになるため上位2桁が消え、 2301010859 となり、そこから更に / (100^2) すると 230101.0859 となります。
更に余りを切り捨てることで 230101(YYMMDD) となるというわけです。

また、そのままだとUTC表記となるのですが、日本で仕事しているしUTCのままだと日にちの認識を誤ってオペレーションをミスる未来が見えたので、JST表記にすることにしました。
これも単純に9時間プラスするだけ…では実はだめで、日付を超えた際の処理を入れる必要があるのです。
例えば有効期限を迎える時間が 20:59 だった場合、+9時間すると 29:59 という表記になってしまうので、これを正しい表記に変える必要性があります。

round(
  (
    (
      (year() * (100^4))
      + (month() * (100^3))
      + (day_of_month() * (100^2)))
      + (
        if (hour() + 9) > 24 {
          (hour() + 9 - 24 + 100) * 100
        else
          (hour() + 9 ) * 100
        end
        )
      + minute()
    )
    - (2000 * (100^4))
  )
  / (100^2), 1
)

とりあえず、最初は愚直に hour() の出力に +9 をします。
その値が24を超えてしまう場合は +9 した後に -24 をして正しい値に修正します。
次に、日にちを繰り上げる必要があるため、日付をカウントアップすべく、 hour() で出した整数に +100 とすることにしました。
そうすることで、日付と時間が「10日の20時」だった場合、
1020 -> 1029 (+9) -> 1005 (-24) -> 1105 (+100) となり、
UTCで「10日の20時」であれば、日本時間で「11日の5時」となります。
24を超えなければ問題ないのでそのままとします。
文章に起こすと非常にわかりづらいのですが、そういうことをすれば、JST表記でYYMMDDが出るはず…。

有効期限を絶対日数で出すPromQLを書いてみる

これを元にして、最初はこういった形で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)して繰り上げる
        # 例) UTCで20時であれば、JSTに修正するために+9すると29となるため、そうなったら -24 をして5にする
        # 更に+100をすることで10500(Dhhmm)になり、最後に合計した際に日付が繰り上がる
        # +09(JST)にした際に24に達さない値には何もしない(-0)
        ((hour(probe_ssl_earliest_cert_expiry{job="cert_expires"}) + 9 > 24) - 24 + 100) * 100 or
        ((hour(probe_ssl_earliest_cert_expiry{job="cert_expires"}) + 9 < 24) - 0) * 100
      ) + 
      minute(probe_ssl_earliest_cert_expiry{job="cert_expires"})
    # YYYYMMDDhhmm を YYMMDDhhmm に
    ) - (2000 * 100 ^ 4)
  # YYMMDDhhmm を YYMMDD に
  ) / (100 ^ 2), 1
) and (round((probe_ssl_earliest_cert_expiry{job="cert_expires"} - time()) / 86400, 1)) == 30

これでYYMMDDで値が出力できることが確認できました。ヨシ!
じゃあこれで運用するぞ〜としばらく運用していたのですが、しばらく経って考慮漏れがあったことに気付きます。

さらなる課題: 月跨ぎの考慮漏れ

月が変わった直後の通知で、YYMMDDが存在しない日にちを指していることに気付きました。
この時に、月を跨ぐ場合に日付がおかしくなってしまうことが判明しました。
↓は実際の通知のスクリーンショットです(一部情報は隠しています)。

このスクリーンショットでは、2022年8月31日23時59分(UTC)が有効期限の証明書の通知を60日前に出していますが、 220832 と出力されてしまいます。
時間が24を超えてしまう場合、前述の通り-24(元の時間に修正する)をして+100(日を+1する)をするようにしていたのですが、月の最終日の場合を考慮していなかったのですね。
月の最終日にこの処理が実行されると、24を超えた場合次月に繰り上がらず、存在しない日にちで出力されてしまうという考慮漏れがあったのです。
年跨ぎの場合でも同様に年の繰り上がりが発生しません。

これはまずい…オペレーションミスの温床になる可能性がある…。
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
  )
# ここはあと何日かを指定する 30で指定すればあと30日で期限の切れるURLと、YYMMDD形式の日付が出力される
) == 30

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

少しでもわかりやすいようにインデントと改行を使いコメントも入れてはみた…のですが、焼け石に水というか、結局どうなってんだこれ…って感じですね…。
読み解くのも時間が掛かりそうなので、読解する際は生成AIに任せましょう。
ちなみにこのPromQLをGeminiに読んでもらったところ、

Prometheusは基本的にグラフの値として「数値」しか扱えません(「2023-12-01」のような文字列を値にできない)。
そのため、このクエリは無理やり計算で「日付に見える数値」を作り出そうとしている、非常に涙ぐましい(そして超複雑な)クエリです。

**なぜこんなことをしているの?**
GrafanaのSingle Statパネルや、Alertmanagerの通知文面において、 {{ $value }} と書いただけで「期限日」を表示させたかったのだと推測されます。
Prometheusは文字列を値として返せないので、人間が読んで「あ、日付だ」とわかる数字に変換しているのです。

**運用上の注意**
非常にハック的(裏技的)なクエリです。 メンテナンスが非常に困難(読み解くのに時間がかかる)ため、
可能であればGrafana側で Time of series 表示機能を使ったり、時刻フォーマット機能を使う方が健全です。
ただ、アラート通知の本文に日付を入れたい場合は、このような苦肉の策が必要になることがあります。

こんなことを言われました。普通に苦言呈されてない?
素直にGrafanaを使ってモニタリングしなさいということですね。 うるせえ!!!

ただ、そんな涙ぐましい努力(笑)のお陰もあってとりあえず目的は達成できました…。

…という感じのことを社内に備忘録として書いていました。
結局今でもこの形から変えていないのですが、2026年現在問題なく動いてはいるので、まあいいかな…と思っています。
最後のPromQLは一部共通化できそうな雰囲気はあるんですが、できるのか不明なのと、やっぱり複雑でもう何が何だかな感じなので触りたくないのですよね…。
あとなんというか、こんな煩雑なPromQLを書くより多分自前で何かしらのexporter作った方が良いと思いました。
もしくは今だともっと簡潔な書き方もありそうですよね…!(調べてはいませんが…)

以上です。

脚注
  1. labelにできればいいのですが、私がその方法を知らないため断念しています。何か方法あるんですかね…? ↩︎

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

SKIYAKI Tech Blog

Discussion