Edited at

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.


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

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

スクリーンショット 2019-08-31 2.55.09.png

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

スクリーンショット 2019-08-31 2.56.35.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にデフォルトで入っている設定です。


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.


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します。

変更は

$ 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に記載するようです

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


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