夜中の Mac に小さな仕事を頼んだ — launchd と pmset を初めて触った話

「毎朝起きたときに、特定のフォルダの中身が一覧で見られたら気が楽だな」と思ったことが、出発点でした。
個人のプロジェクトで、Markdown のメモを溜めているフォルダがあって、ふと「今いくつ溜まっているんだっけ」「これ、いつ書き始めたっけ」を朝のうちに把握しておきたい、というだけの小さな願いです。

手作業で ls を叩けば済む話なんですが、それを毎朝続ける自信は私にはなかったので、Mac 側で勝手に走らせて、結果をログに書き出しておいてもらおうと考えました。
そうやって調べていくうちに、Mac で定時に何かを走らせるなら launchd、夜中に Mac を起こすなら pmset、という 2 つの仕組みに行き着きました。どちらも今回初めてまともに触ったので、その記録を残しておきます。


なぜ cron じゃないのか

「Mac で定時に何かを走らせる」と聞くと、まず思い浮かぶのは Linux でもおなじみの cron ではないかと思います。私もそうでした。
ただ、調べてみると macOS では十年以上前から、cron は「動くけど推奨ではない」状態が続いていて、Apple が公式に勧めているのは launchd という別の仕組みのようです。最初は「動くなら別にどっちでもいいのでは」と思ったのですが、調べていくとそうではない理由がいくつか見つかりました。

1. Apple のスタンスがはっきり「launchd」

cron のマニュアル(man cron)を Mac で読むと、冒頭近くに「Although cron(8) and crontab(5) are officially supported under Darwin, their functionality has been absorbed into launchd(8)」と書かれています(※)。
要するに「動かしてはあげるけど、本来の役割は launchd に吸収済みだから、新規はそっちを使ってね」という、Apple の柔らかい告知です。今すぐ cron が消えるわけではないにせよ、将来の macOS のバージョンアップで静かに削除されても文句は言えない、という立て付けのようです。新しく書くものなら、最初から launchd に寄せておいた方が、年単位で見たときに安心だと判断しました。

※・・・macOS 同梱の crontab(1) / crontab(5) man ページにある「Darwin note」より。
(手元で man crontab を叩くと確認できます)

2. ログの出力先の扱いがかなり違う

cron の場合、スクリプトの標準出力と標準エラーは、デフォルトでは「ユーザー宛のメール」に流れます。Mac で mail コマンドを使うようにセットアップしていないと、実質的には黙って捨てられます。ファイルに残したいなら、crontab の各行ごとに >> /path/to/log 2>&1 のようなリダイレクトを書き足す必要があります。書き忘れたら、その日のログは消えてしまいます。

一方 launchd は、plist という設定ファイルに StandardOutPathStandardErrorPath というキーを書くだけで、自動的にそのファイルへ追記され続けます。スクリプト本体はリダイレクトを意識しなくていい。「設定ファイル側で出力先まで宣言的に決められる」という気持ちよさが、こちらにはあります。1 本だけならどちらでもいいのですが、後でジョブが 3 本 4 本と増えてきたとき、launchd の方が破綻しにくい構造でした。

3. 電源管理層との統合

Mac には電源管理を司る pmset というコマンドがあり、ノート PC のスリープや wake のスケジュール、バッテリー残量に応じた挙動などを細かく制御できます。後述する「夜中に Mac を起こす」設定も、この pmset で行います。
launchd は、この pmset 層と同じ Apple 純正のスタックの上に乗っていて、相性が良いです。一方 cron は POSIX 由来の古いツールで、Mac の電源管理層とは緩い関係です。

補足: POSIX(ポジックス)は「Portable Operating System Interface」の略で、IEEE が定めた UNIX 系 OS の共通仕様のことです。Linux、macOS、各種 BSD などはこの POSIX に概ね準拠しているため、lscron のような基本的なコマンドはどの環境でも似たように動きます。逆に言うと、POSIX の枠の中で定義されているものは「どの UNIX でも動くベースライン」ではあるものの、各 OS 固有の事情(macOS の電源管理など)には踏み込まないので、そこは別の仕組み(macOS なら launchd)が担当している、という棲み分けになっています。

「夜中の Mac でも動かしたい」というユースケースを真っ直ぐ叶えに行くと、自然と launchd + pmset の組み合わせに辿り着きます。

ここまで来ると、「動くからいい」ではなく「この組み合わせで Apple は想定している」という安心感がほしくなって、launchd を選びました。

launchd とは何か

launchd は、macOS が起動した瞬間から動き続けている「親プロセス」のような存在です。Mac の中で動くほぼすべてのバックグラウンドサービスは、launchd が面倒を見ています。

私たちが「定時に何か走らせたい」と思ったとき、launchd に対して「このスクリプトを、この時刻に走らせてね」という指示書を渡します。その指示書のことを plist(Property List)と呼びます。.plist という拡張子の、XML 形式のファイルです。

例えば「毎日 3 時 0 分に、あるスクリプトを走らせる」だけの最小の plist は、こんな構造になります。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.example.my-daily-job</string>

    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/path/to/your-script.sh</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>3</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

    <key>StandardOutPath</key>
    <string>/path/to/logs/stdout.log</string>

    <key>StandardErrorPath</key>
    <string>/path/to/logs/stderr.log</string>
</dict>
</plist>

読み解くと、

  • Label は、この仕事の識別名。com.<自分の識別子>.<仕事の名前> の逆ドメイン形式が慣習で、これがそのままジョブの ID になります。
  • ProgramArguments は、走らせるコマンドを配列で書きます。シェルスクリプトなら /bin/bash と、そのスクリプトのフルパス。
  • StartCalendarInterval が肝で、時刻指定で起動します。HourMinute を入れれば、毎日その時刻に走ります。
  • StandardOutPath / StandardErrorPath は、出力先のログファイルです。指定しないと、出力は静かに捨てられます。

このファイルを ~/Library/LaunchAgents/ というフォルダに置いて、

launchctl load ~/Library/LaunchAgents/com.example.my-daily-job.plist

を 1 回叩けば、launchd の管理下に入ります。あとは指定時刻になれば、勝手にスクリプトが走ります。

登録できたかは、

launchctl list | grep my-daily-job

で確認できます。1 行出てくれば登録済みです。

ここでぶつかった壁

設定が終わって、満足してターミナルを閉じて、その夜は寝ました。
翌朝、ログファイルを確認しに行ったのですが、何も書き出されていません

おかしいな、設定間違えたかな、と plist を見直したり、launchctl list を確認したりしたのですが、登録自体は問題なくされている。スクリプトを手動で叩くと、ちゃんと動く。
しばらく考えて、ようやく気づきました。「夜中、Mac は寝ていた」のだと。

そうなのです。launchd が定時に起動する、というのは「Mac が起きている前提」での話です。スリープに入っているマシンは、launchd の指示も実行されません。デスクトップ Mac で電源が入りっぱなしの環境なら問題ないのですが、私の Mac は普通に夜は蓋を閉じてスリープ状態です。

pmset で Mac そのものを起こす

ここで先ほど少し触れた pmset(Power Management Settings)の出番です。これを使うと、「毎日この時刻に Mac を起こしてね」というスケジュールを Mac の電源管理層に直接書き込めます。

スリープ中の Mac を起こすコマンドは、こんな書き方です。

sudo pmset repeat wake MTWRFSU 02:58:00

分解すると、

  • pmset は電源管理のコマンド。
  • repeat は「繰り返しスケジュール」モード。
  • wake は「スリープから起こす」動作。
  • MTWRFSU は曜日コードで、M(月) T(火) W(水) R(木) F(金) S(土) U(日) の頭文字。これは「毎日」を意味します。木曜だけ R なのは、T が火曜に使われているからです。日曜が U なのも、S が土曜だからです。慣れるまでは少し戸惑いました。
  • 02:58:00 は起床時刻。

電源管理層に書き込むので、sudo で 1 回 Touch ID 認証が必要です。けれど、それさえ通れば設定は永続化されます。再起動しても、消えません。

私の場合は、launchd で組んだジョブを 3:00 に走らせたかったので、その 2 分前の 2:58 に起こすようにしました。起きてから 2 分間で目を覚ましきってもらって、ちょうど 3:00 にスクリプトが走るイメージです。

確認は、

pmset -g sched

で、出力の「Repeating power events」の項目に、自分が登録したスケジュールが入っていれば OK です。
ちなみに「Scheduled power events」の方には、com.apple.alarm.user-invisible-... という macOS 自身が登録している保守メンテ用の wake が並んでいることが多いです。これは触らなくて大丈夫でした。

なお、ノート PC で蓋を閉じた状態で動かすときは、電源アダプタを繋いでおくことを忘れずに。バッテリー駆動のみのときは、Mac がバッテリー保護のために wake schedule をスキップすることがあります。私は寝る前に充電器を挿してから蓋を閉じるようになりました。

動いた朝

設定をやり直して、その日もまた、満足してターミナルを閉じて寝ました。
翌朝、おそるおそるログのフォルダを開くと、ちゃんと前夜 3:00 のタイムスタンプでファイルが生まれていました。中を覗くと、私が指定した Markdown フォルダの一覧が、きれいに書き出されていました。

何でもない数行のログなんですが、自分が寝ている間にマシンが約束通り起きて、頼んでおいた仕事を済ませて、何事もなかったかのようにまた眠っていた、というのは、想像していたよりずっと気持ちのいいものでした。

解除したくなったときのために

最後に、自分用のメモも兼ねて、解除の手順も残しておきます。

launchd のジョブを止めるには、

launchctl unload ~/Library/LaunchAgents/com.example.my-daily-job.plist
rm ~/Library/LaunchAgents/com.example.my-daily-job.plist

pmset の起床スケジュールを解除するには、

sudo pmset repeat cancel

これで元通り、Mac は静かに夜を過ごしてくれます。

結びに

「自動化」という言葉はよく聞きますが、自分のマシンで、自分のために、夜中に何かを走らせる、という小さな自動化は、思っていたよりも手触りがあって楽しい体験でした。
最初の一歩は、特定のフォルダの中身を朝にログ出力する、という何の役にも立たなさそうな小さな仕事です。けれど、ここから少しずつ「結果を別のところに通知する」「複数のフォルダを見るようにする」と広げていけば、自分の生活に馴染んだ道具になっていくはずです。

Mac で何か定時に走らせたい、けれど夜中も含めて確実に動かしたい。そういう人にとって、launchdpmset の組み合わせは、最初の一手として悪くない選択肢だと思います。