dockerとufwの設定が独立なせいで無駄にポートが開いてしまう件と、解決するためのdocker runオプションの記法について


はじめに

docker、便利ですよね。使ってますか?わたしは今まで、dockerを使っていくつかのサーバを構築してきています。mastodonwordpress、他、仕事に使うgitbucketやらredmineやらのwebアプリを始めとしたあれこれ。立ち上げるのも潰すのもデータバックアップからサーバ移行も、設定次第でらくらくです。

ufw、便利ですよね。使ってますか?外部公開するサーバで必要なファイアウォールの設定、ちまちまとiptablesをいじるのは辛すぎですよね。http, https, sshだけ受け止められればあとは全部よしなにdenyしてほしい。そんな思いをufwは簡単に叶えてくれます。(こんな感じ)

この記事では、そんな便利なdockerとufwの設定が完全に独立だったせいで発生した、セキュリティ的に嫌な事象の紹介と、その解決策を紹介します。

ヤバい設定条件

まず、ufwでのiptables設定はこの記事で書いた通り、http(80)/https(443)/ssh(22)以外のすべてのincoming packetを遮断しているとします。さらにdockerコンテナを用いて、Webサービスであれば、

Internet <-(80/443)-> リバースプロキシdockerコンテナ <-(webアプリごとのポート)-> Webアプリのコンテナ

という形で構築しています。このときwebアプリのコンテナでは、リバースプロキシのコンテナとコンテナ間通信をするために、

$ docker run -p (webアプリごとのポート):(webアプリごとのポート) webアプリのコンテナ

のような形で起動しているとしましょう。docker-composeで動作させていれば、

docker-compose.yml
ports:
  - "8080:8080"

のオプションを入れています (version 2の場合)。おそらくdockerコンテナ間の連携ではデファクトスタンダードな書き方でしょう。mastodonのdocker-compose.ymlでもNov. 5, 2017現在でwebおよびsidekiqのコンテナの起動条件がそのように記載されていますね。Qiitaを見ていても、そのようなオプション設定をする記事が多く見られます。先に言っておくと、コンテナ間通信に-pオプションを使うのはセキュリティの観点から大間違いです。

ufwで閉じた俺のポートが外部からアクセスされちゃう件

さて、一旦ufwでssh以外の全てのポートを閉じた後、iptablesを再起動した後に当該のコンテナにインターネット側からアクセスしてみましょう。ufwの再設定前と変わらずアクセスが可能です。 しかし、ufwのステータスを確認すると、ssh以外のポートを全て遮断しています。

$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22                         ALLOW IN    Anywhere            
22 (v6)                    ALLOW IN    Anywhere (v6)  

このような不可解な事象が発生する理由は、以下の2点によります。
- ufwは、自身の管理外でiptablesの変更が行われてもそれに関与することはありません。ufw以外に手作業でiptablesの設定を行ってもそれはufwのステータスには反映されません。
- dockerの-pオプションは問答無用でホストのiptablesを変更してポートを開放する。 (デフォルトでは)

前者については、もうそういうものだと思うしかありません。それを含めてufwを便利に利用するポリシを最後の方に述べます。後者については、iptablesを確認すると、次のようにChainが設定されており、たしかにdockerによってiptablesが変更されていました。

$ sudo iptables -L
(中略)
Chain DOCKER (6 references)
target     prot opt source               destination         
ACCEPT     tcp  --  anywhere             172.18.0.2           tcp dpt:https
ACCEPT     tcp  --  anywhere             172.18.0.2           tcp dpt:http
(後略)

これは、おそらく気づかなかった方がかなりいることでしょう。私も気づかなければ延々コンテナのポートを不用意にインターネット向けに開放し続けていたでしょう。

docker runの-p--expose=の違い

まずはdocker run referenceを見てみましょう。

--expose=[]: Expose a port or a range of ports inside the container.
             These are additional to those exposed by the `EXPOSE` instruction
-p=[]      : Publish a container᾿s port or a range of ports to the host
             format: ip:hostPort:containerPort | ip::containerPort | hostPort:containerPort | containerPort

…はい、この説明では多分動作を予測するのは厳しいと思います。ここで説明を加えましょう。

-p, --publish の動作について

-p hostPort:containerPortとすると、ホスト側のhostPort番号を当該コンテナのcontainerPortにポートフォワード設定をします。例えば、インターネット側からホストのhostPort宛の通信が来た場合、コンテナのcontainerPort宛に転送される、という極めて一般的なポートフォワードです。ただし、このとき dockerはiptablesを変更しホストのhostPortを外向けに開放します。

--expose の動作について

--expose=containerPortとオプション指定すると、当該コンテナのcontainerPortが開放されます。これはコンテナ間通信のみに利用されます。例えば当該コンテナをリンクしたコンテナから、container_name:containerPortという宛先で通信することが可能です。このときホスト側のiptablesは変更されません。

さて、これらを踏まえるとコンテナ間通信を行うときに-p hostPort:containerPortオプションを利用しているという意味はどうなるのでしょうか。簡単にいえば、コンテナ間の通信において、一度ホストの外側のネットワークを介して通信しているという意味になります。無意味です。

ちなみに、-p--exporseは、docker-composeではportsexposeにそれぞれ対応しています。

ufw管理下におけるdocker runのオプション設定の推奨ポリシ

さて、上記を踏まえてufwの設定ポリシ、docker runのオプション設定ポリシとして以下を推奨します。

  • ufwでは、ホストでネイティブで動作しているサービス(sshdなど)に対してのみポート開放。他の全ポートを閉鎖
  • dockerコンテナ間通信向けには、--exposeオプションでコンテナのポートを開放。
  • ホスト外からの通信を受け入れる部分のみ、-pでポートフォワード。

ホスト固有の設定はufwで設定し、dockerでは公開するポートを適切に制御しつつdocker自身の機能で動的なポートフォワードを設定する、というポリシです。

例えば、ホストでネイティブに動作するsshdのポート(22)についてはufwで開けておくべきです。

一方で、80/443番ポートをリバースプロキシの80/443番ポートへ転送する設定などは、リバースプロキシコンテナのdocker runの-pオプションで行うべきでしょう。

加えて、リバースプロキシとバックのwebアプリコンテナとの通信用には、webアプリコンテナのdocker runの--exoposeオプションでポート指定を行うべきです。

このポリシに従ってオプション指定を行うことで、無用なポート公開を防ぎ、必要なときに必要なポートのみを公開することが可能です。

まとめ

簡単に言うと、既存のdocker runの-p hostPort:containerPortオプションでコンテナ間通信にしか使わないものがあれば、それを全部--expose=containerPortに変えておきましょう、ということです。

[おまけ] dockerデーモンの設定で--iptables=falseとするのは悪手だった

dockerデーモンの起動オプションに--iptables=falseとすることで、iptablesにdocker chainを追加することを防止できます。これにより-pオプションをつけてdocker runしていてもそのポートはホスト外部に公開されません。

しかし、前提条件に示したように80/443の通信をnginxのリバースプロキシを通して行っているような場合、httpレイヤでx-forwarded-forが正しく変更されなくなり、バックエンドのコンテナで接続元のアドレスが判別できなくなります。これにより、例えばアドレスによるアクセス制限などが正常に動作しなくなります。幾つかの記事では--iptables=falseによる解決方法を提示していますが、上記の理由により悪手だと言わざるを得ません。ホスト・コンテナ間のポートフォワードとコンテナ間通信の違いを理解した上で、それに合わせてdockerコンテナの起動オプションを適切に設定すべきです。