docker上のアプリにlocalhostでアクセスしたらERR_EMPTY_RESPONSEが出る

# tl;dr

- (問題) webアプリをdockerで立ててアクセスしたら`ERR_EMPTY_RESPONSE`エラーになった
- (原因) containerの外からリクエストが来るのにアプリがlocalhostでLISTENしている
- (解決) アプリの設定を0.0.0.0でLISTENするよう変更する

# 概要

この記事の対象読者
「Webアプリ開発でローカルホストマシン(mac or windows or linux)にdockerをインストールしてアプリをcontainerで動かしてみたが、ブラウザから確認すると`ERR_EMPTY_RESPONSE`(またはcurlで`curl: (52) Empty reply from server`や`curl: (56) Recv failure: Connection reset by peer`)と表示される。ポートマッピングは確かに設定している。」人
または、dockerのネットワークをあまり良く知らない人

# 環境

containerはデフォルトで作成される`bridge`ネットワークに接続する想定です。
途中までMacで挙動見ていたのですが、dockerがlinuxの方が素直なので途中でUbuntuを見ています。Ubuntuのコマンド結果の場合は明記します。

## mac
```
$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.5
BuildVersion:   18F132
```

```
$ docker version
Client: Docker Engine - Community
 Version:           18.09.1
 API version:       1.39
 Go version:        go1.10.6
 Git commit:        4c52b90
 Built:             Wed Jan  9 19:33:12 2019
 OS/Arch:           darwin/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          18.09.1
  API version:      1.39 (minimum version 1.12)
  Go version:       go1.10.6
  Git commit:       4c52b90
  Built:            Wed Jan  9 19:41:49 2019
  OS/Arch:          linux/amd64
  Experimental:     false
```

## Ubuntu

```console:Ubuntu
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="16.04.2 LTS (Xenial Xerus)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 16.04.2 LTS"
VERSION_ID="16.04"
HOME_URL="http://www.ubuntu.com/"
SUPPORT_URL="http://help.ubuntu.com/"
BUG_REPORT_URL="http://bugs.launchpad.net/ubuntu/"
VERSION_CODENAME=xenial
UBUNTU_CODENAME=xenial
```

```console:Ubuntu
$ docker version
Client:
 Version:      17.03.0-ce
 API version:  1.26
 Go version:   go1.7.5
 Git commit:   3a232c8
 Built:        Tue Feb 28 08:01:32 2017
 OS/Arch:      linux/amd64

Server:
 Version:      17.03.0-ce
 API version:  1.26 (minimum version 1.12)
 Go version:   go1.7.5
 Git commit:   3a232c8
 Built:        Tue Feb 28 08:01:32 2017
 OS/Arch:      linux/amd64
 Experimental: false
```

# 問題

**containerの中でlocalhostでLISTENするサーバ**を建てた場合にこの問題がおきます。

## 問題の再現(python http.server編)

手っ取り早い再現方法はpythonのhttp.serverモジュールを利用することです。

```console
$ docker run -d --name http_server -p 8000:8000 python:3.7 python -m http.server -b localhost
124239d068d43bc845665c08cd9654e6e673f4779c90e75718b6fe6d7bf91c72
$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                    NAMES
124239d068d4        python:3.7          "python -m http.serv…"   28 minutes ago      Up 28 minutes       0.0.0.0:8000->8000/tcp   http_server
```

### コマンドの説明

念の為上記コマンドの説明をします。コマンド見てわかる方は読み飛ばしてください。

`docker run` で`python:3.7`イメージを実行しています。
`-d`オプションと`--name http_server`は利便性のためにつけていますが必要ではないです。

`-p 8000:8000` これは重要で、docker hostマシンの8000ポートへのアクセスを`http_server`コンテナの8000ポートへマッピングしています。ドキュメントで述べられているように、これを設定しないとdockerの外の世界からアクセスできません。

> By default, when you create a container, it does not publish any of its ports to the outside world. To make a port available to services outside of Docker, or to Docker containers which are not connected to the container’s network, use the --publish or -p flag.

https://docs.docker.com/config/containers/container-networking/

`python -m http.server -b localhost` 部分がcontainer内で実行するpythonコマンドです。
python３には簡易にhttp serverを建てられるhttp.serverというモジュールがあり(python2時代にはSimpleHTTPServerだったもの)、`python -m http.server`コマンド一発で静的httpサーバが建ちます。
`-b`(または`--bind`)オプションでサーバのIPアドレスとポートを指定することができます。今回の場合はlocalhost(127.0.0.1と解釈される)とデフォルトの8000ポートです。

### 確認

ブラウザから`http://localhost:8000/`にアクセスしてみましょう。
Chrome(英語)だと次のようなエラー画面が表示されます。

`ERR_EMPTY_RESPONSE`
<img width="352" alt="スクリーンショット 2019-08-31 2.55.09.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266939/733a1822-a0fa-0aca-089c-fd058b60385a.png">

firefox(日本語)だと可愛らしい恐竜(?)とともに`ページ読み込みエラー`が表示されます。

<img width="817" alt="スクリーンショット 2019-08-31 2.56.35.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266939/de34f127-ba60-fffa-ba49-3817cd4e02e3.png">


念の為、ホストマシンからcurlで確認してみましょう。
`Empty reply from server`エラーです。

```
$ curl 'http://localhost:8000/'
curl: (52) Empty reply from server
```

docker containerの中に入ってserverが起動しているかどうか確認してみます。

```
$ docker exec -it http_server bash
root@124239d068d4:/# curl 'http://localhost:8000/'
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Directory listing for /</title>
</head>
<body>
<h1>Directory listing for /</h1>
(省略)
```

containerの中からだと、`http://localhost:8000/`で正常にサーバに接続できていることがわかります。

# 原因

container中のサーバがlocalhostでlistenしていると、ホストマシンからアクセスした際にエラーが起きることを確認しました。
この原因はホストマシンのlocalhostとcontainerのlocalhostが異なることが原因です。
(何言ってるんだ当たり前じゃないかと思う方は読者対象外です)

先のhttp.serverを建てた状態でホストマシンから`http://localhost:8000/`へアクセスする場合を例に詳しく見ていきましょう。

## (名前解決) localhost -> 127.0.0.1

まずはlocalhostは127.0.0.1に解決されます。
dockerは関係なく、`/etc/hosts`にデフォルトで入っている設定です。

```console:Ubuntu
$ cat /etc/hosts | grep 'localhost'
127.0.0.1       localhost
```

## (Port Forwarding) 127.0.0.1:8000 -> 172.17.0.9:8000

宛先が127.0.0.1:8000に解決されました。
次はPort Forwardingです。
`-p 8000:8000` と設定したのですが、「ホストコンピュータのすべてのインターフェースの8000ポート宛通信をcontainerの8000ポートに転送する」と解釈されます。

dockerの設定は`docker ps`のPORT列や`docker inspect <container_name>` で確認することができます。

```console:Ubuntu
$ docker ps -l
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                    NAMES
1fde918b24df        python:3.7          "python -m http.se..."   33 minutes ago      Up 33 minutes       0.0.0.0:8000->8000/tcp   http_server
```

```console:Ubuntu
$ docker inspect http_server --format '{{json .NetworkSettings.Ports }}'
{"8000/tcp":[{"HostIp":"0.0.0.0","HostPort":"8000"}]}
```

このdockerの設定がlinuxの世界で反映されるのが`iptables`で、次のように確認できます。

```console:Ubuntu
$ sudo iptables --list -v -t nat
(省略)
Chain DOCKER (2 references)
 pkts bytes target     prot opt in     out     source               destination
    1    40 DNAT       tcp  --  !docker0 any     anywhere             anywhere             tcp dpt:8000 to:172.17.0.9:8000
```

device `docker0`以外のすべての送信元からの8000ポート宛の通信を`172.17.0.9:8000`にDNATしています。(`172.17.0.9`はcontainerのbridgeネットワークのインターフェースに自動で与えられたIPアドレス)

## 172.17.0.9:8000 -x-> 127.0.0.1:8000

ここでようやくホストマシンからのリクエストがcontainerのIPアドレス `172.17.0.9`の8000ポートに届きました。
一方で、アプリケーションは `127.0.0.1:8000` でlistenしていますが、 **`172.17.0.9:8000`ではListenしていません。**

```console:Ubuntu
$ docker exec -it http_server bash
root@1fde918b24df:/# ss -lt
State      Recv-Q Send-Q                                                 Local Address:Port                                                                  Peer Address:Port
LISTEN     0      5                                                          127.0.0.1:8000                                                                             *:*
```

そのため、Connection Refuseされます。悲しい。
これが原因です。

## 原因まとめ

ホストマシンとcontainerはnamespaceで区切られていて別のマシンと捉えて構わないため、containerのloopback interfaceにはホストマシンからはアクセスできない。

# 解決策

[この記事](https://qiita.com/amuyikam/items/0063df223aed40193ba9)で少し触れているように、`0.0.0.0`でサーバを建てると、そのホスト(今回の場合container)が持つ全てのインターフェースでLISTENします。
一般的には、localhost(または127.0.0.1)ではなく`０．０．０．０`でサーバーを建てると良いでしょう。
サーバーのデフォルトIPアドレスやIPアドレス指定方法はフレームワークによって異なるため、「<フレームワーク名> host」とか「<フレームワーク名> bind」で調べてください。

## 注意

多くのWebアプリケーションサーバが(特に開発モードで)`0.0.0.0`ではなく`127.0.0.1`でLISTENするのには訳があります。

> If you run the server you will notice that the server is only accessible from your own computer, not from any other in the network. This is the default because in debugging mode a user of the application can execute arbitrary Python code on your computer.

https://flask.palletsprojects.com/en/1.1.x/quickstart/#public-server

> Running Rack apps on 0.0.0.0 in development mode will allow malicious
  users on the local network (ex: a Coffee Shop or a Conference) to abuse
  or potentially exploit the app. Safer to default host to localhost when in
  development mode.

https://github.com/rack/rack/commit/28b014484a8ac0bbb388e7eaeeef159598ec64fc

同じネットワーク内にいる他人、例えば公衆WiFiにつないで開発しているときに同じWiFiにつないだ人に開発中のアプリケーションが見られてしまうのみならず、debugモードの場合任意のコードを実行されてしまう危険があると記載しています。そのため、通常はホストマシンからのみ接続できるよう`127.0.0.1`のインターフェースでしかアクセスを受け付けません。

今回のようにcontainerのサーバを`0.0.0.0`でLISTENするよう設定した場合にもホストマシンからcontainerにanyでポートフォワードされて来るのでこれが当てはまります。
ホストマシン以外から接続しないことがわかっている場合、対策のため次のように`-p`オプションを変更すると安全でしょう。
`-p localhost:8000:8000`

## 言語ごとの対応

### Flask

Flaskの開発用サーバは `127.0.0.1:5000` でLISTENします。
変更は

```console
$ flask run --host=0.0.0.0
```

またはコードの中で

```python
app.run(host='0.0.0.0')
```

https://flask.palletsprojects.com/en/1.1.x/api/#flask.Flask.run
https://flask.palletsprojects.com/en/1.1.x/quickstart/#public-server

### Ruby on Rails

Railsも開発用サーバは`localhost:3000`です。

次のように起動

```console
$ rails server -b 0.0.0.0
```

または`config/boot.rb`に記載するようです

https://guides.rubyonrails.org/4_2_release_notes.html#default-host-for-rails-server

### Nuxt

nuxtの開発用サーバもデフォルトではlocalhostでLISTENします。

```console
$ nuxt --hostname 0.0.0.0
```

または環境変数やpackage.jsonで設定できるそうです。
https://nuxtjs.org/faq/host-port/

## 解決策のまとめ

以下の2点を実施。
1. アプリケーションサーバを0.0.0.0でLISTENするよう変更する
2. ポートフォワードの設定を `-p localhost:8000:8000` などに変更して外部からのアクセスを遮断する