さくらのVPSでNixOSを使い、Kamal でコンテナをデプロイする
ここ数日間色々とやっていた、が、何をどうしたか良く分からんくなってきたので整理する
ブログにまとめようかと思ったけど、複雑怪奇なので Cosense のままが良さそう
基本的な情報
VNC のキーボードは US配列
インスタンスは 2 CPU 1GB メモリの SSD 50GB → 二倍して 100GB
リージョンは石狩
NixOSをインストールする
まずは NixOS を さくらの VPS にインストールする
さくらのVPSの機能であるカスタムISOインストール機能を使う
さくらのVPSで先程DLした ISO を上げる
sftp で繋いで put latest-nixos-minimal-x86_64-linux.iso すれば OK
あとは up した iso をブートしてインストールする
実際のさくらのVPSにおける構成は下記の通り
パーティション分けは下記
code:_
/dev/vda - GPT でフォーマット
/dev/vda1 - BIOS BOOT Partition
/dev/vda2 - EFI System
/dev/vda3 - SWAP
/dev/vda4 - NIXOS (btrfs)
ファイルシステムのマウントは下記
code:_
基本的には impermanence rootfs でやっていく
また不要な実行権限は mount に noexec を付けてファイルシステムレベルで落す
---
btrfs で使っている mount options
- executable - compress=zstd,ssd,space_cache=v2
- readwrite - 上記に加えて noexec,nosuid,nodev
---
/ - tmpfs
boot/ - /dev/vda2 (vfat)
nix/ - /dev/vda4 - executable (subvol=/nix)
tmp - /dev/vda4 - executable (subvol=/tmp)
persist/
etc/ - readwrite (subvol=/persist/etc)
nixos/ - readwrite (subvol=/persist/etc/nixos)
var/ - directory
db/ - readwrite (subvol=/persist/var/db)
lib/ - readwrite (subvol=/persist/var/lib)
log/ - readwrite (subvol=/persist/var/log)
home/
docker/ - directory
.config/
docker/ - readwrite (subvol=/persist/home/docker/.config/docker)
.kamal/ - readwrite (subvol=/persist/home/docker/.kamal)
.local/ - directory
share/ - directory
data/ - executable (subvol=/persist/home/docker/.local/share/data)
docker/ - executable (subvol=/persist/home/docker/.local/share/docker)
このディレクトリ構成に従って /mnt へマウント構成を作り、nixos-install する
この際の bootloader は grub2 を使う
さくらのVPSの場合、BIOS+GPT で起動するので、EFI+GPTな構成でインストールすると boot しない
当初この仕様にハマった
罠
なお現在の構成は下記のリポジトリで見れます
さくらのVPSでシリアルコンソールを使いたい場合、次の設定を足す
code:sakura.nix
_: {
boot.kernelParams = [
"console=ttyS0,115200n8"
"console=tty0"
];
}
さくらのシリアルコンソールは最後の手段としてのデバッグに便利
……なんだけど、試用期間中であるためか、結構入力を取り漏らす
なのでパスワード認証が通らない、とか良くある
その代わり VNC コンソールはそこそこに動くので、そっちの方が便利な時は便利
セキュリティ対策
やっている事は下記
1. Docker rootless を使う
rootful な docker が不用意に 0.0.0.0 で listen すると port に穴があく
それを防ぐために docker rootless を使う
NixOS だと virtualisation.docker.rootless.enable = true; の設定一発で環境が構築される
便利
(2) の対策と合わせると、例え 0.0.0.0 で listen しても外部と疎通できない環境が作れる
逆に言うと穴を開けないと外部とどうやっても通信できない環境と化す
ミスってたりするとツラい
2. networking.firewall で原則としてポートは閉める。必要に応じて開ける
基本的には全部閉めた上で、allowedTCPPorts や allowedUDPPorts などを使う
穴を開ける時も必要最低限でやる
かつ穴開けするネットワークインターフェースも限定する
さくらの場合、IPv4+IPv6通信が出来る interface 以外に、もう二つ IPv6 だけが生える eth がある
よってインターフェースを限定することにより、誤ったポート開放を防ぐことができる
tailscale は tailscale0 という interface を生やす
これに対して firewall を適用することで、よりポートの穴を閉めることが出来る
3. openssh ではなく tailscale の ssh 機能を使う
openssh のポート開放は狙われまくるので基本使用しない
tailscale が狙われる率が無いとは限らないとけど、
VPN ソフトウェアなので、たぶん大丈夫やろ
と判断しています
油断やんけ
tailscale の ssh を使うと tailscale ネットワーク以外からは ssh できなくなる
そのため tailscale の VPN へ侵入できない限り何もできない!
あと ACL も設定しているので、早々簡単にはアタックできないはず
ただし tailscale のネットワークはプライベート用なので、ネットワーク侵害が起きると全部死ぬ
つまりセキュリティ侵害された時点で PC 環境がサヨナラ!してしまう
グエー死んだんゴ
4. 公開 https に Cloudflare Tunnel を使う
今のところ未実施
→ 実施した
どちらかというと IP を遮蔽してインスタンスを守る意味でやる予定
ただインスタンスから他のサーバ等にアクセスすると存在がバレるので、そこは気休め
そのためネットワーク帯域に対するDDoSの防御にはならない
特にFediverseを使ったりする予定なので、そこはアテにならないと考えた方が良い
とは言えフロントエンドへのDDoSは防げそうなので、それそれで割り切ることとする
5. インスタンス固有の情報は表に出さない
今の VPS の設定は下記のリポジトリで公開している
これは主にネットワーク情報や Firewall で開けているポート情報など
なお tailscale ネットワークにおける IP 情報は載せている
これは tailscale で振られる IP が 100.60.0.0/10 であるため
100.60.0.0/10 は Shared Address Space という ISP の内部用のIP空間
これにより 100.60.0.0/10 は他のネットワーク空間で重複し得る
あとプライベートの https を使う際にも公開しているんで害はあまりないと判断
とは言え個人レベルでの話なんで、商用プロダクトではやらない方が良いと思う
また NixOS で使う *.nix は別パーティションにおいて impure で読み込んでいる
今使ってる VPS だと、下記のコマンドでシステムの設定を反映させている
# nixos-rebuild [switch|build|boot] --flake /etc/nixos/#$(hostname) --impure
実際には $(hostname) は直打ち
Docker rootless を有効化する
これはシンプルにこうやった
code:docker.nix
_: {
virtualisation.docker.rootless = {
enable = true;
setSocketVariable = true;
daemon.settings = {
ip = "127.0.0.1";
};
};
users.groups.docker = {};
users.users.docker = {
group = "docker";
extraGroups = lib.mkForce ;
isNormalUser = true;
linger = true;
};
}
ポイント
virtualisation.docker.rootless.enable でお手軽 docker rootless
docker rootless を使った理由はセキュリティ向上のため
下手に rootful な docker を使うとfirewall に穴が空いてしまう
またコンテナ内部が root でも外に出たら docker:docker なので悪さはできない
virtualisation.docker.rooless.daemon.settings.ip = 127.0.0.1 で不用意なポート公開を抑止
これについては networking.firewall でも防げるけど、一応付けておく
users.users.docker.isNormalUser = true; で docker:docker アカウントを作っている
これは主にデバッグと状態の確認を行うため
この際、余計なグループには一切加入させない
例えば wheel などに入っていると sudo が使えるようになるけど、これはセキュリティリスク
これについては console:users アカウントで色々できるようにしている
users.users.docker.linger = true; は docker rootless をサービス化する際に必須
これが有効でない場合、docker:docker でログインしない限り docker daemon が永続化されない
最初この辺りの仕様を把握していなくてハマった
Docker Registry を建てる
これは Docker Registry を HTTP Basic Auth で建てつつ Caddy で https 化する、という手法を取った
code:docker-registry.nix
{ ... }: {
services.dockerRegistry = {
enable = true;
enableGarbageCollect = true;
garbageCollectDates = "*-*-* 04:00:00";
};
services.caddy = {
enable = true;
virtualHosts = {
"registry.nyke.server.thotep.net" = {
useACMEHost = "nyke.server.thotep.net";
logFormat = ''
output stdout
'';
extraConfig = ''
reverse_proxy 127.0.0.1:5000
'';
};
};
};
}
これのポイント
docker registry は https が前提なので security.acme.enable = true; で Let's Encrypt で証明書を発行する
この際設定を試行錯誤する場合においては次のテスト用サーバを使うこと
https://acme-staging-v02.api.letsencrypt.org/directory
こっちを使わないと本番サーバの Rate Limit に引っ掛って証明書が取れなくなる
また Caddy は tailscale の network で listen しつつ stdout でログを吐いている
stdout でログを吐いている件については手抜き
本来ならローテーションを付けてストレージに吐くべき
また listen する IP は tailscale 網の物についている
これは docker registry が必要になる場面が tailscale ssh 経由だけであるため
実際のところ kamal でしか使わないので、こう言う運用になっている
Kamal が使えるよういい感じにする
これ以降、色々ハマりどころがあったので、順番に
1. kamal を使うための ruby 環境を用意する
これは下記のような default.nix を用意し nix-shell でシェルに入る、という運用にした
code:default.nix
{
pkgs ? import <nixpkgs> { },
}:
with pkgs;
(buildFHSUserEnv {
name = "nyke-with-kamal";
targetPkgs =
p: with p; [
pkg-config
ruby
stdenv.cc.cc
stdenv.cc.cc.lib
stdenv.cc.libc
zsh
];
runScript = "zsh";
}).env
TIP: なぜ nix shell を使ってないか
自分の環境では <nixpkgs> = /etc/nixpkgs で /etc/nixpkgs がパッチを当てた nixpkgs であるため
この時 <nixpkgs> はシステムの nix flake で使っているものとなる
これにより import <nixpkgs> { }; で自動的に nix flake と同じ <nixpkgs> が使われる
そのため個人的には nix flake を使わなくても良いかな、という判断になりました nyarla.icon
2. 次に bundle install --vendor=vendor 経由で kamal をインストール
これは次のような Gemfile を用意してなんとかした
code:Gemfile
gem 'kamal', '2.3.0'
シンプル
3. 最後に bundle exec kamal --help などで kamal が実際に起動できるか確認する
実際にデプロイ
これが一番ハマりポイントだった
0. 下準備
1. docker registry を tailscale 網内で https を使って建てる
これはここまでの解説で既に書いた
2. tailscale ssh で SetEnv を扱えるようにした上で下記の環境変数をセットする
これは ~/.ssh/config で下記のような設定を行う
code:~/.ssh/config
Host nyke
HostName ...
SetEnv DOCKER_HOST=unix:///var/run/user/1001/docker.sock
tailscale ssh で SetEnv を使うためには ACL の 設定で "acceptEnv": ["DOCKER_HOST"] の設定が必要
部分的に抜粋するとこうなる
code:acl.json
{
"ssh": [
{
"action": "accept",
}
]
}
これが必要となる理由
1. docker rootless を使っていてコンテナをホストする専用のアカウントがある
docker rootless の場合 docker.sock が XDG_RUNTIME_HOME が生える
自分のインスタンスの場合、
docker ユーザーは id:1001
pam_systemd が有効な環境なので XDG_RUNTIME_HOME は /var/run/user/1001
よって docker.sock は /var/run/user/1001/docker.sock に生える
2. kamal が docker コマンドを走らせる際、DOCKER_HOST がセットされていない
そのため docker daemon が動いてねーよ的なエラーがでる
つまり docker コマンドが走らない
よってこの DOCKER_HOST を ssh 接続時にセットする必要がある
3. 上記の事柄から ssh 接続時に DOCKER_HOST をセットして云々する必要性が出た
……という事情によりこういった構成になった
1. kamal init でサービス定義を初期化
実際にはディレクトリを掘って bundle exec kamal init をしている
2. 次に kamal のデプロイファイルを書く
例として Linuxserver.io の FreshRSS だとこんな感じ
code:config/deploy.yml
---
service: freshrss
# registry
registry:
server: registry.nyke.server.thotep.net
username:
- KAMAL_REGISTRY_USERNAME
password: x
# build
image: kalaclista/freshrss
builder:
arch: amd64
context: ./context
dockerfile: ./context/Dockerfile
# deployment
proxy:
ssl: false
host: reader.nyke.server.thotep.net
app_port: 80
healthcheck:
path: /themes/icons/FreshRSS-logo.svg
ssh:
user: docker
servers:
web:
- nyke
# runtime
env:
TZ: Asia/Tokyo
volumes:
- /home/docker/.local/share/data/freshrss:/config
3. 最後に初期化デプロイをする
まず rootless docker を使っているためデフォルトの http ports を変える
コマンドは次の通り
$ kamal proxy boot_config set --http-port=8080 --https-port=8433
この設定をした後で $ kamal proxy reboot --rolling をする
これで(たぶん)kamal-proxy が動くようになる
次に該当するサービスで $ kamal setup をする
と、たぶん初期デプロイが動く
あとは更新があるたびに $ kamal deploy をする
デプロイ時のハマりどころ
1. Dockerfile の存在が必須
単純にサードパティが用意した既存の docker コンテナを使いたい場合でも必要(っぽい)
そのため自分のところではこのような Dockerfile を用意した
code:context/Dockerfile
FROM lscr.io/linuxserver/freshrss:1.24.3
ほぼ虚無
2. git リポジトリのすべてがリモードに upload される
そのため不要なファイルはすべて .gitignore に突っ込んでおく必要がある
自分の場合 .gitignore に * を指定して必要となるファイルを git add -f するようにした
3. config/deploy.yml は リポジトリに commit されている必要がある
kamal の場合 git リポジトリをリモートでも checkout しているらしく、そのため commit が必須
これもハマった……
4. Web サーバの生存確認に失敗すると、kamal-proxy がルーティングを即座に切断する
これは必要性があってこうなっている、とは思う
ただ、既存のアプリケーションで healthcheck 用のエンドポイントが生えてない場合、苦難に直面する
上記例で使った FreshRSS の場合、フロントの URL などがリダイレクトになっている
そのため 200 OK を返す http endpoint として既存のページが使えない
↑ については static assets を healthcheck の endpoint として使うことで解決した
よって API サーバなどを kamal で建てたい場合、healthcheck 用の Endpoint は必ず用意すること
NixOS + Rootless docker 自体のハマりどころ
事象: NixOS + tailscale + rootless docker でコンテナ内のホスト名解決が出来ない
これは色々と複雑に事象が絡みあっている
原因
1. tailscaled が /etc/resolv.conf を上書きしてしまっている
これにより rootless docker 内での名前解決に支障が出る
ただしこれは原因の一段階目
この問題は services.talscale.extraUpFlags 辺りで [ "--accept-dns=false" ] を指定すれば回避できる
2. services.resolved.enable = true; になってない
これは上記設定をシステムの nix 定義に組込めば ok
ただし networking.resolvconf.enable = true; だと conflict するので、networking の方が無効化する
3. NixOS で /etc/resolv.conf を上手く取り扱えていない
関連 Issue はこの辺り
rootless docker はネットワーク周りで slirp4netns を使っているので、これに引っ掛ってる
これについては下記の workaround でなんとか出来る
environment.etc."resolv.conf".mode = "direct-symlink";
ただし、この workaround は services.resolved.enable = true; が有効でないと使えない
なので、そこだけは注意
事象: docker@server へログインしていないと docker daemon が落ちる
原因
users.users.docker.linger = enable; が抜けているため
この設定が抜けていると docker:docker へのログイン時にのみ docker daemon が動くという仕様になる