ぽよメモ

レガシーシステム考古学専攻

おうちサーバ無料監視 by Grafana Cloud

TL; DR

  • Grafana Cloudは無料枠が大きく個人でも使いやすい
    • メトリクス・ログ・アラートに加えトレースまで扱える
  • Grafana Agentをnon-rootで動かしていると埋め込みのcAdvisorでは正しくメトリックを拾えない事があるので別途立てると良い
  • みんな構造化ログにしてくれ

おうちサーバも監視したい

昨今は安価なミニPCでも十分な性能が出るようになってきており、特に中国製のミニPCがコスパの良さから注目されています。これにより自宅におうちサーバとして置いたり、複数購入してKubernetesクラスタを組むなどが安価でできるようになり、私の周りではにわかに盛り上がりを見せています。

ある程度サーバを運用していると死活監視やディスク容量の警告、ログの検索などが欲しくなります。昨今ではOSSでこれらの機能を簡単にデプロイできるようになってきてはいますが、そもそもおうちサーバに監視基盤を載せてしまうとまるごとダウンしたときには検知出来ません。外部サービスに監視を任せたいというのが正直なところです。ただ、ミニPCをサーバにせざるを得ないほどの貧民であるため、当然富豪的SaaSを使うことは出来ません。

この記事ではいくつかのSaaSを簡単に検討し、最終的に選んだGrafana Cloudについての簡単な解説をしたいと思います。必須の要件は以下の3つです。

  • メトリクスの可視化:最低1週間程度はサーバの状態を可視化ができる
  • ログ:最低1週間程度はサーバのログを保存・検索できる
  • アラート:メトリクスに基づく死活監視ができる

SaaS 比較検討

記載されている価格・機能は2023年9月ベースのものです。

Mackerel

元々はこのMackerelを使っていました。このブログの運営元であるはてな株式会社が提供している純国産のモニタリングSaaSです。ホストベースでの課金になりますが、無料枠があります。

  • 1スタンダードホストあたり200のメトリクス。1日だけ表示可能。5ホストまで無料。
  • ログの扱いは無し
  • アラートは10個まで

機能的にもう少し欲しいというのが正直なところでした。今回はこの乗り換え先を検討していく形になります。

Datadog

モニタリングのSaaSとして古くからあり、業務でも触れているためその信頼性の高さや使い勝手はよく知っています。ただ無料枠は大きくなく、料金も高額であるため今回は見送りとしました。

  • メトリクス
    • 無料枠では5ホストまで、1日以内のデータが閲覧可能。
    • 有料枠は18.75ドル/ホストで15ヶ月以内のデータが閲覧可能(Proプラン)。高い。
  • ログ
    • 無料枠無し。取り込みに0.13ドル/GB/month、保持に2.13ドル/100万ログイベント/month(15日間の保持期限の場合)の料金がかかる。
  • アラート
    • メトリクスに含まれている。無料枠には記載なし。Pro以上は無制限。

Elastic Cloud

Elasticsearchで有名なElastic社のSaaSです。以前はELKスタックと呼ばれていた、Elasticsearch、Logstash、Kibanaをベースとした基盤を提供しています。インスタンスの構成をユーザが選んでプロビジョニングするのが特徴で、ある程度小規模な環境を組めます。

構築した環境の構成に関わらず、基本的な機能はElastic Cloudのプランによります。最も安価なStandardでもサーバメトリクスの収集、ログの保存・検索、アラートは一通り揃っていそうでした*1。保存可能な容量などは構築した環境の構成に寄ります。

いくつか試して最も安い構成は以下でした(全通り試したわけではないのでもっと安価にできるかも)。

0.0234ドル/hourなので、月額に換算すると

$0.0234 * 24 hour * 30 days = $16.848

となり、およそ月額17ドルほどとなりました。30GBまでのログ・メトリクスが保存・検索できます。課金の単位がこのインスタンスに対するものなので、おうちサーバ台数が増えても料金は変わらないところはメリットです。

悪くはないですが、この構成では画像の警告にも出ているとおり容易にデータロスが発生する可能性があります。冗長度を持たせるために2 zoneにすると単純に料金2倍です。今回は見送りとしました。

NewRelic

これもオブザーバビリティ界隈では著名なSaaSの一つです。自社のアプリケーションにNewRelicのAPMが組み込まれているという方も多いのではないでしょうか。実は無料枠がそこそこ大きく、個人でも使いやすくなっています。

いくつか制限がありますが、これらがどれくらい通常利用に影響してくるかまでは検討できていません。

https://docs.newrelic.com/jp/docs/accounts/accounts-billing/new-relic-one-pricing-billing/new-relic-one-pricing-billing/#free

今回APMの利用予定がなく、NewRelicの強みを活かせそうには思えなかったので見送りとしました。

Grafana Cloud

Grafana CloudはGrafanaが提供しているSaaSでGrafana製品が内部では利用されています。

他にもTempoによるトレーシング、k6による負荷試験の実施や、Grafana Pyroscopeを使ったパフォーマンスプロファイルの取得まで様々な機能があります。

様々なデータソースとの接続が可能ですが、基本的にはPrometheus+Lokiのマネージドサービスであると捉えています。実際にセットアップしていくとPrometheusやLokiのRemote Write用のURLが用意されており、そこに指定されたユーザ名と生成したAPIトークンで接続して書き込みます。そのため、必ずしも指定されたAgentを使わなければならないわけではなく、最終的にオンプレでPrometheusやLokiをホストすればスムーズに移行できるのも良いところだと思いました。

総合的に判断し、今回はGrafana Cloudを採用してみることにしました。

セットアップ

Grafana Cloudのアカウント作成

grafana.com

Grafana CloudのトップページからCreate free accountしてアカウントを作成します。

https://grafana.com/orgs/{{ 作成したorg名 }}/access-policies なURLにアクセスして、 -write で終わる書き込み可能なAccess Policyに対して Add Token してアクセストークンを発行します。名前は適当で良いです。

作成したトークンの文字列はメモっておく必要があります。

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

まずstaticモードflowモードという2つのモードがあり、提供されるAgentのバイナリも両方違いますが、今回はstaticモードでデプロイします。理由はflowモードの説明が全く頭に入ってこなかったからです。

セットアップにはAnsibleを使っていますが、ほとんど生の設定を書くのとAnsibleのconfigを書くのに大きな違いは無いので適宜読み替えてください。また、Ansibleはインストール済み・対象サーバへの接続等はセットアップ済みであるとして進めます。

grafana.com

まず、Grafana関連のAnsible RoleはGrafanaが公式で公開しているのでこれをインストールします。

ansible-galaxy collection install grafana.grafana

これを使ってインストールするplaybookを書きます。人によってこの辺は色々やりかたがあると思うので、あくまでこれは一例と考えて下さい。

main.yaml:

---
- name: Install Grafana Agent
  hosts: all
  become: true
  tasks:
    - name: Install Grafana Agent
      ansible.builtin.include_role:
        name: grafana_agent
    # grafana_agent roleは変更後のconfigで正しく立ち上がっていることをチェックしない
    # REST APIを叩いてチェックする
    - name: Ensure that the grafana-agent is ready
      uri:
        url: http://localhost:12345/-/ready
        method: GET
        status_code: 200
      # 成功するまで1秒間隔で5回までリトライする
      register: check_grafana_agent_readiness
      until: check_grafana_agent_readiness is not failed
      retries: 5
      delay: 1

inventory fileなりhost_varsなりgroup_varsなりにconfigの変数を書きます。prometheusやLokiのUser、URLはStackの設定から見ることができます。 `https://grafana.com/orgs/{{ 作成したorg名 }}` のページからそれぞれのサービスの詳細に飛ぶことで設定を見ることができます。

group_vars/all.yaml:

---
# この値は実際にはansible-vaultで暗号化しておいた方が良い
grafana_cloud_api_key: 'メモしておいたアクセストークン'
# Prometheusのremote writeのBASIC認証に使うユーザ名(人によって違う)
grafana_cloud_prometheus_user: '999999'
# Prometheusのremote write URL(人によって違う)
grafana_cloud_prometheus_url: 'https://prometheus-prod-XX-prod-YYYY.grafana.net/api/prom/push'
# Lokiへのpush時のBASIC認証に使うユーザ名(人によって違う)
grafana_cloud_loki_user: '999999'
# Lokiのpush先URL(人によって違う)
grafana_cloud_loki_url: 'https://logs-prod-ZZZ.grafana.net/loki/api/v1/push'

# grafana-agentユーザがLinuxサーバ上に作られるが、
# 所属するグループはここで宣言しなければ全てパージされる
grafana_agent_user_groups:
  # For journal logs
  - adm
  - systemd-journal
  # For container logs
  - docker

grafana_agent_metrics_config:
  configs:
    - name: integrations
      remote_write:
        - basic_auth:
            password: '{{ grafana_cloud_api_key }}'
            username: '{{ grafana_cloud_prometheus_user }}'
          url: '{{ grafana_cloud_prometheus_url }}'

  global:
    scrape_interval: 60s
  wal_directory: /tmp/grafana-agent-wal

grafana_agent_logs_config:
  configs:
    - name: default
      clients:
        - basic_auth:
            password: '{{ grafana_cloud_api_key }}'
            username: '{{ grafana_cloud_loki_user }}'
          url: '{{ grafana_cloud_loki_url }}'
      positions:
        filename: /tmp/positions.yaml
      target_config:
        sync_period: 10s
      scrape_configs:
        # 単一のファイルを指定して送信する例
        - job_name: authlog
          static_configs:
          - targets:
            - localhost
            labels:
              instance: '${HOSTNAME:-default}'
              __path__: /var/log/auth.log
              job: auth.log
        # systemd-journalのログを送信する例
        - job_name: integrations/node_exporter_journal_scrape
          journal:
            # 過去のログをどれくらい遡って送信するか
            max_age: 1h
            labels:
              job: systemd-journal
              instance: '${HOSTNAME:-default}'
          relabel_configs:
            - source_labels: ['__journal__systemd_unit']
              target_label: 'service'
            - source_labels: ['__journal_priority_keyword']
              target_label: 'level'
            # 余計なログ転送を抑えるためにsessionやuserなどのjournalログを送らず捨てる
            - source_labels: [__journal__systemd_unit]
              regex: ^session-\d+.scope$
              action: drop
            - source_labels: [__journal__systemd_unit]
              regex: ^user@\d+.service$
              action: drop
        # Dockerコンテナのログを自動的に見つけて送信する例。大量のコンテナがある場合には注意。
        # また、長く運用しているコンテナがあり過去のログが残っている場合、先に削除しておくこと。
        - job_name: integrations/docker
          docker_sd_configs:
            - host: unix:///var/run/docker.sock
              refresh_interval: 10s
          relabel_configs:
          - action: replace
            replacement: integrations/docker
            target_label: job
          - action: replace
            replacement: ${HOSTNAME:-default}
            target_label: instance
          - source_labels:
              - __meta_docker_container_name
            regex: '/(.*)'
            target_label: service
          - source_labels: 
              - __meta_docker_container_log_stream
            target_label: stream

grafana_agent_integrations_config:
  # node-exporterを起動してそのホストのメトリクスを取る
  node_exporter:
    enabled: true
    instance: ${HOSTNAME:-default}
    # disable unused collectors
    disable_collectors:
      - ipvs
      - btrfs
      - infiniband
      - xfs
      - zfs
      - nfs
      # エラーログが出るので無効にした
      - thermal_zone
    # exclude dynamic interfaces
    netclass_ignored_devices: "^(veth.*|cali.*|[a-f0-9]{15})$"
    netdev_device_exclude: "^(veth.*|cali.*|[a-f0-9]{15})$"
    # disable tmpfs
    filesystem_fs_types_exclude: "^(autofs|binfmt_misc|bpf|cgroup2?|configfs|debugfs|devpts|devtmpfs|tmpfs|fusectl|hugetlbfs|iso9660|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$"
    # drop extensive scrape statistics
    relabel_configs:
    - replacement: ${HOSTNAME:-default}
      target_label: instance
    metric_relabel_configs:
    - action: drop
      regex: node_scrape_collector_.+
      source_labels:
      - __name__
    # デフォルトでは無駄に多いのでメトリクスを削る。もっと削っても良いかも。
    - action: keep
      regex: node_time_seconds|node_arp_entries|node_boot_time_seconds|node_cpu_seconds_total|node_disk_io_time_seconds_total|node_disk_io_time_weighted_seconds_total|node_disk_read_bytes_total|node_disk_read_time_seconds_total|node_disk_reads_completed_total|node_disk_write_time_seconds_total|node_disk_writes_completed_total|node_disk_written_bytes_total|node_filefd_allocated|node_filefd_maximum|node_filesystem_avail_bytes|node_filesystem_device_error|node_filesystem_files_free|node_filesystem_readonly|node_filesystem_size_bytes|node_load1|node_load15|node_load5|node_md_disks|node_md_disks_required|node_memory_Active_anon_bytes|node_memory_Active_bytes|node_memory_Active_file_bytes|node_memory_AnonHugePages_bytes|node_memory_AnonPages_bytes|node_memory_Bounce_bytes|node_memory_Buffers_bytes|node_memory_Cached_bytes|node_memory_CommitLimit_bytes|node_memory_Committed_AS_bytes|node_memory_Dirty_bytes|node_memory_Inactive_anon_bytes|node_memory_Inactive_bytes|node_memory_Inactive_file_bytes|node_memory_Mapped_bytes|node_memory_MemAvailable_bytes|node_memory_MemFree_bytes|node_memory_MemTotal_bytes|node_memory_SReclaimable_bytes|node_memory_SUnreclaim_bytes|node_memory_ShmemHugePages_bytes|node_memory_ShmemPmdMapped_bytes|node_memory_Shmem_bytes|node_memory_Slab_bytes|node_memory_SwapTotal_bytes|node_memory_VmallocChunk_bytes|node_memory_VmallocTotal_bytes|node_memory_VmallocUsed_bytes|node_memory_WritebackTmp_bytes|node_memory_Writeback_bytes|node_netstat_Icmp6_InErrors|node_netstat_Icmp6_InMsgs|node_netstat_Icmp6_OutMsgs|node_netstat_Icmp_InErrors|node_netstat_Icmp_InMsgs|node_netstat_Icmp_OutMsgs|node_netstat_IpExt_InOctets|node_netstat_IpExt_OutOctets|node_netstat_TcpExt_ListenDrops|node_netstat_TcpExt_ListenOverflows|node_netstat_TcpExt_TCPSynRetrans|node_netstat_Tcp_InErrs|node_netstat_Tcp_InSegs|node_netstat_Tcp_OutRsts|node_netstat_Tcp_OutSegs|node_netstat_Tcp_RetransSegs|node_netstat_Udp6_InDatagrams|node_netstat_Udp6_InErrors|node_netstat_Udp6_NoPorts|node_netstat_Udp6_OutDatagrams|node_netstat_Udp6_RcvbufErrors|node_netstat_Udp6_SndbufErrors|node_netstat_UdpLite_InErrors|node_netstat_Udp_InDatagrams|node_netstat_Udp_InErrors|node_netstat_Udp_NoPorts|node_netstat_Udp_OutDatagrams|node_netstat_Udp_RcvbufErrors|node_netstat_Udp_SndbufErrors|node_network_carrier|node_network_info|node_network_mtu_bytes|node_network_receive_bytes_total|node_network_receive_compressed_total|node_network_receive_drop_total|node_network_receive_errs_total|node_network_receive_fifo_total|node_network_receive_multicast_total|node_network_receive_packets_total|node_network_speed_bytes|node_network_transmit_bytes_total|node_network_transmit_compressed_total|node_network_transmit_drop_total|node_network_transmit_errs_total|node_network_transmit_fifo_total|node_network_transmit_multicast_total|node_network_transmit_packets_total|node_network_transmit_queue_length|node_network_up|node_nf_conntrack_entries|node_nf_conntrack_entries_limit|node_os_info|node_sockstat_FRAG6_inuse|node_sockstat_FRAG_inuse|node_sockstat_RAW6_inuse|node_sockstat_RAW_inuse|node_sockstat_TCP6_inuse|node_sockstat_TCP_alloc|node_sockstat_TCP_inuse|node_sockstat_TCP_mem|node_sockstat_TCP_mem_bytes|node_sockstat_TCP_orphan|node_sockstat_TCP_tw|node_sockstat_UDP6_inuse|node_sockstat_UDPLITE6_inuse|node_sockstat_UDPLITE_inuse|node_sockstat_UDP_inuse|node_sockstat_UDP_mem|node_sockstat_UDP_mem_bytes|node_sockstat_sockets_used|node_systemd_unit_state|node_textfile_scrape_error|node_uname_info|node_vmstat_oom_kill|node_vmstat_pgfault|node_vmstat_pgmajfault|node_vmstat_pgpgin|node_vmstat_pgpgout|node_vmstat_pswpin|node_vmstat_pswpout|process_max_fds|process_open_fds
      source_labels:
      - __name__
  prometheus_remote_write:
    - basic_auth:
        password: '{{ grafana_cloud_api_key }}'
        username: '{{ grafana_cloud_prometheus_user }}'
      url: '{{ grafana_cloud_prometheus_url }}'

grafana_agent_env_vars:
  HOSTNAME: '%H'

この設定をデプロイします。

# -K を付けるとSSH先で使うパスワードを実行時に聞かれる
ansible-playbook -K main.yaml

コケずに走りきったらセットアップ終了です。

cAdvisorを建てる

grafana-agentにはcAdvisorを内部で起動する機能がありますが、この方法で建てたgrafana-agentでは、cAdvisorもgrafana-agentユーザで起動してしまいます。すると /var/lib/docker 以下などへのアクセスでエラーが出まくるので嬉しくありません。cAdvisorだけroot権限を付与して別に起動します。詳細は↓を参照。

github.com

これもAnsibleで建てても良いでしょう。

cAdvisorのメトリクスを参照する

先ほど適用した設定にcAdvisorのメトリクスを取りに行く設定を入れます。

grafana_agent_metrics_config:
  configs:
    # ... 省略 ...
    - name: cadvisor
      scrape_configs:
        - job_name: cadvisor
          static_configs:
            # リモートホスト上でcAdvisorがリッスンしているアドレスを指定する
            - targets: ['localhost:8080']
              labels:
                instance: '${HOSTNAME:-default}'
          relabel_configs:
            - source_labels: ['name']
              target_label: 'service'
          # デフォルトでは多すぎるので適当にメトリックを削る
          metric_relabel_configs:
          - action: keep
            regex: container_cpu_usage_seconds_total|container_fs_inodes_free|container_fs_inodes_total|container_fs_limit_bytes|container_fs_usage_bytes|container_last_seen|container_memory_usage_bytes|container_network_receive_bytes_total|container_network_tcp_usage_total|container_network_transmit_bytes_total|container_spec_memory_reservation_limit_bytes|machine_memory_bytes|machine_scrape_error
            source_labels:
            - __name__
      remote_write:
        - basic_auth:
            password: '{{ grafana_cloud_api_key }}'
            username: '{{ grafana_cloud_prometheus_user }}'
          url: '{{ grafana_cloud_prometheus_url }}'

これを再び適用して走りきればOK。

Grafana Cloudで表示する

ダッシュボードを作る

最初はNode Exporterで取得したメトリクスをまずは表示してみます。

GrafanaでPrometheusをdatasourceとしたときのダッシュボードの作り方は他に無限に情報があるのでここでは詳しく解説しません。今回は既に公開されているものをimportして利用します。

https://grafana.com/grafana/dashboards/1860-node-exporter-full/

https://{{ 作成したorg名 }}.grafana.net/ にアクセスし、右上の + から import dashboardを開くと上記のリンクのダッシュボードをimportできるUIが出てくるので適当にimportします。

Node Exporterのダッシュボード

こんな感じで見えるダッシュボードが生成されればOK。

色々設定すると、自前でもこんな感じのダッシュボードが作れます。

録画システムのダッシュボード

ログを検索する

ログをダッシュボードに表示する方法なども他の記事に譲るとして、アドホックにログを検索するやり方だけ書いておきます。ハンバーガーメニューから Explore をクリックするとログやメトリクスを指定して表示する機能が出てきます。データソースを grafanacloud-{{ 作成したorg名 }}-logs にして適用な検索内容を入れると表示されることが分かります。

無料枠を超えないために

Grafana Cloudにはハードリミットを用意してそれを超えたら一切受け付けないようにして課金されないようにするような仕組みはありません。そのため、予期しないログのバーストなどで課金されないようにするにはユーザが頑張るしかないようです。

Promtailの設定にリミットを入れる

一行当たりのサイズや送信レートにリミットをかけ、そもそも大量に送られないようにします。

grafana.com

より詳細に、例えば特定のログストリームだけ異なる設定を適用する場合はpipelineを使います。

grafana.com

scraping intervalを広めにとる

細かくメトリクスが欲しくなってしまいますが、DPMという概念があるため1分より早い頻度でのデータの送信は無料枠のアカウントには含まれていません。Grafana Agentなどのデフォルトは60sになっているので、これ以上縮めないようにしましょう。

grafana_agent_metrics_config:
  configs:
  # ... 省略 ...
  global:
    scrape_interval: 60s

不要なメトリクスやlabelをdropする

見ることがないメトリクスは取っていても意味が無いため、そもそも送らないようにします。また、不要なlabelについてもdropすることでbillable seriesを減らせます。

prometheus.io

droplabeldrop を使って不要なメトリック・ラベルはどんどん削っておくとサーバ台数が増えても無料枠に収まるはずです。

アラートを仕掛ける

まずは無料枠の上限に近づいてきたらアラートを出すようにします。

公式のドキュメントにメトリクスの課金アラートに関する設定例が載っていますが、これは課金する前提の場合であり、実際に課金されるような使用量に至るまで検知出来ません。

grafana.com

監視するだけならそれほど必要ではないのですが、各リソースの利用量の計算の仕方と、無料枠で許されている範囲を把握しておいた方がよいです。今回はログとメトリクスに関してだけ考えますが、他のリソースでもドキュメントに計算方法は書いていますのでそれを参考にして下さい。

https://grafana.com/docs/grafana-cloud/account-management/billing-and-usage/active-series-and-dpm/

https://grafana.com/docs/grafana-cloud/account-management/billing-and-usage/logs-billing/

Metricであれば grafanacloud_org_metrics_billable_series という、課金対象となるmetricsのseriesを表すメトリックが現在いくつかなのかを監視します。ただし、ユーザには最初の10k seriesまで無料という無料枠( grafanacloud_org_metrics_included_series というメトリックで取得できる )が用意されているので、これにどれくらい近接したかを監視します。

Logでもほぼ同様で、 grafanacloud_org_logs_usage というメトリックを監視します。このメトリックの単位はGBです。 grafanacloud_org_logs_included_usage に無料枠の上限(50GB。無料トライアルの間は100GB)が入っているので、その差分がいくつになったらアラートを鳴らすかを記述します。

また、 grafanacloud_org_logs_overage というメトリックに現在の超過料金が入っているのでこれが0以上になったらという監視を入れたり、ログの流量 grafanacloud_logs_instance_bytes_received_per_second が一定以上の状態が30分以上続いたら、などの条件でアラートを入れ最終的に自分の環境では以下の様になりました(伝わらない)。

サポートからはGrafana Machine Learningなどを活用して異常検知するなども提案されましたが、Free accountにはMachine Learningは含まれていないので今のところ検討していません。

2023-09-23 追記 grafanacloud-usage datasourceが1日に1回くらいの割合でNoDataになってアラートを飛ばしてくるので、NoDataになったときの挙動をOKにしてしまうのがおすすめです。どうせNoDataになってもユーザ側で対応できることは何もないので、それで十分だと思います。

注意点

Grafana Cloudのログの無料枠とは保存容量ではなく、送信量に対してかかります。そのため、何らかの理由でGrafana Cloudのサーバ側からリジェクトされた場合でもその容量は課金対象です。例えばログのタイムスタンプがある程度以上古かったり未来を指している場合にエラーになることがあり、実際には拒否されているにもかかわらずBillable Usageだけは増加することがあります。

これについてはもう少し詳しく別記事で解説できればと思っています。

まとめ

  • Grafana と Prometheus、Lokiのマネージドサービスという感じで使い慣れている人には色々使い勝手が良い
    • ただしあまり親切とは言い難いので、料金を抑えるために使う側で色々な工夫が必要
  • ちょっとした自宅サーバのログやメトリックを保存しておく分には十分な無料枠
  • 無料枠に感謝し、色々触ってみて業務に活かしていきたい

*1:あまりにも機能比較のチェックリストが長い https://www.elastic.co/jp/subscriptions/cloud

Go 1.20で入ったexec.CommandのCancelとWaitDelayで外部コマンドを正しく終了させる

背景

Goでは外部コマンドの実行時に os/exec パッケージの CommandCommandContext を利用します。特に CommandContext を利用することで、 Goのcontextの流儀に従ってコマンドのタイムアウトや中断が可能であり大変便利です。

一方で、LinuxmacOSなどにおいてContextによるタイムアウト・中断時には外部コマンドに即座に SIGKILL が送られてキルされてしまうため、一部のケースでは孫プロセスが孤児プロセスとして残ってしまったり、後始末を正しく出来ないままコマンドが終了してしまうという問題が知られていました。

より安全にコマンドを終了させる方法として、まずは SIGINTSIGTERM を送り、一定時間内に終了しなければ SIGKILL で終了させるなどの方法が知られています。しかし、 CommandContext を使わずにContextによる中断のハンドリングを自分で行う必要があるなど初学者には難しい状況になっていました。

Go 1.20で導入されたCancelとWaitDelay

ひっそりとリリースノートに記載されている内容なので、あまり気にしておられない方も多いかと思います。 exec.Cmd 構造体に新たに二つのフィールドが追加されました。

Cancel の型は func() errorWaitDelay の型は time.Duration です。元々は下記のプロポーザルから実装されたもののようです。当初は KILL されるまでの時間と、ContextがDoneになったときに送信するシグナルを指定できるようにするはずだったようですが、途中で任意の実装を挟み込めるようになったようです。

Cancel

まず Cancel は、 CommandContext で渡したContextがDoneになったときに呼び出される関数で、デフォルトでは cmd.Process.Kill() を呼び出す関数が設定されるようです。

代わりに SIGTERM を送ったりする関数を与えることで、Contextをキャンセルしたりタイムアウトさせた際に送信するシグナルを指定できる他、任意の処理が出来るので標準入力を閉じたりネットワーク越しにリクエストを送ったりすることもできるようです。要するにこれまで手動でコンテキストをハンドリングして行っていたようなものをここに書いておくだけでよくなるということです。

WaitDelay

ContextがDoneになってから、 cmd.Process.Kill() されるまでの猶予時間です。この間に Cancel の処理を終わらせないと SIGKILL で終了させられてしまいます。

デフォルトは0になっているため、 Cancel を使う際は WaitDelay を手動設定しておかないとうまく処理できません。

使い方

SIGINTを送って死ななければSIGKILLで終了させる

まずは以前までの書き方をおさらいします。まず、 exec.CommandContext を使うとContextがDoneになったときにSIGKILLが送られてしまうのでこれは使えません。 exec.Command を使い、Contextのハンドリングは自分で行わなければいけません。

package main

import (
    "context"
    "fmt"
    "os"
    "os/exec"
    "os/signal"
    "time"
)

func run() error {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()
    cmd := exec.Command("bash", "-c", "trap 'echo \"signal received\"; sleep 10; echo \"done\"' SIGINT; sleep 120")
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    if err := cmd.Start(); err != nil {
        return err
    }
    errCh := make(chan error, 1)
    go func() {
        defer close(errCh)
        errCh <- cmd.Wait()
    }()
    for {
        select {
        case exitErr := <-errCh:
            return exitErr
        case <-ctx.Done():
            fmt.Println("Send SIGINT")
            cmd.Process.Signal(os.Interrupt)
            select {
            case exitErr := <-errCh:
                return exitErr
            case <-time.After(5 * time.Second):
                fmt.Println("Send SIGKILL")
                cmd.Process.Kill()
                return <-errCh
            }
        }
    }
}

func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

(適当に書いたので穴があるかも知れない)

ここではシグナルを受け取るとContextをキャンセルし、子プロセスに SIGINT を送って5秒待機、まだ終了しなければ SIGKILL を送って待ちます。

CancelWaitDelay を使うと以下の様に書けます。

package main

import (
    "context"
    "fmt"
    "os"
    "os/exec"
    "os/signal"
    "time"
)

func run() error {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()
    cmd := exec.CommandContext(ctx, "bash", "-c", "trap 'echo \"signal received\"; sleep 1; echo \"done\"' SIGINT; sleep 120")
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    cmd.Cancel = func() error {
        return cmd.Process.Signal(os.Interrupt)
    }
    cmd.WaitDelay = 5 * time.Second
    return cmd.Run()
}

func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

実行してみると下記の様にシグナルを受け取っていることがわかります。

❯ go run main.go                                  
^Csignal received
done
Error: exit status 130
exit status 1

シグナルを受け取ってからのスリープの時間を10秒などに延ばすと、 SIGKILL が送られていることがわかります。

❯ go run main.go
^Csignal received
Error: signal: killed
exit status 1

注意点

Windowsでは cmd.Process.Signal が実装されておらず、今回の方法で正しくプロセスが終了できるとは限らないことに注意が必要です。実際Windowsでのプロセスの正しい終了の仕方を全然知らない……知っている方がおられれば是非教えて頂きたいですね。

GitHub Actionsのキャッシュをより細かく制御するactions/cache/restoreとactions/cache/save

はじめに

これはGitHub Actions Advent Calendar 2022 22日目の記事です。諸事情によりフライング投稿です。

GitHub Actionsのキャッシュにおいて、そのリストアと保存を別々に制御する機能が actions/cache@v3.2.0-beta.1 で実装されたので使ってみました。トピックブランチではキャッシュを保存しない、ビルドが失敗した際にもキャッシュを保存する、などこれまでは出来なかった細かい制御が可能になっています。

背景

GitHub Actionsにおいて、ダウンロード済みの依存関係などをキャッシュすることでワークフローの実行を高速化することは、一般的によく知られたテクニックです。
一方でGitHub Actionsのキャッシュはいくつかの制限があることが知られています。

  • 1リポジトリあたり合計10GBまで*1
  • デフォルトブランチおよびカレントブランチのキャッシュしかリストアできない*2
    • Pull Requestではベースブランチのキャッシュも利用可能
  • 同じキーに対するキャッシュを上書きできない*3
  • 暗黙的に定義された事後処理ステップにおいてキャッシュの保存が行われるため、無関係な箇所でジョブが失敗した場合にでもキャッシュの保存がスキップされてしまう

これにより、特にキャッシュサイズが大きい場合トピックブランチで複数回キャッシュの保存が行われるとデフォルトブランチのキャッシュが消えてしまったり、依存関係のフェッチとテストを別ジョブに分けて確実に依存関係がキャッシュされるようなワークアラウンドが必要なケースがありました。

actions/cache/restoreとactions/cache/save

以前からより詳細なキャッシュの制御がしたいという要望はあり、2019年頃から以下のようなissueがありましたが、あまり進展は見られていませんでした。

github.com

しかし、2022年12月になって急にDiscussionにてrestoreとsaveに対応するactionが実装されることが発表されました。

github.com

実際に v3.2.0-beta.1 からrestoreとsaveが実装されています。

github.com

それぞれについて書くことはそんなにありません。単にキャッシュのリストア・保存が別アクションに分かれただけです。

■ 追記(2022/12/26)

actions/cache/saveとactions/cache/restoreはv3.2.0でGAになりました。
以降のサンプルコードの@v3.2.0-beta.1@v3 で読み替えても動作します。

github.com

追記終わり

ユースケースの紹介

実際に背景で説明したいくつかの課題をこれで解決することが出来るので一例を紹介します。

トピックブランチではキャッシュを保存しない

2GBのランダムなダミーデータをキャッシュに保存してみることにします。ただし、トピックブランチではキャッシュの保存をスキップします。

name: Save cache only on main

on: [push]

jobs:
  run:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - uses: actions/cache/restore@v3.2.0-beta.1
        with:
          path: |
            ./large-object
          key: ${{ runner.os }}-${{ runner.arch }}-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-
      - name: Generate random file if needed
        run: |
          if [ ! -f ./large-object ]; then
            base64 /dev/urandom | head -c 2048M > large-object
          fi
      - uses: actions/cache/save@v3.2.0-beta.1
        if: github.ref == 'refs/heads/main'
        with:
          path: |
            ./large-object
          key: ${{ runner.os }}-${{ runner.arch }}-${{ github.sha }}

トピックブランチではsaveがスキップされる

mainにマージすると保存される

キャッシュがevictされる問題に対応出来るだけでなく、そのトピックブランチでしか有効でないキャッシュの保存にかかる時間をスキップできることも大きいです。サイズの大きなキャッシュのアップロードを無効化するだけで場合によっては数十秒〜数分の短縮に繋がることもあります。

ただし、これはトピックブランチで長い期間開発する場合にはキャッシュがないことによりむしろ実行時間が延びる可能性があります。

常にキャッシュを保存する

例えばテストが失敗するケースでも、キャッシュを保存したいというようなユースケースです。特にflakyなテストが存在する場合には有用かもしれません。

name: Save cache always

on: [push]

jobs:
  run:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - name: Step to fail
        run: |
          echo hello > ./file-to-cache
          false
      - uses: actions/cache/save@v3.2.0-beta.1
        if: always()
        with:
          path: |
            ./file-to-cache
          key: ${{ runner.os }}-${{ runner.arch }}-${{ github.sha }}

途中のステップで失敗してもキャッシュが保存されている

途中のステップで失敗しているので、ジョブ全体のステータスとしては失敗になります。

restoreとsaveで異なるkeyを使う

これがどれくらい需要のあるユースケースなのかはわかりませんが、これまでは地味にできなかったことです。
hashFiles などを使ってハッシュを計算する際、これまでは最初のリストア時に計算されたkeyが保存時にもそのまま利用されていました。つまりそのビルド中に hashFiles による計算結果が変わる場合に対応出来ていませんでした。例として以前の挙動を確認してみます。

途中のステップで hashFiles の対象としているファイルを作成しています。最初の評価時点ではファイルが存在しないため、 hashFiles('**/hello.txt') は空文字列になります。

name: Old behavior

on: [push]

jobs:
  run:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - uses: actions/cache@v3
        with:
          path: |
            ./hello.txt
          key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/hello.txt') }}
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-
      - name: Generate hashFiles targets
        run: |
          echo "hello" > hello.txt

actions/cacheでは最初にkeyを評価する時の値で保存する

actions/cache/saveを使って同じ事をすると、保存時に hashFiles の結果が再度評価されていることがわかります。

name: hashFiles get different result

on: [push]

jobs:
  run:
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
      - uses: actions/cache/restore@v3.2.0-beta.1
        with:
          path: |
            ./hello.txt
          key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/hello.txt') }}
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-
      - name: Generate hashFiles targets
        run: |
          echo "hello" > hello.txt
      - uses: actions/cache/save@v3.2.0-beta.1
        with:
          path: |
            ./hello.txt
          key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/hello.txt') }}

save時に評価されている

hashFiles を使う場合に限らず、restoreとsaveで異なるkeyを指定することが可能になっているので刺さる人には刺さるかも知れません。

まとめ

ライトなユースケースでは従来通り actions/cache をそのまま利用するのがわかりやすく、記述も容易であるため完全に置き換わることはない印象です。
一方、より詳細なキャッシュの制御を求める人にとっては待望の新機能になりそうです。キャッシュサイズの大きさが気になっている人は、とりあえずトピックブランチでのキャッシュ保存を辞めてみると良いかも知れません。