😎

nixos-anywhereを真似て、VPSにNixOSをTerraformでインストールした

2025/03/11に公開

先日のNix Meetup #2で、nixos-anywherediskoが話題になったようです。私も2年ほど昔、nixos-anywhere(当時は確か、nixos-remoteという名前でした)を参考に、GitHub Actions上でTerraformを使ってVPSにNixOSをインストールするMinimal Viable Product (MVP)を構築していました。今回、このリポジトリをprivateからpublicに変更して公開しました。この記事はそのレポジトリの内容の解説です。

https://github.com/akirak/hcloud-nixos-terraform-example

プロジェクトの構成

今回のプロジェクトの構成は以下の通りです。

  • VPS: Hetzner Cloud CPX21 (3 vCPUs, 4 GB RAM, 80 GB SSD) in Hillsboro, USA
  • NixOS Configuration: github:akirak/homelab#shu (shuはMandarinで"舒”) (flake.nix) (ディレクトリ)
  • State backend: Terraform Cloud ※現在は使っていません。ライセンスが変更されたため、他の選択肢がおすすめです。
  • CI/CD: GitHub Actions

Hetznerはドイツに本社があるクラウド業者です。同様のサービスを提供している日本で有名な業者として、DigitalOceanやVultrなどがあります。Hetznerといえば、nix-communityがHetznerのdedicated(物理)サーバをビルドサーバとして利用していますが、今回利用しているHetzner Cloudは仮想マシンのサービスです。Hetznerの物理サーバも仮想サーバも、他業者と比べると値段の割に高いスペックが魅力ですが、サーバのロケーションが限られています。よって、マシンスペックはなるべく高いほうがいいが、ユーザからのアクセスのレイテンシーは最重要ではないという用途に適しています。今回はCDの検証が目的なので問題ありません。

NixOS configurationは、VPSの検証用に定義したホストなので、機能はほとんどありませんが、インフラ面で以下の特徴があります。

  • Remote disk unlockingに対応しています。ファイルシステム全体(boot/UEFIを除く)がLUKSで暗号化されており、Linuxのstage-1 bootでSSHサーバを起動します。インスタンスを起動した後、ユーザがログインしてパスワードを入力するまで、その後の起動プロセスには進めず、主要なサービス等が開始しないようになっています。ただし、VPSでこれをやるのは意味がなさそうです(詳細は、後の「VPSの暗号化は、面倒なだけで意味がないかもしれない」を参照)。
  • ファイルシステムの構成にはdiskoを使っています。自動化が簡単になるので、こういう実験的なプロジェクトには便利でした。
  • Erase your darlingsを実践しており、​/var​配下、​/nix​配下、およびあらかじめ指定した特定の/etc配下のファイル以外のファイルは、インスタンスを再起動するとwipeされるようになっています。(ここではZFS用の設定方法ではなく、tmpfsに置いたファイルが消滅する最も基本的な方法を使っています)
  • Cachix Deployのagentが動いており、GitHubリポジトリのmasterが更新されたらCachixを経由してNixOSのプロファイルがswitchされるようになっていました。(ただし現在は私のflakeでCachix Deployの出力をコメントアウトしており、CDは無効になっています。)

Terraformの処理内容

Hetzner Cloudは、NixOSのISOイメージを提供していますが、VMインスタンス作成時にNixOSをインストールすることはできません。いったんDebian等のインスタンスを作成してから、NixOSに入れ替える必要があります。この操作を毎回手動で実行するのは嬉しくないので、Terraformで自動化しようというのが本プロジェクトの趣旨です。

https://nixos.wiki/wiki/NixOS_friendly_hosters

NixOSをリモートでインストールするための方法としては、nix-communityのnixos-anywhereがあります。2023年1月頃に、私もnixos-anywhere(当時はnixos-remoteという名前だった)を使ってHetzner CloudにNixOSをインストールしようとしたものの、断念した記憶があります。当時のメモにはit was slowと書かれています。何がslowだったのかは不明です。さらにはIPアドレスが変わってSSHで接続できないという事象も発生していたようです(?)。

nixos-anywhereのIssuesを見ると、2025年3月の現時点でもまだ多くのチケットがopenのままになっており、コーナーケースが多いようです。

代わりにnixos-anywhereを「分解」して、同等の内容をシェルスクリプトとTerraformで再現することにしました。それが今回公開したリポジトリの内容です。

処理の内容は、main.tfの通り、以下のようになっています。

  1. NixOSのkexecイメージをnix-community/nixos-imagesからダウンロードし、kexec実行。
  2. kexecでNixOS起動後、Terraformの変数に設定された公開鍵やパスフレーズ等の情報をfile provisionerでファイルに保存。
  3. 手製のインストーラスクリプトを実行し、GitHub上にある自分のリポジトリからNixOS環境をインストールし、再起動。

処理の大部分は、Terraformのホスト環境からVPS (Hetzner Cloud)のサーバにコマンドを送信することになるので、remote-exec provisionerを使います。 サーバは、​hcloud_server provider​を使って以下のように定義されています。

resource "hcloud_server" "shu" {
  name        = "shu"
  image       = "114690387"
  server_type = "cpx21"
  location    = "hil"
  public_net {
    ipv4_enabled = true
    ipv6_enabled = true
  }
  ssh_keys = [
    "${hcloud_ssh_key.ephemeral_ssh_key.id}",
  ]

  connection {
    type        = "ssh"
    user        = "root"
    host        = self.ipv4_address
    private_key = file(var.private_key_file)
  }

  provisioner "remote-exec" {
    inline = [
      "apt-get update",
      "DEBIAN_FRONTEND=noninteractive apt-get -y install kexec-tools",
      "curl -L https://github.com/nix-community/nixos-images/releases/download/nixos-unstable/nixos-kexec-installer-x86_64-linux.tar.gz | tar -xzf- -C /root",
      "/root/kexec/run",
      # Keep the session open before the machine starts booting into NixOS
      "sleep 6"
    ]
  }

  provisioner "remote-exec" {
    inline = [
      "mkdir -p /persist"
    ]
  }

  provisioner "file" {
    source      = var.luks_passphrase_file
    destination = local.luks_pass_file
  }

  provisioner "file" {
    content     = <<-TOKEN
      CACHIX_AGENT_TOKEN=${var.cachix_agent_token}
    TOKEN
    destination = local.cachix_agent_token_temp_file
  }

  provisioner "remote-exec" {
    script = local_file.nixos_installer.filename
  }

  # Wait for NixOS to reboot
  provisioner "local-exec" {
    command = "sleep 10"
  }
}

インストーラ(local_file.nixos_installer)では、ユーザから渡されるトークン等を含んだファイルのパスを、​templatefile​関数を使って次のように実行時に埋め込んでいます。

resource "local_file" "nixos_installer" {
  filename = "install-nixos.sh"
  content = templatefile("${path.module}/install-nixos.sh", {
    "nixos_config" : local.nixos_config
    "disko_config" : local.disko_config
    "boot_ed25519_key" : local.boot_ed25519_key
    "luks_key" : local.luks_key
    "luks_pass_file" : local.luks_pass_file
    "luks_device" : local.luks_device
    "cachix_agent_token_temp_file" : local.cachix_agent_token_temp_file
  })
}

インストーラの内容は、NixOSの標準的なインストールプロセスに概ね沿ったものになっています。 diskoでファイルシステムを作成・マウントした後、各種キーの生成や保存をしています。 最後に​nixos-install​を実行した後、インスタンスを再起動する必要がありますが、コマンドを最後まで実行しないとエラーになり、CDのワークフローが失敗と判定されてしまうため、次のように​systemd-run​で1秒遅延させて非同期で再起動を開始します。

systemd-run --on-active=1 reboot

以下にインストーラ(install-nixos.sh)の全文を示します。検証用だったため、きれいなコードではなくデバッグ用のコマンドを多数仕込んでいます。

#!/usr/bin/env bash

set -euox pipefail

disko_command="nix run github:nix-community/disko \
    --extra-experimental-features nix-command \
    --extra-experimental-features flakes \
    --no-write-lock-file --"

dd if=/dev/urandom of=${luks_key} bs=512 count=4

# disko fails to mount partition if create and mount are done in a single
# execution. It may be safer to create and mount in separate steps and running
# sync between them.
$${disko_command} --mode create --flake "${disko_config}"
sync

fdisk -l

$${disko_command} --mode mount --flake "${disko_config}"

findmnt -m --real

mkdir -p /mnt/persist
echo "${nixos_config}" > /mnt/persist/config-flake

ssh-keygen -t ed25519 -N "" -f "${boot_ed25519_key}"
boot_key="/mnt${boot_ed25519_key}"
mkdir -p $(dirname $boot_key)
cp "${boot_ed25519_key}" "$boot_key"

# The encryption key is copied to an encrypted partition in the installation,
# but not to the initrd. You can back it up to your local machine later.
luks_key="/mnt${luks_key}"
mkdir -p $(dirname $luks_key)
cp "${luks_key}" "$luks_key"

wc -c "${luks_pass_file}"

cat "${luks_pass_file}" | cryptsetup --key-file="${luks_key}" luksAddKey ${luks_device}
cat "${luks_pass_file}" | cryptsetup luksOpen --test-passphrase ${luks_device}

mkdir -p /mnt/etc
cp "${cachix_agent_token_temp_file}" /mnt/etc/cachix-agent.token

nixos-install \
    --no-write-lock-file \
    --no-root-password \
    --no-channel-copy \
    --show-trace \
    --option accept-flake-config true \
    --flake "${nixos_config}"

findmnt -m --real

ls /mnt

# Use `shutdown -r now` to only shutdown
systemd-run --on-active=1 reboot

GitHub Actionsのワークフロー

GitHub ActionsでTerraform (OpenTofu)が動きます。privateリポジトリで運用するMVPだったので最小限のCD構成にしており、以下のライフサイクルになっています。

  1. PRを出すと、​tofu plan​が動きます。validationやセキュリティチェック等もここで動きます。
  2. masterブランチにpushされると、​tofu apply​が動き、VPSインスタンスがprovisionされます。GitHubの​workflow_dispatch​から手動でも実行可能です。
  3. provisionされたインスタンスは、任意のタイミングでworkflow_dispatchから手動でdestroyできます。作成されたインスタンスをすぐにdestroyすれば、VPSの費用はほぼゼロです。

ログインのしかた

オペレーションを定期的に練習しなさいというDevOpsの推奨プラクティスに従って、以下の手順を毎月1回実施していました。

  1. ワークフローを手動実行してインスタンス作成。
  2. SSHでログインし、暗号化解除。
  3. ワークフローでインスタンスをdestroy。

事前準備として、VPSにインスタンスするためのSSH公開鍵や、Hetzner CloudのAPIトークン等をTerraform Cloud(もしくは代替のサービス)に事前に登録しておく必要があります。 GitHub側には、Terraformのトークンをシークレットとして保存する必要があります。

ワークフローを実行してインスタンスが作成されたら、Hetzner CloudのダッシュボードなどでインスタンスのIPを確認します。

以下のコマンドでインスタンスにログインして、ファイルシステムの暗号化を解除します。

# インスタンスのIPを設定
ip=xxx.xx.xx.xx
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
    -p 222 "root@$ip" cryptsetup-askpass

SSHクライアントの​StrictHostKeyChecking=no​と​UserKnownHostsFile=/dev/null​オプションは、​known_hosts​ファイルのチェックと更新を無効化するためのオプションです。Remote disk unlockingを使う場合は、暗号化解除前後でSSHのホストキーが変わるため、このオプションを指定しないと頻繁に​​~/.ssh/known_hosts​ファイルを削除する必要が生じます。Remote disk unlockingを使わない場合も、サーバインスタンスを頻繁に作り直すときにはおすすめのオプションです。このtipはXe Iasoさんの記事で紹介されていたものです。私はzshで次のエイリアスを定義しています。

alias ssh-ignore='ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'

暗号化を解除した後も同様のコマンドでSSHに接続します。ポート番号は変えています。

ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
    -p 2022 root@$ip

暗号化解除後のSSH接続を便利にするために、VPSにデプロイされるNixOSマシンにTailscaleをインストールしておくのもいいでしょう。先ほども紹介したXe Iasoさんによる記事では、NixOSマシンをTailscaleのサーバに自動接続するためのcloudinitによる方法が説明されています。

https://xeiaso.net/blog/nix-flakes-terraform/

kexecには十分なメモリーが必要

nixos-install​を実行するための一時的なNixOS環境への切り替えにはkexecを使いますが、これには十分なメモリーが必要です。nixos-anywhereのドキュメントでは、swapを除いて少なくとも1GBは必要と記載されています。 私がHetzner Cloudで試したときは、メモリー2GBのインスタンスでもメモリー不足で失敗しました。 4GBあれば十分なようです。 ただし、メモリーが4GBもあるVPSは、以下に示すようにHetzner Cloud以外の業者では料金がなかなか高いです。

主なVPS業者のメモリー2GB/4GB最廉価プランの、月額料金比較

2GB 4GB
Vultr Regular Performance $10 $20
DigitalOcean Basic Regular Droplets $12 $24
Hetzner Cloud AMD Germany $5.09 $8.59
Hetzner Cloud Ampere (ARM) Germany - $4.59

ちなみに、私が使ったインスタンスの価格(Hetzner Cloud CPX21 in US)は、2023年1月時点では月7.55ユーロでした。当時の為替レートで8.21ドルです。同クラスのインスタンスが、現在は月10.59ドルですので、2年間で3割値上がり(ドル換算)しています。

VPSの暗号化は、面倒なだけで意味がないかもしれない?

以上のように、ファイルシステムの暗号化まで施したNixOSサーバを、GitHub ActionsからVPSにデプロイするMVPを2年前に作りました。しかしその後、このサーバは手順確認以外の目的で使っていません。

Remote disk unlocking対応のファイルシステム暗号化は、サーバに入っているデータを盗まれないようにという意図に基づいていますが、物理的なハードウェアからVMの実行環境まで業者が管理している以上、業者に悪意があったら無力だそうです。たとえばリモートからSSH経由で打鍵したパスフレーズも、サーバ管理者なら盗むことが可能なようです。本記事の中で、暗号化の部分はnixos-anywhereとの組み合わせで使うことはないでしょう。

個人利用のサーバについては現在、VPSは利用せず、物理サーバを自分の管理下で運用するか、AWSなどのパブリッククラウド(の中でも主にmanagedサービス)を使う以外の方向性は検討していません。これはNixOSと関係ないVPS一般の話ですし、仮にVPSが適している用途があるとしたら、NixOSを使うことになると思います。ちなみに物理サーバでは、私は現在もremote disk unlockingを実践中です。

Dependabot(Renovateも同様)の恐怖

このリポジトリはGitHub Actionsを使っており、アクションを更新するためにDependabotを使っています。費用を抑えるため普段はインスタンスは存在しないのですが、dependabotのPRをマージすると、masterにpushされるためapplyのワークフローが動き、インスタンスが作成されてしまいます。インスタンスができたことに気づかずに翌月Hetznerから課金されたことが何度かありました。幸いにも費用は1回10ドル未満で済みましたが。安全のため、現在はGitHub Actionsのワークフローを無効にしています。一時的にしか稼働させないつもりの有料インスタンスを、GitHub Actionsでprovisionするようなワークフローを構築すると、dependabot/renovateが原因で課金されてしまうという教訓でした。対策はいくらでもあるでしょうが、落とし穴の存在を知っておくことが重要です。

余談: diskoについて

LUKSによる暗号化やLVMにも対応しており、複雑なファイルシステム構成を宣言的に定義して、破壊的操作までやってくれるdiskoについて、私はNixOSの物理マシンでも一部導入したものの、現在ではほとんど使っていません。過去にdisko自体のスキーマが変わったことがあり、ファイルシステムの破壊的操作さえもこのツールに任せながら、長期的に間違いなくアップグレードしていけるのだろうか?と不安を抱きました。

「DBの寿命はアプリよりも長い」という格言がありますが、同様に「ファイルシステムの寿命はNixOSホストよりも長い」ようなライフサイクルでの利用を想定している場合(たとえばLinuxで自前のNASを構築する場合)、diskoはあまり使わないほうがいいと思います。

一方で、diskoが適している場合もあり、それは今回のようにファイルシステムのライフサイクルとマシンのライフサイクルが一致し、マシンのライフサイクルが短期間に限られる場合だと思います。diskoを使うことでファイルシステムの生成を簡単に自動化できるので、イテレーションを速くしたい状況では役に立つでしょう。

このあたりの話は、もしかしたら既に先日のmeetupで述べられていたかもしれませんが、私は参加しておらず詳細な内容を知らないので、備考として意見を残しておきます。

まとめ

nix runで簡単に使えることが魅力となるはずだったnixos-anywhereですが、案外とコーナーケースが多く、期待通りに使えない場合もあります。今回は、nixos-anywhereの代わりにTerraformとbashを使った古典的なスクリプティングで、NixOSをVPSにデプロイできました。場合によってはNixで課題を解決するのが難しく、一見原始的な方法で解決せざるを得ないのかもしれません。状況を適切に見極めながらNixと付き合っていきたいと思いました。

Discussion