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
$ 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
$ 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 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.
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(英語)だと次のようなエラー画面が表示されます。
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
にデフォルトで入っている設定です。
$ 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>
で確認することができます。
$ 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
$ docker inspect http_server --format '{{json .NetworkSettings.Ports }}'
{"8000/tcp":[{"HostIp":"0.0.0.0","HostPort":"8000"}]}
このdockerの設定がlinuxの世界で反映されるのがiptables
で、次のように確認できます。
$ 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していません。
$ 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点を実施。
- アプリケーションサーバを0.0.0.0でLISTENするよう変更する
- ポートフォワードの設定を
-p localhost:8000:8000
などに変更して外部からのアクセスを遮断する