Dockerコンテナでポートをpublishしても外部公開しない方法

2023年2月15日

Dockerコンテナ内でバインドしたポートへのアクセスを限定したい。

コンテナ内のポートへのアクセス

アクセスの種類としては多々ある。

アクセス不可

何もしなければ、コンテナの外部からはアクセスできない。他のコンテナからも、そのホスト内の他プログラムからさえもアクセスできない。

※完全にコンテナ内で閉じているので、例えば、ホストで80番が使われていようが、他コンテナで80番を使おうが、同じく80番を使うことができる。競合することが無いからである。

他コンテナからのアクセス

他コンテナとの間に共通ネットワークを築いていれば、コンテナ名をホスト名とみたてて、そのホスト名の目的のポートには簡単にアクセスできる。例えば、共通ネットワーク上にmysqlのコンテナがあった場合、特に何もしなくてもデータベースポート3306にアクセスできる。

※この場合もまた、コンテナで自由に80番を使うことができる。共通ネットワーク上でアクセス可能とは言っても、「ホスト」が別だからだ。

外部公開1:インターフェースを指定する

対象コンテナのポートを、インターフェースを指定して公開するには、docker-composeでは、例えば以下のように書く。

192.168.12.34:80:80

この場合、192.168.12.34というNICにポートがバインドされた状態になる。このとき、Dockerは勝手にiptablesを操作して、このインターフェースを通じてホストの外部にも公開してしまう。

つまり、以前はそのコンテナ、あるいは他コンテナからのアクセスのみだったのだが、今回はいきなり、ホスト全体とこのNICを通した外部にも公開されてしまう。「コンテナとホスト内のみ」という選択肢は無い。

※また、この場合、少なくとも192.168.12.34というインターフェースに関しては、80番が競合しうる。

外部公開2:インターフェースを指定しない

以下のようにすると、IPアドレスは0.0.0.0が指定されたものとして扱われる。

8080:8080

この場合、ホスト内全体からアクセスできるが、なおかつこのホストのすべてのNICにおいて外部公開される。Dockerは勝手にそのような操作をiptablesに施す。

問題

ここで問題になった点としては、以下である。

Tailscaleを使用すると、ホスト内に仮想的なNICが作られ、そこに100.64.0.3といったIPがふられるのだが、このNICを通してのみ、特定のDockerコンテナの特定のポートにアクセスしたいのである。つまり、この特定コンテナの特定ポートは、Tailscaleの他のクライアントからのみアクセスできるようにしたい。

このポートを広く外部公開するのではなく、Tailnet(TailscaleによるVPNネットワーク)の中のマシンからのみアクセスできるようにしたいのである。

この意味としては、例えば、Traefikのダッシュボードや、Portainer、ついでにDockerコンテナではないがcockpitなど、外部からは決してアクセスしてもらいたくないようなウェブサービスを「内部」だけで使いたいのである。なおかつ、「内部」からしかアクセスできないとなれば、強固なパスワードなどを使う必要もない。パスワード無しでも構わない。

解決策1

最も簡単な解決策としては、このホストのTailscale仮想NICのIPを指定することである。これが例えば、100.64.0.3であれば以下のようにする。

100.64.0.3:8080:8080

しかし、これには問題がある。

  • このIPを変更したい場合には、IPを記述しているすべてのdocker-compose.ymlを修正しないといけない。
  • 何よりも、dockerが起動する以前にTailscaleが起動していないといけない。でないと、仮想NICが存在しない状態でこの指定がされるためエラーが発生する。

後者は、サービスの起動順序を指定することにより回避できそうだが、とりあえずこの二つの問題があった。

解決策2

解決策1を採用しないとすれば、以下のようになる。

  • コンテナ側では、「8080:8080」のようにポートを全公開する。
  • しかし、Dockerは、iptablesを勝手に操作し、このポートを全世界に公開してしまう。
  • 上を阻止して、Tailscaleの仮想NICからのみアクセスできるようにする。

この説明が以下にある。

https://docs.docker.com/network/iptables/

要するに、Dockernoによるiptables操作をや阻止するのはやめた方が無難。そうではなく、ユーザ用に用意されているDOCKER-USERというルールで外部から来る任意のパケットを妨害するよう設定し、Tailscale内部からのパケットのみを通過させるようにするということになる。

このあたりにも説明がある。

https://serverfault.com/questions/946010/what-are-proper-iptables-rules-for-docker-host

採用した方法

結局のところ、先の説明の通り、Dockerが用意しているDOCKER-USERというチェインを操作して不要なパケットを拒絶するようにした。

最も簡単な記述としては、以下である。ここでは、9000, 8080, 9090のポートについて、tailscaleによる仮想NIC(tailscale0)以外からのアクセスを拒絶する。

iptables -F DOCKER-USER
iptables -A DOCKER-USER ! -i tailscale0 -p tcp --dport 9000 -j REJECT
iptables -A DOCKER-USER ! -i tailscale0 -p tcp --dport 8080 -j REJECT
iptables -A DOCKER-USER ! -i tailscale0 -p tcp --dport 9090 -j REJECT

当然ながら、コンテナ側ではこれらの3つのポートについて全世界に大公開するように設定しているが、実際にはTailnet内のマシンからしかアクセスできない。例えば、9000を使うPortainerのdocker-compose.ymlは以下である。

version: '3'
services:
  portainer:
    image: portainer/portainer-ce:2.15.1
    container_name: portainer
    ports:
      - 9000:9000
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./data:/data
      - ./certs:/certs

なお、DOCKER-USERの初期値は以下になっているようだ。

iptables -F DOCKER-USER
iptables -A DOCKER-USER -j RETURN

これに加え、マシン起動時にこの設定をする仕組みが必要になる。iptables-saveだと余計なものまでセーブしてしまうからだ。

このあたりだろう