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 servercurl: (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

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
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モジュールを利用することです。

$ 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 runpython: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.

python -m http.server -b localhost 部分がcontainer内で実行するpythonコマンドです。
python3には簡易に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

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

念の為、ホストマシンから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にデフォルトで入っている設定です。

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> で確認することができます。

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
Ubuntu
$ docker inspect http_server --format '{{json .NetworkSettings.Ports }}'
{"8000/tcp":[{"HostIp":"0.0.0.0","HostPort":"8000"}]}

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

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していません。

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にはホストマシンからはアクセスできない。

解決策

この記事で少し触れているように、0.0.0.0でサーバを建てると、そのホスト(今回の場合container)が持つ全てのインターフェースでLISTENします。
一般的には、localhost(または127.0.0.1)ではなく0.0.0.0でサーバーを建てると良いでしょう。
サーバーのデフォルト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.

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.

同じネットワーク内にいる他人、例えば公衆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します。
変更は

$ flask run --host=0.0.0.0

またはコードの中で

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です。

次のように起動

$ rails server -b 0.0.0.0

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

Nuxt

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

$ 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 などに変更して外部からのアクセスを遮断する