【Docker】インターネット接続をさせたくないデバイスをDockerのChronyで時刻同期をする

OSのサポート切れのデバイスの時刻同期をなるべく安全に行いたいと思ったのが、やろうと思ったきっかけです。  
ぶっちゃけた話、外部からの通信を制限しているLAN内ならば普通にインターネット接続して時刻同期してもいいと思うし、手間がかかりません。  
（基本的にはOSのサポート切れのデバイスでインターネット接続をするのはお勧めしません。接続する場合は自己責任で行ってください。）

## 概要
- Ubuntu 22.04 LTSのDockerコンテナでChronyを動かして、LAN内のWindows 7とAndroid 10の時刻同期をする
- Windows 7とAndroid 10の時刻同期をするときは、Router上流のネットワーク接続を切断してオフラインで行う

## 想定読者
- セキュリティリスクを考慮して、サポート切れのOSが搭載されているデバイスをオフラインで時刻同期したいというニッチな要望をお持ちの方
- ホストPCに余計なソフトウェアを入れたくないとか環境を汚したくないという事情があるが、NTPサーバーを立てて時刻同期してみたいという方

## ネットワーク構成
<img width="800" alt="docker-container-ntp-diagrams" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3715783/d0016899-4e14-3fe2-e794-2aad64899f76.png">
Dockerネットワーク内のChrony Clientは不要ですが、内部で時刻同期できているかの確認で使うために用意しています。

## ChronyのDockerコンテナを動かすホストPC
- Ubuntu 22.04 LTS: 192.168.11.3
  - Docker rootless
    - Client Version 27.4.1
    - Server Version 27.4.1
  - Docker Compose version v2.32.1

## インターネット接続させたくないデバイス
- LIFEBOOK AH42/C Windows 7（2011年製っぽい）: 192.168.11.2
- Xperia Ace SO-02L Android 10（2019年6月1日発売っぽい）: 192.168.11.4

## 手順
1. Dockerコンテナを準備する
1. Chronyの設定ファイルを編集する
1. ホストPCのufwの設定を変更する
1. Dockerコンテナを動かしてChrony Serverの時刻同期をする
1. Router上流のインターネット接続を切る
1. Windows 7とAndroid 10をLANに接続する
1. Windows 7にNTPサーバーの参照先を設定する
1. Android 10にNTPサーバーの参照先を設定する
1. Windows 7を時刻同期する
1. Android 10を時刻同期する

## 1. Dockerコンテナを準備する
- 任意のディレクトリに下記のディレクトリ構成を作成します
```
./chrony-related
│
├─debi-test
│
└─ub-test
```

- ub-testディレクトリに移動してChrony ServerのDockerfileを作成します
<details open><summary>Chrony ServerのDockerfile</summary>

```dockerfile:Dockerfile
FROM ubuntu:22.04
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
    iproute2 \
    iputils-ping \
    chrony \
    vim \
    tzdata \
    tcpdump \
    && apt-get -y clean \
    && rm -rf /var/lib/apt/lists/* \
    && ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
```
</details>

- `docker image build -t chrony-ub:1 .`コマンドを実行してイメージをビルドします
- ビルドができたら`docker container run -dit --name chrony-ub --rm chrony-ub:1 bash`で一度コンテナを動かします
- `docker container cp chrony-ub:/etc/chrony/chrony.conf .`でchronyの設定ファイルをホストPCにコピーします
- `docker container stop chrony-ub`でコンテナを停止します

- debi-testディレクトリに移動してChrony ClientのDockerfileを作成します
<details open><summary>Chrony ClientのDockerfile</summary>

```dockerfile:Dockerfile
FROM debian:latest
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
    iproute2 \
    iputils-ping \
    chrony \
    vim \
    tcpdump \
    tzdata \
    && apt-get -y clean \
    && rm -rf /var/lib/apt/lists/* \
    && ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
```
</details>

- `docker image build -t chrony-debi:1 .`コマンドを実行してイメージをビルドします
- ビルドができたら`docker container run -dit --name chrony-debi --rm chrony-debi:1 bash`で一度コンテナを動かします
- `docker container cp chrony-debi:/etc/chrony/chrony.conf .`でchronyの設定ファイルをホストPCにコピーします
- `docker container stop chrony-debi`でコンテナを停止します

chronyがあれば十分ですが、トラブルシューティング用にiproute2、iputils-ping、tcpdump等をインストールしています。  
tzdataとln -sfのところは、Dockerコンテナ内で日本時間を表示できるようにするためのものですが、時刻同期には無関係なので無くても問題ありません。

- ディレクトリ構成は下記のようになります
```
./chrony-related
│
├─debi-test
│      chrony.conf
│      Dockerfile
│
└─ub-test
        chrony.conf
        Dockerfile
```

## 2. Chronyの設定ファイルを編集する
- Chrony Serverの/ub-test/chrony.confに下記を追記します
```
元々記載されていた時刻同期先をコメントアウトして、任意の同期先を追加
#pool ntp.ubuntu.com        iburst maxsources 4
#pool 0.ubuntu.pool.ntp.org iburst maxsources 1
#pool 1.ubuntu.pool.ntp.org iburst maxsources 1
#pool 2.ubuntu.pool.ntp.org iburst maxsources 2
server ntp.nict.jp minpoll 6 maxpoll 8
server 0.jp.pool.ntp.org minpoll 6 maxpoll 8
server 1.jp.pool.ntp.org minpoll 6 maxpoll 8
server 2.jp.pool.ntp.org minpoll 6 maxpoll 8

アクセス許可するホストのネットワークを指定しますが、Dockerネットワーク外のLANからのアクセスは全てデフォルトゲートウェイになるため、実質飾りみたいなものです
allow 192.168.254.0/24

Chrony Server自体が時刻同期できない状態でも時刻同期を提供できるように、自身のstratumを10として設定します
local stratum 10
```

- Chrony Clientの/debi-test/chrony.confに下記を追記します
```
元々記載されていた時刻同期先をコメントアウトして、Chrony ServerのIPアドレスを追加
#pool 2.debian.pool.ntp.org iburst
server 192.168.254.10 iburst
```

## 3. ホストPCのufwの設定を変更する
- `sudo ufw allow from 192.168.11.0/28 to any port 123 proto udp`を実行して、LAN内からの時刻同期の問い合わせを受け入れられるようにします
  - これが実質的なアクセス許可設定になります
  - iptablesやfirewalldを使っている場合は、そちらのやり方でUDP123番ポートを受け入れられるようにしてください

## 4. Dockerコンテナを動かしてChrony Serverの時刻同期をする
- `sudo sysctl -w net.ipv4.ip_unprivileged_port_start=123`を実行して、Dockerが123番ポートを公開できるようにします
  - rootlessなDockerの場合は、1024番ポート以下をデフォルトで公開できないために必要な操作になります
  - 上記のコマンドは一時的に123番ポート以上を公開できるようにするもので、ホストPCを起動しなおすと元の1024番ポート以下が公開できない状態に戻ります
- chrony-relatedディレクトリに移動してdocker-compose.ymlを作成します
<details><summary>docker-compose.yml</summary>

```yml:docker-compose.yml
services:
  ubtest:
    build: ub-test
    image: chrony-ub:1
    container_name: ubuntu
    command: >
      bash -c '
      chronyd -x && bash
      '
    tty: true
    ports:
      - "123:123/udp"
    volumes:
      - ./ub-test/chrony.conf:/etc/chrony/chrony.conf
    networks:
      internal:
        ipv4_address: 192.168.254.10

  debian:
    build: debi-test
    image: chrony-debi:1
    container_name: debi
    command: >
      bash -c '
      chronyd -x && bash
      '
    tty: true
    volumes:
      - ./debi-test/chrony.conf:/etc/chrony/chrony.conf
    networks:
      internal:
        ipv4_address: 192.168.254.20

networks:
  internal:
    name: ntp-network
    ipam:
      config:
        - subnet: 192.168.254.0/24
```
</details>

- ディレクトリ構成は下記のようになります
```
./chrony-related
│  docker-compose.yml
│
├─debi-test
│      chrony.conf
│      Dockerfile
│
└─ub-test
        chrony.conf
        Dockerfile
```

- `docker compose up -d`を実行してDockerコンテナを動かします
- 5～10分くらい経ってから`docker compose exec ubtest chronyc sources`を実行します
  - 下記のように`*`がついている現在同期しているソースがあれば、/ub-test/chrony.confに記載した同期先と通信できています
```
MS Name/IP address         Stratum Poll Reach LastRx Last sample               
===============================================================================
^* ntp-k1.nict.jp                1   6    77    53    +13ms[  +16ms] +/-   28ms
^+ ntp-b2.nict.go.jp             1   6    77    52    +19ms[  +19ms] +/-   28ms
^+ v160-16-113-133.ntp.tky2>     3   6    77    52    +13ms[  +13ms] +/-   24ms
^- 45.159.48.231                 3   6    77    52    +23ms[  +23ms] +/-  163ms
```

- `docker compose exec ubtest chronyc tracking`を実行してReference IDを確認します
  - 下記のように/ub-test/chrony.confに記載した同期先のどれかになっていれば、Chrony Serverは時刻同期できています
```
Reference ID    : 3DCD7882 (ntp-k1.nict.jp)
Stratum         : 2
Ref time (UTC)  : Sat Jan 25 06:59:50 2025
System time     : 0.038299233 seconds fast of NTP time
Last offset     : +0.000589726 seconds
RMS offset      : 0.003223524 seconds
Frequency       : 92.161 ppm fast
Residual freq   : +0.378 ppm
Skew            : 52.468 ppm
Root delay      : 0.015766550 seconds
Root dispersion : 0.030345995 seconds
Update interval : 64.8 seconds
Leap status     : Normal
```

- `docker compose exec debian chronyc tracking`を実行してReference IDを確認します
  - 下記のようにChrony Serverになっていれば、Dockerネットワーク内での時刻同期ができています
```
Reference ID    : C0A8FE0A (ubuntu.ntp-network)
Stratum         : 3
Ref time (UTC)  : Sat Jan 25 07:00:59 2025
System time     : 0.046149909 seconds fast of NTP time
Last offset     : -0.000116804 seconds
RMS offset      : 0.004550459 seconds
Frequency       : 110.957 ppm fast
Residual freq   : +0.325 ppm
Skew            : 27.168 ppm
Root delay      : 0.015842594 seconds
Root dispersion : 0.016304253 seconds
Update interval : 64.7 seconds
Leap status     : Normal
```

## 5. Router上流のインターネット接続を切る
- Router上流側のLANケーブルを外してインターネット接続を切ります

## 6. Windows 7とAndroid 10をLANに接続する
- Windows 7とAndroid 10をRouterに接続してLANにつなげます

## 7. Windows 7にNTPサーバーの参照先を設定する
- 管理者権限でcmd（コマンドプロンプト）を開きます
- `sc start w32time`を実行してWindows タイム サービス（W32Time）を起動します
  - `net start w32time`でも起動できます
  - GUIでインターネット時刻設定から時刻同期するときも、Windows タイム サービスが起動しています
- `w32tm /config /update /manualpeerlist:192.168.11.3,0x8 /syncfromflags:manual`を実行して、時刻同期の参照先としてホストPCを設定します
- `sc stop w32time`を実行してから`sc start w32time`を実行して、Windows タイム サービスを再起動し、設定を反映させます
- `w32tm /query /configuration`を実行して、`NtpServer:`の値にホストPCのIPアドレスが記載されていることを確認します

上記と同様の操作はGUIのインターネット時刻設定からも可能です。

## 8. Android 10にNTPサーバーの参照先を設定する
- adbが使えるPCとAndroid 10をUSBケーブルで接続します
  - adbの導入や接続時の手順は省略します
- PCでPowerShellを開きます
- `adb shell settings put global ntp_server 192.168.11.3`を実行して、時刻同期の参照先としてホストPCを設定します
- `adb shell settings get global ntp_server`を実行して、ホストPCのIPアドレスが表示されることを確認します
- `adb kill-server`を実行してPCとの接続を切れるようにします
  - これを実行しないとPC側でadbのプロセスが残ったままになります
- USBケーブルを外します

## 9. Windows 7を時刻同期する
- Windows 7の管理者cmdで`w32tm /resync`を実行して、手動で時刻同期を行います
  - 「コマンドは正しく完了しました。」というような表示が出れば時刻同期が成功しているはずです
- `w32tm /query /source`を実行して、ホストPCのIPアドレスが表示されることを確認します

これでWindows 7の時刻同期は完了です。  
個人的に、0.何秒とかのずれに収まっていれば十分かなと思っております。

## 10. Android 10を時刻同期する
- デバイスを再起動して再度LANにつなげることで時刻同期を行います
  - adbで時刻同期を行う方法は見つけられませんでした
  - デバイスのWi-Fiをオンオフする操作では時刻同期を行うことができませんでした
    - ホストPCのWiresharkで、デバイスからの時刻同期問い合わせのパケットが届いていないことを確認しています

これでAndroid 10の時刻同期は完了です。  
`docker compose exec ubtest chronyc -a clients`を実行すると、時刻同期問い合わせをしてきたクライアントを確認できます。
```
Hostname                      NTP   Drop Int IntL Last     Cmd   Drop Int  Last
===============================================================================
debi.ntp-network               23      0   6   -    25       0      0   -     -
192.168.254.1                   2      0   7   -   116       0      0   -     -
```
`debi.ntp-network`がChrony Clientで、`192.168.254.1`がLAN内のデバイスになります。

## 参考にしたサイト
- [chrony（NTPd代替）の設定方法](https://www.bigbang.mydns.jp/chrony-x.htm)
- [【NTPサーバ】Chrony ver. 4.6 の設定について](https://qiita.com/ponponpoko/items/e8f0777b881d60c1da59)
- [【Centos7】chronyc sources コマンドの出力結果の見方](https://rurukblog.com/post/chronycsources/)
- [Chronyd inside LXC conatiner](https://serverfault.com/questions/1052509/chronyd-inside-lxc-conatiner)
- [chronyd(8) Manual Page](https://chrony-project.org/doc/4.6.1/chronyd.html)
- [WindowsサーバNTPクライアント設定/確認コマンドまとめ](https://qiita.com/sugenuma/items/d48a3543e22cf497589e)
- [Windows-NTPコマンド設定](https://momo-chienoki.com/Windows/Windows_Command_NtpSettings/)
- [android でLAN内のNTPサーバーに時刻を合わせる](https://qiita.com/ykatsu111/items/b97f26da8360078f68f9)

## ハマったところ
- DockerコンテナでのChronydの起動方法を見つけるのに手間取った
  - `chronyd -x`を使えば起動できることを見つけて解決できました
  - Dockerコンテナなので`systemctl`は使えないですし、`service`コマンドは無意味でした
- Chrony Serverの/etc/chrony/chrony.confでアクセス許可するIP範囲を、LANの`192.168.11.0/28`にしていて時刻同期できなかった
  - Dockerネットワークの`192.168.254.0/24`を許可することで解決できました
  - Chrony Serverでtcpdumpを使って、LANからの全てのアクセスはDockerネットワークのデフォルトゲートウェイのIPアドレスになっていることがわかりました