NixOS を(非力な)サーバで使用する際に、インストールされているパッケージを人手を介さずに自動更新するための方法を紹介します。
#NixOS における自動更新の難しさ
サーバのような、常時動作していて外部からアクセス可能なマシンを運用する場合、 security fix などのソフトウェアの更新を継続的に適用することが必要です。この作業を人間がいちいち行うのは大変手間なので、自動的に更新が適用される仕組みを用意すると便利です。
Ubuntu などの Linux distribution では unattended-upgrade のような仕組みが整備されており、特に悩むことなくソフトウェアの自動更新を有効にできます。このような機能を有効化すると、定期的にパッケージのレポジトリに更新を見に行き、必要であればソフトウェアの更新が実施されます。
NixOS で同等の操作をするには、NixOS が利用する nixpkgs のコミットハッシュを自動で更新する必要があります。この目的のために、NixOS には system.autoUpgrade
という設定が用意されています。NixOS Wiki を見ると “Automatic system upgrades” というページがあり、このページに従って system.autoUpgrade
を設定すれば、nixpkgs の自動更新が有効にできそうに思えます。
しかし system.autoUpgrade
を使うと、管理している flake.lock
の値と実際にデプロイされている nixpkgs とがズレるという問題があります[1]。サーバに NixOS をデプロイする際、その設定を管理する Nix Flake のソースコードは GitHub などで管理するのが一般的でしょう。このような状況で system.autoUpgrade
を有効化すると、サーバ上の flake.lock
のみが更新され、GitHub で管理されている flake.lock
の値とずれることになります。その結果、例えば GitHub 側での更新を取り込もうとサーバ上で git pull && nixos-rebuild switch
を実行すると、自動更新が適用されていない古い flake.lock
が適用され、せっかく更新されたソフトウェアがダウングレードされてしまいます。
また別の問題として、通常のワークロードをさばきながら NixOS のビルドを行うにはサーバのマシンパワーが十分でない可能性があります。ソフトウェアのビルドを行う場合は言わずもがなですし、仮に必要なパッケージが全てキャッシュからダウンロードできる場合でも、NixOS 自体のビルドにリソースが持っていかれます。実際、CPU 2 コア・メモリ 2GB 程度の VPS で nixos-rebuild switch
を実行すると、全てのパッケージがキャッシュに乗っている場合でも、途中でシステムの応答がなくなってしまうことがありました。このような状況では、そもそもマシン上で nixos-rebuild switch
を打って更新を行うことができないため、当然 system.autoUpgrade
も使えません。
#解決策
以上のような状況を踏まえて実用的な自動更新の仕組みを考えてみます。前提として、NixOS の設定は Nix Flake で管理しており、かつソースコードは GitHub 上で管理しているものとします。
まず flake.lock
にズレが発生するのを防ぐために、GitHub 側で flake.lock
を更新した後、それをサーバへ適用する必要があります。これには GitHub Actions(GHA)が便利です。GHA の workflow ではスケジューリングの設定(一日一回など)が行えるので、この機能を使って定期的に flake.lock
が更新されるようにします[2]。
さらに GHA 上でサーバへの適用も行います。nixos-rebuild
には
--target-host
というオプションがあり、これを使うことで GHA 上でパッケージのビルドを行いつつ、その結果だけをサーバへ SSH で送信できます。これによって、サーバが非力であっても NixOS の更新を行えます。
ただし nixos-rebuild switch
に --target-host
をつけて使うことは避けたほうが無難です。というのも、更新の際にネットワーク系の systemd service が
reload・restart されると通信が途切れてしまい、その結果、更新が中途半端に止まってしまうことがあるからです。代わりに、まず nixos-rebuild boot
で必要なデータの転送を行った後、
nixos-rebuild switch
を(サーバ側に Nix ファイルは置かないため、正確には /nix/store/.../bin/switch-to-configuration switch
を)打つ
transient な systemd service をサーバ側で起動させます。このようにすることで、ネットワーク周りの更新や再起動も行えます。なお nixos-rebuild boot
の代わりに nixos-rebuild dry-activate
などを使用してもおそらく問題ないと思いますが、万が一 switch が失敗した場合でも強制再起動で更新を終えることができるので、ここでは nixos-rebuild boot
を使うことにします。
#実装
仕組みは説明し終えたので、以下は実際のセットアップを紹介します。
事前準備として、サーバに Tailscale を導入しておきます。Tailscale でなくても、
GHA からサーバへ SSH できるトンネルを用意するものであれば何でも間に合いますが、
GHA 上で nixos-rebuild boot
などを実行する都合上、root でログインできる必要があります[3]。Tailscale SSH を使うと GHA からのみ root ログインを許容するような ACL を設定できるので、一番便利でかつセキュリティ的にも大きな穴を空けずに済みます。
GHA 上では以下のような workflow を使います(環境変数等は適当に設定する必要があります):
name: Unattended upgrade
on:
workflow_dispatch:
schedule:
- cron: "0 9 * * *" # スケジューリングはお好みで。
# 同時に 2 つの workflow が走らないようにする。
concurrency:
group: unattended-upgrade
cancel-in-progress: true
jobs:
upgrade:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: cachix/install-nix-action@v30
# まず flake.lock を更新する。
- name: update flake.lock
run: |
nix flake update
# 差分があれば commit&push する。
- uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "unattended-upgrade: flake.lock"
branch: master
commit_options: "--no-verify --signoff"
file_pattern: flake.lock
skip_dirty_check: false
skip_fetch: true
# Tailscale をセットアップする。
- name: Tailscale
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
# 適切な tag をつけた上で、Tailscale ACL で
# server への root での SSH を許可する。
tags: tag:gha
# nixos-rebuild boot を各サーバで打つ。
# 実際の処理は nixos-rebuild-boot action で行う。
- name: "server1: nixos-rebuild boot"
id: server1-boot
uses: ./.github/actions/nixos-rebuild-boot
with:
hostname: server1
target-host: ${{ secrets.SERVER1_TARGET_HOST }}
# 必要なだけ↑を並べる。
# switch-to-configuration switch を各サーバで打つ。
# 実際の処理は systemd-run-nixos-switch action で行う。
- name: "server1: systemd-run nixos switch"
uses: ./.github/actions/systemd-run-nixos-switch
with:
target-host: ${{ secrets.SERVER1_TARGET_HOST }}
nix-store-path: ${{ steps.server1-boot.outputs.nix-store-path }}
# 必要なだけ↑を並べる。
# 更新が失敗したら Discord に通知する。
- if: ${{ failure() }}
name: notify to Discord if build failed
run: |
curl \
-H "Content-Type: application/json" \
-d "{\"username\":\"${{ vars.DISCORD_USERNAME }}\",\"content\":\":x: unattended upgrade on GHA failed. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \
${{ vars.DISCORD_WEBHOOK_URL }}
workflow 内で使っている nixos-rebuild-boot action と systemd-run-nixos-switch action は、それぞれ以下のようになっています:
name: "nixos-rebuild boot"
description: "nixos-rebuild boot"
inputs:
hostname:
required: true
description: hostname
target-host:
required: true
description: target host
outputs:
nix-store-path:
description: nix store path
value: ${{ steps.main.outputs.nix-store-path }}
runs:
using: composite
steps:
- name: main
id: main
shell: bash
# nixos-rebuild boot を --target-host 付きで打つ。
# その後 switch-to-configuration のパスを取得しておく。
# この値は systemd-run-nixos-switch で使う。
run: |
export NIX_SSHOPTS="-o StrictHostKeyChecking=no"
nix run nixpkgs#nixos-rebuild -- boot \
--flake path:.#${{ inputs.hostname }} \
--target-host ${{ inputs.target-host }}
echo "nix-store-path=$(nix eval --raw path:.#nixosConfigurations.${{ inputs.hostname }}.config.system.build.toplevel.outPath)" >> $GITHUB_OUTPUT
name: systemd-run nixos switch
description: systemd-run nixos switch
inputs:
target-host:
required: true
description: target host
nix-store-path:
required: true
description: nix store path
outputs: {}
runs:
using: composite
steps:
- name: setup
shell: bash
# switch-to-configuration switch を実行する。
# 途中でネットワーク系の systemd service が再起動しても良いように、
# systemd-run を使って transient service として実行する。
# その後、そのサービスの終了を待つ。
run: |
ssh -o StrictHostKeyChecking=no ${{ inputs.target-host }} -- \
systemd-run --unit=anqou-nixos-switch \
${{ inputs.nix-store-path }}/bin/switch-to-configuration switch
while [ $(ssh -o StrictHostKeyChecking=no ${{ inputs.target-host }} -- \
systemctl is-active anqou-nixos-switch) = "active" ]
do
echo "still active"
sleep 10
done
if [ $(ssh -o StrictHostKeyChecking=no ${{ inputs.target-host }} -- \
systemctl is-active anqou-nixos-switch) != "inactive" ]; then
exit 1
fi
#まとめ
GHA 最高!
#注釈
-
これ自体はそこまで珍しくないアプローチです。この記事で紹介されるように、GHA が更新した
flake.lock
を単に pull して適用する systemd service を動かすというのも、サーバマシンに十分な余力がある場合は現実的だと思います。まさにこれを行うための comin というツールもあります。 ↩ -
root でログインできない場合
--use-remote-sudo
オプションを使ってサーバ側でsudo
を実行させるという手もありますが、この場合は標準入力経由で root のパスワードを(三回)要求されます。これに GHA 上で対応するのは面倒なので root ログインする方が楽でしょう。 ↩