このガイドのゴール
コンテナ 192.168.2.11 上に、SERVERセグメント内専用の DNS(Unbound)と NTP(Chrony)を構築します。これは今後のフェーズ全部が依存する「土台」です。HeadscaleのTLS証明書検証や、各サーバーのログの時刻整合性は、ここが正確であることが前提になります。
0. まず設計方針を理解する(3分で読めます)
0-1. なぜ「Unbound」なのか
DNSサーバーには大きく2種類の役割があります。
- 権威DNS:自分のドメインの正解を「発表する」役(今回はDMZのBINDがこれを担当)
- キャッシュ(再帰)DNS:色々なドメインの問い合わせを「代わりに調べてあげる」役
今回2.11が担うのは後者です。Unboundは軽量・高速で、LAN内専用のキャッシュDNSとして広く使われています。
0-2. なぜDMZ(172.16.1.x)はこのサーバーに頼らないのか
これが一番大事な設計判断です。これまでの検討で、こういう結論になりました。
もしDMZが2.11に依存すると…
↑ FortiGateに「DMZ→SERVERを許可する例外」が必要になる
→ これまで積み上げてきた「DMZ→SERVERは原則拒否」が崩れる
今回の設計:DMZは外部に直接問い合わせる
→ FortiGateの例外ルールが不要。DMZとSERVERの境界がそのまま守られる
なので、2.11の設定では「SERVER(192.168.2.0/24)以外からの問い合わせは拒否する」という制限を必ず入れます。
0-3. なぜpodman runにそのままapt-get installを書かないのか
コンテナ起動時のコマンドの中でapt-get installを実行する書き方には、2つの問題があります。
- コンテナが再起動するたび(サーバー再起動・障害復旧時など)に、毎回インターネットから再インストールが走る。つまり「LAN内の基盤であるDNSサーバーの復旧が、外部のインターネット接続に依存してしまう」という本末転倒な状態になります。
- 再現性がない。同じ手順で作っても、実行するたびにバージョンが微妙に変わる可能性があります。
そこで今回は、「Dockerfile」という設計図を1回だけ作り、そこからイメージ(完成品)をビルドしておく方式にします。一度ビルドすれば、あとは何度再起動してもインターネット接続なしで即座に起動します。
0-4. なぜ「起動スクリプト(entrypoint.sh)」が必要なのか
1つのコンテナの中で、DNS(unbound)とNTP(chrony)という2つの独立したプログラムを同時に動かす必要があります。コンテナは通常「1つの代表プロセス」が動いている間だけ生き続ける仕組みなので、2つを正しく管理する小さな「指揮役」のスクリプトを用意します。これが entrypoint.sh です。
0-5. なぜ「ディレクトリ」ではなく「ファイル」単位でマウントするのか
これは実装時の重要な注意点です。仮に -v ホスト側ディレクトリ:/etc/chrony:Z のようにディレクトリ単位でマウントすると、何が起こるか見てみましょう。
ディレクトリごと上書き(マウント)すると…
↓
コンテナ内の /etc/chrony/ は、ホスト側フォルダの中身(chrony.confのみ)で"完全に置き換え"られ、chrony.keysなど他のファイルが消えて見えなくなります(「ディレクトリ・マスキング」)
ファイル単位マウント(採用)
変更したいファイルだけをピンポイントでマウントし、パッケージが用意した他のファイルには触れないようにします。
今回の設定では実害が出にくいケースですが、将来的に機能を追加したときに気づきにくい不具合の原因になります。そのため、変更したいファイルだけをピンポイントでマウントする「ファイル単位マウント」を採用します。
構築手順
ディレクトリの準備
設定ファイルとDockerfileを置く場所を作ります。
sudo mkdir -p /srv/containers/identity/build
sudo mkdir -p /srv/containers/identity/unbound
sudo mkdir -p /srv/containers/identity/chrony
| ディレクトリ | 用途 |
|---|---|
build/ | Dockerfileと起動スクリプトの置き場所(イメージのビルド専用) |
unbound/ | Unboundの設定ファイル(コンテナを作り直しても消えない) |
chrony/ | Chronyの設定ファイル(同上) |
Unboundの設定ファイルを作成
sudo vi /srv/containers/identity/unbound/unbound.conf
以下の内容を貼り付けます。
server:
interface: 0.0.0.0
port: 53
do-ip4: yes
do-udp: yes
do-tcp: yes
# セキュリティ設定:SERVER内とループバック以外からの問い合わせは拒否
# ★172.16.1.0/24(DMZ)はここに書かない、というのが今回の設計判断です
access-control: 127.0.0.0/8 allow
access-control: 192.168.2.0/24 allow
# 内部ドメインの定義
local-zone: "home.local." static
local-data: "mgmt-server.home.local. IN A 192.168.2.3"
メモ:今後、他のコンテナ(Headscaleやmonitoringなど)が立ち上がるたびに、local-data: の行を1行ずつ追加していきます。書式は local-data: "ホスト名.home.local. IN A IPアドレス" です。
参考:全コンテナ追加後のlocal-data(完成形)
Phase 3〜5まで進めて全コンテナが揃うと、最終的に以下の9行になります。
local-data: "mgmt-server.home.local. IN A 192.168.2.3"
local-data: "macv0.home.local. IN A 192.168.2.99"
local-data: "identity-dns.home.local. IN A 192.168.2.11"
local-data: "monitoring.home.local. IN A 192.168.2.12"
local-data: "headscale.home.local. IN A 192.168.2.13"
local-data: "syslog.home.local. IN A 192.168.2.14"
local-data: "loki.home.local. IN A 192.168.2.15"
local-data: "idp.home.local. IN A 192.168.2.16"
local-data: "openziti.home.local. IN A 192.168.2.17"
Chronyの設定ファイルを作成
sudo vi /srv/containers/identity/chrony/chrony.conf
以下の内容を貼り付けます。
# 同期先の日本標準時サーバー(NICT)
pool ntp.nict.jp iburst
# クライアントへの時刻提供を許可する範囲(SERVER内のみ)
# ★ここにも172.16.1.0/24は書きません
allow 192.168.2.0/24
# 時刻が大きくズレていた場合、起動直後3回までは一気に補正する
makestep 1.0 3
Dockerfile(設計図)の作成
sudo vi /srv/containers/identity/build/Dockerfile
FROM docker.io/ubuntu:24.04
RUN apt-get update && \
apt-get install -y --no-install-recommends unbound chrony && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
# COPY直後に、コンテナ内部でも確実に実行権限を付与する
# (ホスト側でchmod +x済みでも、ビルド環境によっては権限が引き継がれないことがあるため、
# 念のためここでも明示しておくと、どの環境でビルドしても確実に動作する)
RUN chmod +x /entrypoint.sh
EXPOSE 53/udp 53/tcp 123/udp
ENTRYPOINT ["/entrypoint.sh"]
ポイント:RUN apt-get install は「イメージのビルド時」に1回だけ実行されます。podman run のたびに実行される旧方式とは違い、ここで完成形がイメージとして固定されます。
起動スクリプト(entrypoint.sh)の作成
sudo vi /srv/containers/identity/build/entrypoint.sh
#!/bin/bash
set -e
echo "[entrypoint] Unbound(DNS)を起動します..."
unbound -d -c /etc/unbound/unbound.conf &
UNBOUND_PID=$!
echo "[entrypoint] Chrony(NTP)を起動します..."
# -x: コンテナにはホストの時計を直接操作する権限がないため、その操作を無効化
chronyd -d -x -f /etc/chrony/chrony.conf &
CHRONY_PID=$!
# どちらかが異常終了したら、コンテナ全体も終了させる
# (DNSだけ死んでNTPだけ動き続ける「半死に状態」を防ぐため)
wait -n "$UNBOUND_PID" "$CHRONY_PID"
EXIT_CODE=$?
echo "[entrypoint] プロセスが終了しました(exit code: $EXIT_CODE)。コンテナを終了します。"
exit "$EXIT_CODE"
実行権限を付けます(こちらはホスト側での操作。前述の通り、Dockerfile側でも二重に保証しています)。
sudo chmod +x /srv/containers/identity/build/entrypoint.sh
イメージのビルド
cd /srv/containers/identity/build
sudo podman build -t identity-dns-ntp:latest .
COMMIT identity-dns-ntp:latest
--> xxxxxxxxxxx
Successfully tagged localhost/identity-dns-ntp:latest
Successfully tagged が出ればビルド成功です。
【推奨】本番起動の前に設定ファイルの文法チェック
いきなり起動して失敗の原因を調べるより、先に文法だけチェックすると効率的です。
sudo podman run --rm \
-v /srv/containers/identity/unbound/unbound.conf:/etc/unbound/unbound.conf:Z \
identity-dns-ntp:latest unbound-checkconf /etc/unbound/unbound.conf
unbound-checkconf: no errors in /etc/unbound/unbound.conf
エラーが出た場合は、ステップ2のファイルにタイプミス(インデントのズレなど、YAMLライクな書式は特にスペースの数に敏感です)がないか確認してください。
コンテナの起動
ステップ0-5で説明した通り、ここではディレクトリ単位ではなく、ファイル単位でマウントします。
sudo podman run -d \
--name identity-dns-ntp \
--network local_lan \
--ip 192.168.2.11 \
-v /srv/containers/identity/unbound/unbound.conf:/etc/unbound/unbound.conf:Z \
-v /srv/containers/identity/chrony/chrony.conf:/etc/chrony/chrony.conf:Z \
--restart always \
identity-dns-ntp:latest
sudo podman ps
CONTAINER ID IMAGE STATUS PORTS NAMES
b72c40a8a051 localhost/identity-dns-ntp:latest Up About a minute 53/tcp, 53/udp, 123/udp identity-dns-ntp
sudo podman logs identity-dns-ntp
[entrypoint] Unbound(DNS)を起動します...
[entrypoint] Chrony(NTP)を起動します...
chronyd version 4.5 starting ...
Selected source 133.243.238.164 (ntp.nict.jp)
実機検証済み:コンテナが正常に起動し、NICTのNTPサーバーと同期していることを確認しています。
動作確認(必ず実施)
sudo podman ps
identity-dns-ntp が Up 状態で表示されればOKです。もし数秒後に消えている場合は、起動直後にクラッシュしています。次のログ確認に進んでください。
sudo podman logs identity-dns-ntp
[entrypoint] Unbound(DNS)を起動します... と [entrypoint] Chrony(NTP)を起動します... の両方が表示され、エラーが出ていなければ正常です。
sudo podman exec identity-dns-ntp chronyc tracking
Reference ID : 3DCD7882 (ntp-k1.nict.jp)
Stratum : 2
System time : 0.000677686 seconds fast of NTP time
Leap status : Normal
Stratum 2(原子時計から2段目・高精度)、誤差1ミリ秒未満、Leap status: Normal。時刻同期は正常に機能しています。
Stratumが16のままだったり、Leap statusがNot synchronisedの場合は、まだ同期できていません。数分待ってから再確認してください(インターネット接続自体に問題がないかも合わせて確認してください)。
ホストにdigコマンドがない場合は先にインストールします。
sudo dnf install -y bind-utils
内部ドメインの解決テスト:
dig @192.168.2.11 mgmt-server.home.local
;; ANSWER SECTION:
mgmt-server.home.local. 3600 IN A 192.168.2.3
;; SERVER: 192.168.2.11#53(192.168.2.11)
ANSWER SECTIONに正しくホストのIPが返ってきています。
外部ドメインの再帰解決テスト:
dig @192.168.2.11 google.com
こちらもANSWER SECTIONにIPアドレスが返ってくれば、再帰DNSとして正常に機能しています。
【任意・推奨】ホストOSのDNS参照先を2.11に切り替える
今まで作った「SERVER内専用DNSサーバー(192.168.2.11)」を、ホストOS自身も使うように設定します。これにより mgmt-server.home.local のような内部名でサーバー同士が会話できるようになります。
nmcli connection show --active
NAME の列の値(例:enp1s0)をメモしてください。次のコマンドで使います。
<接続名> を手順1で確認した名前に置き換えて実行してください:
sudo nmcli connection modify <接続名> \
ipv4.dns "192.168.2.11" \
ipv4.dns-search "home.local" \
ipv4.ignore-auto-dns yes
| オプション | 意味 |
|---|---|
ipv4.dns "192.168.2.11" | DNSサーバーとして2.11を使う |
ipv4.dns-search "home.local" | mgmt-server と打つだけで mgmt-server.home.local として検索する |
ipv4.ignore-auto-dns yes | ルーター(FortiGate)が自動配布するDNS設定を無視する |
sudo nmcli connection up <接続名>
注意
一瞬ネットワークが切断されますが、すぐ再接続されます。SSHで作業している場合も通常は自動で戻ります。
① resolv.confの中身を確認
cat /etc/resolv.conf
# Generated by NetworkManager
search home.local
nameserver 192.168.2.11
nameserver 1.1.1.1
② 実際にDNS解決できるか確認
nslookup mgmt-server.home.local
Server: 192.168.2.11
Address: 192.168.2.11#53
Name: mgmt-server.home.local
Address: 192.168.2.3
実機検証済み:ホストOSからの内部名前解決も正常に機能しています。
トラブルシューティング
| 症状 | 原因の可能性 | 対処法 |
|---|---|---|
podman psに表示されない(起動直後に終了) | unbound.confかchrony.confの文法エラー | podman logs identity-dns-ntpでどちらのプロセスが落ちたか確認。ステップ7の文法チェックも活用 |
| digがタイムアウトする | コンテナがSERVER内から見えていない/access-controlの範囲外 | Phase 1のルート設定(ip route get 192.168.2.11)を再確認 |
| Permission deniedがボリュームマウントで出る | SELinuxのラベル不一致 | -vオプションの末尾に:Zが付いているか確認(既に付いていますが、設定ファイルを直接編集した後はrestorecon -R -v /srv/containers/identityを試す) |
| ChronyがいつまでもNot synchronised | コンテナからインターネットに出られない | sudo podman exec identity-dns-ntp ping -c 3 ntp.nict.jpで疎通確認。FortiGate側でSERVERセグメントの外向き通信が許可されているか確認 |
dig @192.168.2.11 google.comは失敗するがmgmt-server.home.localは成功する | 内部ゾーンの応答とインターネットへの再帰解決は別経路。SERVERからインターネットへの出口がブロックされている可能性 | ホストOS(2.3)からping 8.8.8.8などで、コンテナ自体ではなくSERVER全体の外向き疎通を確認 |
| ステップ10後、ホストの名前解決が全体的に不安定になった | DNS参照先を2.11単独にしてしまい、2.11再起動中に解決できない瞬間がある | フォールバック用DNSを2番目に追加(手順2の ipv4.dns に複数指定可能) |
フェーズ2完了時の構成図
ここまでで、以下のような状態が出来上がっています。「DMZはこの基盤に依存しない」という設計思想が反映されています。
DMZ(172.16.1.x)
SERVERの2.11は使わず、こちらを直接参照する
SERVER(192.168.2.0/24)
↑ SERVER内のPC・サーバーのみ利用可(DMZからの問い合わせは拒否される)
フェーズ2完了チェックリスト
podman psで identity-dns-ntp が Up 状態podman logsにUnbound・Chrony両方の起動メッセージがあり、エラーなしchronyc trackingで Stratum が16ではない数値、Leap status: Normaldig @192.168.2.11 mgmt-server.home.localが 192.168.2.3 を返すdig @192.168.2.11 google.comが正しくIPを返す(再帰解決の確認)- マウントがファイル単位(unbound.conf/chrony.confそれぞれ個別)になっている
- unbound.conf・chrony.confのどちらにも 172.16.1.0/24 が含まれていないことを再確認した
- (任意)ホストOSのDNS参照先をnmcli経由で2.11に切り替えた(DMZには適用していないことを確認)
すべてチェックできれば、フェーズ2は完了です。