Docker 上の OpenVPN:最小限のクライアントに必要な実行契約
レビュー済みのイメージ、保護されたプロファイル一つ、TUN アクセス、NET_ADMIN。コンテナが得るもの、得てはならないもの、トンネルが機能することをどう証明するか。
VPN クライアントをコンテナに入れると、内部にトンネルを持つ名前空間が一つ得られますが、すべての通信を VPN 経由でルーティングするホストが得られるわけではありません。これは別々のプロジェクトであり、両者を一つとして扱うことが、コンテナが意図せず特権を持つに至る経路です。このガイドが扱うのは前者だけであり、レビュー済みのイメージ一つ、保護されたプロファイル一つ、そして自らのネットワーク名前空間に閉じ込められたクライアントプロセスです。
すでに誰かが承認した成果物から始め、以下の手順は、openvpn 実行ファイルと、証明書および鍵素材がマウント内ですでに解決済みの完全なクライアントプロファイルを含むイメージを前提とします。ソフトウェアをビルドせず、資格情報を発行せず、root なしのランタイムやネイティブの Windows コンテナも扱いません。それぞれが答えを変えるためです。
コンテナが得るものと得ないもの
プロファイルとそれが参照するすべてを、制限された権限のローカルディレクトリに置き、読み取り専用でマウントしますが、プロファイル内の相対パスはそのマウント内で解決される必要があります。証明書がプロファイルの一つ上のディレクトリにあり、コンテナから決して見えないときに人々が見落とすのはこの点です。
services:
vpn:
image: ${VPN_CLIENT_IMAGE:?set the reviewed image reference}
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
volumes:
- ./vpn:/vpn:ro
working_dir: /vpn
entrypoint: ["openvpn"]
command: ["--config", "/vpn/client.ovpn"]
そのファイルが与えるのは三つだけで、それ以外は求めません。デプロイ方針がそう定めるならイメージ参照はダイジェストで固定すべきであり、プロファイルはレビュー済みのものであるべきで、ホスト上で誰かがその場で編集したコピーではありません。
ここでの読み取り専用は、コンテナがホスト上のファイルを変更できないという意味であり、マウントした秘密鍵を読めないという意味ではありません。それがマウントの狙いそのものであり、ファイル権限、所有権、そして秘密がどうホストに置かれたかが今も重要な理由です。パスワードはコマンドラインで渡したりイメージ層に焼き込んだりしてはいけず、どちらも後から消せない場所に残ります。
ケーパビリティ、entrypoint、そしてその間の罠
必要な設定は二つで、それぞれ無関係な仕事をします。NET_ADMIN はコンテナの名前空間内でインターフェースとルートの操作を許可します。/dev/net/tun のデバイス項目は、プロセスがトンネルデバイスを開くこと自体を許可し、片方だけを与えると明確に失敗するので便利です。TUN デバイスが欠けていれば開く時点で失敗し、ケーパビリティが欠けていればクライアントがインターフェースを設定しようとした時点で失敗します。
どちらも privileged: true を正当化しません。特権コンテナはすべてのケーパビリティに加えてデバイスアクセスを得ますが、これはこのワークロードが必要とするよりはるかに大きな許可です。実行時の特権とケーパビリティに関するリファレンスは、その許可がどこまで届くかを説明しています。chart やブログ記事がそれを求める場合は、まず要件が実は欠けたデバイスパスや欠けたケーパビリティだったのではないか確認してください。
entrypoint の上書きが効いてくる部分です。openvpn を直接呼ぶのは素のイメージには正しく、起動スクリプトがデーモンを実行する前にファイアウォール規則やリゾルバファイル、資格情報を準備する製品イメージには誤りです。そのスクリプトを上書きすると準備が飛ばされ、中途半端に設定された名前空間で立ち上がったトンネルをデバッグすることになります。
クライアントはフォアグラウンドで動かし、コンテナの主プロセスにします。デーモン化するプロセスはすぐに終了し、コンテナはループで再起動し、必要なログはどれも同じに見える再起動の間に散らばります。
名前空間の内側から検証し、ホストから検証しない
スタックを起動し、クライアントが接続状態に達したことをログで確認してから、実際にトンネルを保持している名前空間を調べます。
docker compose up -d
docker compose logs --tail 40 vpn
docker compose exec vpn ip -br addr
docker compose exec vpn ip route
アドレス一覧にはプールからのアドレスを持つトンネルインターフェースが表示され、ルートテーブルにはそこを指すプッシュされた宛先が表示されるはずです。インターフェースがなければ、クライアントはそれを作る段階に到達していません。存在してもどのルートも使っていないなら、見るべきは push かルートオプションです。
アドレス一覧にはプールからのアドレスを持つトンネルインターフェースが表示され、ルートテーブルにはそこを指すプッシュされた宛先が表示されるはずです。インターフェースがなければクライアントはそれを作る段階に到達しておらず、存在してもどのルートも使っていないなら、見るべきは push かルートオプションです。
切断状態も契約の一部です
次に、重要な宛先を、トンネルを使うべき名前空間から、ping だけでなくアプリケーションで試します。名前解決は別の失敗であり、明示的に試す価値があります。プッシュされた DNS アドレスはコンテナのリゾルバを自動では更新せず、push を無視する独立したクライアントはイメージが設定したものを通じて名前を解決します。
ワークロードが通常の経路を決して使ってはならないなら、ファイアウォール規則と名前空間内の準備完了シグナルで明示的に設計します。キルスイッチは自分で書き、テストするポリシーであり、コンテナでクライアントを動かすことで受け継ぐ性質ではありません。
再接続の挙動には独自の確認が必要で、再起動ポリシーはプロセスが再起動することを証明するだけでその間に通信がどうなるかを語らないため、トンネルを止めて意図した宛先を試し、接続が拒否されるのか、ハングするのか、コンテナの通常のルートを通って静かに成功するのかを観察する必要があり、重要なのは最後の結果であって、健全なログでは見えません。転送、変換、ファイアウォールの規則は外側の経路の一部であり、公開ポートは VPN インターフェースではありません。