blog.anqou.net
rss
author
tags

ぼくのかんがえたさいきょうの NixOS 自動更新

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 最高!

#注釈

  1. system.autoUpgrade の問題点については こちらの記事が詳しいです。

  2. これ自体はそこまで珍しくないアプローチです。この記事で紹介されるように、GHA が更新した flake.lock を単に pull して適用する systemd service を動かすというのも、サーバマシンに十分な余力がある場合は現実的だと思います。まさにこれを行うための comin というツールもあります。

  3. root でログインできない場合 --use-remote-sudo オプションを使ってサーバ側で sudo を実行させるという手もありますが、この場合は標準入力経由で root のパスワードを(三回)要求されます。これに GHA 上で対応するのは面倒なので root ログインする方が楽でしょう。