NixOS containersでForgejoとAtticdをコンテナ化した
背景
以前、自宅サーバにForgejoでGitのホスティングサーバを立ててCloudflare Tunnel経由でアクセスする - ncaq に書いたように自宅サーバでForgejoを運用しています。また、 Atticを利用して個人用にNixのキャッシュサーバを自宅サーバに構築する - ncaq に書いたように、 Attic というNixのバイナリキャッシュサーバも同じサーバで動かしています。
これらのサービスはホスト上で直接動作していたのですが、セキュリティ向上のためにコンテナ化することにしました。
なぜコンテナ化するのか
コンテナ化の主な目的はストレージの保護です。
ホスト上で直接サービスを動かしていると、万が一プログラムにバグがあった場合に、そのプログラムの担当範囲以外のストレージのコンテンツが破壊される可能性があります。コンテナ化することで、各サービスがアクセスできるファイルシステムを明示的に制限できます。
ネットワーク分離も副次的なメリットとしてあります。サービスごとにIPアドレスを分けることで、 Cloudflare Tunnelの設定もシンプルになります。
なぜNixOS containersか
NixOSにはNixOS Containersという機能があり、 systemd-nspawnベースの軽量コンテナを宣言的に定義できます。
NixOS containersを選んだ理由は、既存のNixOS optionsで宣言的に設定していたサービスを、そのままcontainers.<name>.configで囲むだけでコンテナ化できる簡単さです。
Docker/Podmanのように別途Dockerfileを書いたり設定を管理する必要がなく、ホストとコンテナの設定を簡易に管理できます。
逆に言うとNixOS optionsが提供されておらず、開発元が公式にDocker/Podmanイメージを公開している場合は、 Podmanを使って管理したほうが良いと思います。
実装
feat(seminar): 一部のサービスをコンテナ化 by ncaq · Pull Request #384 · ncaq/dotfiles で実装しました。
コンテナネットワーク設定の一元管理
分かりやすさのためにコンテナのIPアドレスとUID/GIDを一元管理するモジュールを作成しました。
# container-network.nix
{ lib, ... }:
let
addressType = lib.types.submodule {
options = {
host = lib.mkOption {
type = lib.types.str;
description = "Host-side IP address for the container";
};
container = lib.mkOption {
type = lib.types.str;
description = "Container-side IP address";
};
};
};
userType = lib.types.submodule {
options = {
uid = lib.mkOption {
type = lib.types.int;
description = "User ID (must match between host and container for PostgreSQL peer auth)";
};
gid = lib.mkOption {
type = lib.types.int;
description = "Group ID (must match between host and container for PostgreSQL peer auth)";
};
};
};
in
{
options.containerAddresses = lib.mkOption {
type = lib.types.attrsOf addressType;
default = {
forgejo = {
host = "192.168.100.10";
container = "192.168.100.11";
};
atticd = {
host = "192.168.100.20";
container = "192.168.100.21";
};
};
description = "Container network addresses";
};
options.containerUsers = lib.mkOption {
type = lib.types.attrsOf userType;
default = {
forgejo = {
uid = 991;
gid = 986;
};
atticd = {
uid = 993;
gid = 988;
};
};
description = "Container user/group IDs for PostgreSQL peer authentication";
};
config = {
networking.nat = {
enable = true;
internalInterfaces = [ "ve-+" ];
};
networking.firewall.trustedInterfaces = [ "ve-+" ];
};
}
containerAddressesオプションでIPアドレスを、
containerUsersオプションでUID/GIDを定義しています。これにより各サービスの設定ファイルからこれらの値を参照できます。
ve-+はNixOSコンテナが使用するvethインターフェースのワイルドカードパターンです。
NATとファイアウォールの設定でこれを指定することで、将来コンテナを追加しても自動的に対応できます。
networking.nat.externalInterfaceは指定していません。指定する場合はeth1やenp9s0などの実際のインターフェイス名を書く必要がありますが、これらは環境によって異なり予測不能なので、指定せずデフォルトのまま機械に決定させるのが良いでしょう。
Forgejoのコンテナ化
Forgejoをコンテナ化する設定は以下のようになりました。
# forgejo.nix
{ config, pkgs, ... }:
let
addr = config.containerAddresses.forgejo;
user = config.containerUsers.forgejo;
forgejoWrapper = pkgs.writeShellScriptBin "forgejo" ''
exec nixos-container run forgejo -- forgejo "$@"
'';
in
{
environment.systemPackages = [ forgejoWrapper ];
containers.forgejo = {
autoStart = true;
privateNetwork = true;
hostAddress = addr.host;
localAddress = addr.container;
bindMounts = {
"/run/postgresql" = {
hostPath = "/run/postgresql";
isReadOnly = true;
};
"/var/lib/forgejo" = {
hostPath = "/var/lib/forgejo";
isReadOnly = false;
};
};
config =
{ config, lib, ... }:
{
system.stateVersion = "25.05";
networking.useHostResolvConf = lib.mkForce false;
services.resolved.enable = true;
networking.firewall.trustedInterfaces = [ "eth0" ];
users.users.forgejo = {
uid = user.uid;
group = "forgejo";
};
users.groups.forgejo.gid = user.gid;
services.forgejo = {
enable = true;
database = {
type = "postgres";
createDatabase = false;
socket = "/run/postgresql";
};
settings = {
server = {
HTTP_PORT = 8080;
SSH_PORT = 2222;
DOMAIN = "forgejo.ncaq.net";
ROOT_URL = "https://forgejo.ncaq.net/";
SSH_DOMAIN = "forgejo-ssh.ncaq.net";
START_SSH_SERVER = true;
};
session = {
COOKIE_SECURE = true;
};
service = {
DISABLE_REGISTRATION = true;
REQUIRE_SIGNIN_VIEW = true;
};
repository = {
DEFAULT_BRANCH = "master";
};
};
};
environment.systemPackages = [ config.services.forgejo.package ];
};
};
users.users.forgejo = {
isSystemUser = true;
group = "forgejo";
uid = user.uid;
};
users.groups.forgejo.gid = user.gid;
systemd.tmpfiles.rules = [
"d /var/lib/forgejo 0750 forgejo forgejo -"
];
systemd.services."container@forgejo" = {
after = [ "postgresql.service" ];
requires = [ "postgresql.service" ];
};
}
ポイント
privateNetworkによるネットワーク分離
privateNetwork = trueを設定することで、コンテナは専用のネットワーク名前空間で動作します。
hostAddressとlocalAddressでホストとコンテナのIPアドレスを指定します。
PostgreSQLソケットのbindMount
PostgreSQLはホスト側で動作しており、コンテナからはUnixソケット経由で接続します。
/run/postgresqlをbindMountすることで、限られた権限で安全に接続できます。
UID/GIDの一致
PostgreSQLのpeer認証を使用するため、ホストとコンテナでUID/GIDを一致させる必要があります。
containerUsersで定義した値を両方で使用することで、
peer認証が正しく動作します。
ForgejoのビルトインSSHサーバ
コンテナ化する前はホストのOpenSSHサーバを経由してForgejoにSSH接続していましたが、コンテナ化するとホストのOpenSSHからコンテナ内のForgejoに接続するための設定が複雑になります。
そこでSTART_SSH_SERVER = trueを設定してForgejoのビルトインSSHサーバを有効にしました。これによりホストのOpenSSHを経由せず、
Forgejoが直接SSH接続を受け付けるようになります。
管理用ラッパースクリプト
ホストからforgejoコマンドを実行できるようにラッパースクリプトを追加しました。内部でnixos-container runを呼び出してコンテナ内のコマンドを実行します。
PostgreSQL起動後にコンテナを起動
systemd.services."container@forgejo"でafterとrequiresを設定し、
PostgreSQLが起動してからコンテナが起動するようにしています。
Atticdのコンテナ化
Atticdも同様にコンテナ化しました。基本的な構造はForgejoと同じですが、データディレクトリが/mnt/noa/atticd(HDD)になっている点が異なります。
ForgejoのデータはそのままSSDに、
Atticdのデータはサイズが大きくなりやすいためbcacheで高速化したHDDに配置しています。
# atticd.nix (抜粋)
let
addr = config.containerAddresses.atticd;
user = config.containerUsers.atticd;
atticadmWrapper = pkgs.writeShellScriptBin "atticd-atticadm" ''
exec nixos-container run atticd -- atticd-atticadm "$@"
'';
in
{
environment.systemPackages = [ atticadmWrapper ];
containers.atticd = {
autoStart = true;
privateNetwork = true;
hostAddress = addr.host;
localAddress = addr.container;
bindMounts = {
"/run/postgresql" = {
hostPath = "/run/postgresql";
isReadOnly = true;
};
"/mnt/noa/atticd" = {
hostPath = "/mnt/noa/atticd";
isReadOnly = false;
};
"/etc/atticd.env" = {
hostPath = "/etc/atticd.env";
isReadOnly = true;
};
};
# ...
};
}
Cloudflare Tunnel設定の更新
コンテナ化に伴い、 Cloudflare Tunnelの設定もコンテナのIPアドレスを参照するように変更しました。
# cloudflare.nix (抜粋)
let
forgejoAddr = config.containerAddresses.forgejo.container;
atticdAddr = config.containerAddresses.atticd.container;
in
{
services.cloudflared.tunnels.seminar = {
ingress = {
"forgejo.ncaq.net" = "http://${forgejoAddr}:8080";
"forgejo-ssh.ncaq.net" = "ssh://${forgejoAddr}:2222";
"nix-cache.ncaq.net" = "http://${atticdAddr}:8080";
};
};
}
以前はconfig.services.forgejo.settings.server.HTTP_PORTのようにサービスの設定を参照していましたが、コンテナ化後はコンテナのIPアドレスを直接参照するようになりました。
一応80のような特権ポートではなく1024以上の非特権ポートを使っています。今のところ内部では関係ないのですが。
PostgreSQLの設定
Forgejoはこれまではservices.forgejoの設定だけでPostgreSQLのデータベースやユーザーを自動作成していましたが、コンテナ化によりそれができなくなったので、
postgresql.nixにforgejoのデータベースとユーザーを明示的に追加しました。
# postgresql.nix (抜粋)
{
services.postgresql = {
ensureDatabases = [
"atticd"
"forgejo"
];
ensureUsers = [
{
name = "atticd";
ensureDBOwnership = true;
}
{
name = "forgejo";
ensureDBOwnership = true;
}
];
};
}
SSH接続時の警告抑制
ForgejoのビルトインSSHサーバはpost-quantum鍵交換をサポートしていないため、 OpenSSH 9.x以降では警告が出ます。これを抑制するためにSSH設定を追加しました。
# ssh.nix (抜粋)
{
programs.ssh = {
matchBlocks = {
"forgejo-ssh.ncaq.net" = {
proxyCommand = ''
${pkgs.cloudflared}/bin/cloudflared access ssh --hostname %h
'';
extraOptions = {
WarnWeakCrypto = "no-pq-kex";
};
};
};
};
}
トラブルシューティング
コンテナ内のDNS解決
コンテナ内でDNS解決ができない問題が発生しました。以下の設定で解決しました。
networking.useHostResolvConf = lib.mkForce false;
services.resolved.enable = true;
useHostResolvConfをfalseにしてホストの/etc/resolv.confを使わないようにし、代わりにコンテナ内でsystemd-resolvedを有効にしています。
PostgreSQL peer認証
コンテナからPostgreSQLに接続できない問題が発生しました。原因はホストとコンテナでUID/GIDが一致していなかったためでした。
containerUsersで明示的にUID/GIDを指定することで解決しました。
既存の環境からUID/GIDを調べるには以下のコマンドを使います。
id forgejo
id atticd
今後の課題
PostgreSQL接続方式の検討
現在はPostgreSQLのUnixソケットをbindMountしてpeer認証で接続していますが、 TCP接続の方が良かったかもしれません。
Unixソケット経由はオーバーヘッドが小さいですが、 TCP接続の方が原理原則が明確になります。将来的にPostgreSQLを別のマシンに移動することも容易になります。
今のところ問題は発生していないので、必要になったら検討します。
まとめ
NixOS containersを使ってForgejoとAtticdをコンテナ化し、ストレージ保護によるセキュリティ向上を実現しました。
主な変更点は以下の通りです。
container-network.nixでコンテナのIPアドレスとUID/GIDを一元管理privateNetwork = trueでネットワーク分離bindMountsで必要なディレクトリのみをコンテナに公開- PostgreSQLソケットをbindMountで共有してpeer認証を維持
- 管理用ラッパースクリプトでホストからコンテナ内のコマンドを実行可能に
- ForgejoのビルトインSSHサーバを有効化
NixOSのコンテナ機能は既存のNixOS設定をそのまま囲むだけでコンテナ化できるため、移行が容易で保守性も維持できます。自宅サーバのサービス分離にはおすすめです。
hatena-bookmark