• 作成:

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は指定していません。指定する場合はeth1enp9s0などの実際のインターフェイス名を書く必要がありますが、これらは環境によって異なり予測不能なので、指定せずデフォルトのまま機械に決定させるのが良いでしょう。

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を設定することで、コンテナは専用のネットワーク名前空間で動作します。 hostAddresslocalAddressでホストとコンテナの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"afterrequiresを設定し、 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設定をそのまま囲むだけでコンテナ化できるため、移行が容易で保守性も維持できます。自宅サーバのサービス分離にはおすすめです。