概要
この記事では読者諸賢に以下の情報を提供できるかもしれません。
-
docker run
時に-p
オプションを付けなくても良い開発環境を提案する。 - chromebookで
hosts
の編集と同様の効果を実現できる。 - chromebook内のネットワーク階層について理解が深まる。
- JupyterをNGINXでプロキシする際に必要な設定について概略を掴める。
はじめに
この記事は
「コンテナrunしたけどポートフォワーディングしてなかった!」というときの対応と改善策をchromebookで検討しました。
だいぶニッチな需要&-p
オプション付ければ済む話なのですが、何かのお役に立てば幸いです。
動機
「とりあえず動かしてみるか」ぐらいの軽い気持ちでコンテナを立ち上げ、内部でごちゃごちゃいじった後に「-p
オプションつけ忘れた…」ってことが何度がありました。
で、その場しのぎで対処できるんですが、なんか毎回無駄なことしてるなぁと。
あと各種サービスの開発用のポートって8000
とか8080
とか8787
とか8888
とか同じようなのが多くないですか?
これも変更すればいい話なのですが、複数コンテナで何も考えずに-p 8080:8080
とかしちゃうと競合で怒られたり、「今ローカルの8080はどのコンテナと繋がってるんだ…?」と混乱したりすることもあったため、この際ポリシーを決めよう!と試行錯誤した結果をまとめます。
方針
という訳で今回のゴールは、
-p
オプションつけなくてもさくっとコンテナの動作確認できるようにする!
です。
そのための手段が煩雑になってしまっては本末転倒なので、以下に挙げた方法以外の、とにかくお手軽に目的を達成できる方法を検討します。(結果としてお手軽ではなくなりましたが…)
-
iptables
を設定する記事( ここやここ)を見かけましたが、普段使わないコマンドを叩くのはちょっと敷居が高い…(スキル不足です、すみません…) -
コンテナをイメージ化して
-p
オプションつけてrunする、というのも煩雑すぎて却下
環境
- PC: Acer Chromebook 11 CB311-8H-C5DV
- 仮想環境: crostini(LXD)
- Debian GNU/Linux 9 (stretch)
- Docker: Docker version 19.03.8, build afacb8b7f0
前提条件
chromebookのアーキテクチャはざっくりと以下のような感じです。
①chromebook
今回の主役
②crostini
chrome OSにビルドインされているLXDベースの仮想環境(のプロジェクト名)。
正確にはdocker同様コンテナ型の仮想環境で、デフォルトではpenguinコンテナ一つが有効化されている状態だが詳細は割愛。(図の中で入れ子が多くなって見づらくなる…)
以下では便宜上、penguinコンテナのことをcrostiniと呼びます。
【参照:Chromium OS Docs - Running Custom Containers Under Chrome OS】
chromebookからは100.115.92.202
またはpenguin.linux.test
(またはpenguin.termina.linux.test
)でアクセスできます。
確認方法
LXDのコンテナ一覧
crosh> vmc start termina
(termina) chronos@localhost ~ $ lxc ls
+---------+---------+------------------------------+------+------------+-----------+
| NAME | STATE | IPV4 | IPV6 | TYPE | SNAPSHOTS |
+---------+---------+------------------------------+------+------------+-----------+
| penguin | RUNNING | 172.21.0.1 (br-a0c17ef02fe7) | | PERSISTENT | 1 |
| | | 172.19.0.1 (br-e5939d775b77) | | | |
| | | 172.18.0.1 (br-d7b930feccbc) | | | |
| | | 172.17.0.1 (docker0) | | | |
| | | 100.115.92.202 (eth0) | | | |
+---------+---------+------------------------------+------+------------+-----------+
(termina) chronos@localhost ~ $
chromebookのhosts(開発者モードのみ)
crosh> shell
chronos@localhost / $ tail /etc/hosts
# In case you want to be able to connect directly to the Internet (i.e. not
# behind a NAT, ADSL router, etc...), you need real official assigned
# numbers. Do not try to invent your own network numbers but instead get one
# from your network provider (if any) or from your regional registry (ARIN,
# APNIC, LACNIC, RIPE NCC, or AfriNIC.)
#
#####DYNAMIC-CROSDNS-ENTRIES#####
100.115.92.202 penguin.linux.test
100.115.92.202 penguin.termina.linux.test
chronos@localhost / $
③containers
dockerコンテナたち。
crostiniが導入された初期は起動に一手間必要でしたが、いつの間にか普通に使えるようになりましたね。
私の環境ではコンテナを走らせた順に、172.17.0.2
、172.17.0.3
、...とIPアドレスが割り振らていくようです。
以上、このような環境を前提として検討を進めて参ります。
-p
オプション設定時の挙動
という訳で、まずはちゃんと-p
オプションを設定した場合の挙動を確認してみましょう。
図にするとこんな感じです。
(見づらい…。8000とか8080の赤い四角はcrostiniのポートだと思ってください…。)
この場合、container1へはpenguin.linux.test:8000で、container2へはpenguin.linux.test:8080でアクセスできることになります。
対処法① socatでプロキシ
-p
オプションつけ忘れたって時に、手っ取り早く上のような構成を実現するにはsocat一択ではないでしょうか。
socatとは、(以下google翻訳)
Socatは、2つの双方向バイトストリームを確立し、それらの間でデータを転送するコマンドラインベースのユーティリティです。
socat(1):多目的リレー-Linux manページ
という、あらかた何でもプロキシできるやつです。
例によって図示するとこんな感じ。
crostini内で8000ポートと172.17.0.2:8000を繋ぐ
socat tcp-listen:8000,reuseaddr,fork \
tcp-connect:172.17.0.2:8000
対処法② サブドメインでコンテナ指定しNGINXで振り分ける
socatによる対処は非常にお手軽なんですが、これを2,3回やった頃に「面倒くせぇ!」となりました。
そして「そもそも-p
オプションつけなくてもよくしよう!」と思い立ちました。
ということでNGINXを使います。
例のごとく図示。(plantUMLが楽しくて…)
- NGINXはcrostini内で80番ポートをリッスン
- NGINXはX.penguin.linux.test(Xは2以上の数字)というリクエストを受けると、内部で172.17.0.Xのコンテナにフォワードする
NGINX設定
上の要件を実現する最小設定はこんな感じです。
ついでにX.testでも同じ挙動となるようにしました。
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name ~^(?<subdomain>.+)\.penguin\.linux\.test$
~^(?<subdomain>.+)\.test$;
location / {
proxy_pass http://172.17.0.$subdomain;
}
}
hosts設定
後はブラウザからX.penguin.linux.testにアクセスするときに全て100.115.92.202で名前解決するようにすればよいです。
windowsなどであれば直接hostsファイルをいじればよいでしょうが、chromebookではhostsを編集できません。1
仮にhostsを編集するとしても、X.penguin.linux.testとX.testを必要なだけずらずらと書くのはなんかダサい…
という訳で、DNSサーバをたてることにします。(後述)
websocketもプロキシする等
ここまでで最低限のやりたいことは実現できましたが、まだ不十分な箇所があります。
anaconda3コンテナでJupyterを起動する場合を例に(というかこれを使いたかったので)見ていきましょう。
以下コマンドでコンテナを走らせます。
docker run -it \
--name container_name \
-v /home/username/apps/anaconda/mnt:/opt/notebooks \
continuumio/anaconda3:2020.02 \
/opt/conda/bin/jupyter notebook \
--notebook-dir=/opt/notebooks \
--allow-root \
--ip='0.0.0.0' \
--port=80 \
--no-browser \
--NotebookApp.token='' \
--NotebookApp.password='' \
簡単に説明すると、
-v
:作成するファイルはホスト側で永続化したいので、適当にディレクトリを作成してマウントさせます。
--port=80
:jupyter notebook
のオプション。80番ポートを使用する。最初alpineイメージ(anaconda3:2020.02-alpine)で実行しようとしたらroot権限が必要だと怒られました。2
しかしそもそもsudo
コマンドが無く、apkでsudoをaddするためにはroot権限が必要…3
そもそもanacondaパケージの容量がすごすぎてベースイメージalpineで軽量化しても霞んで見えるので、使い慣れたdebianベースに変更しました。
こちらではデフォルトでrootなので問題なく80番ポート使えます。4
--allow-root
: jupyter notebook
のオプション。root権限で実行する際に必要。
--ip='0.0.0.0'
: jupyter notebook
のオプション。デフォルトではlocalhost
になっている。今回外部(コンテナの外)からアクセスするので、全てのIPを許可する。
'0.0.0.0'
でも'*'
でもいいっぽい。5
404 Not Found
さて、ブラウザから先程走らせたコンテナにアクセスして新しいノートブックを作成しようとすると、こんなエラーが表示されます。
ターミナルでは以下。
Blocking Cross Origin API request for /api/contents. Origin: http://3.test, Host: 172.17.0.3
どうやらオリジンとホストが違うからブロックするよ、ということのようです。
Host - HTTP | MDN
Origin - HTTP | MDN
という訳でchromeのDevToolsで404になったHTTPリクエストを確認すると、Host
とOrigin
は一致しているので、Nginxがプロキシする際にHost
ヘッダーを書き換えていることが分かります。
POST /api/contents HTTP/1.1
Host: 3.test
Connection: keep-alive
Content-Length: 19
Accept: application/json, text/javascript, */*; q=0.01
DNT: 1
X-Requested-With: XMLHttpRequest
X-XSRFToken: 2|1120b20b|51fbe92a334cbd816742728c7ff542b0|1590415173
User-Agent: Mozilla/5.0 (X11; CrOS x86_64 12871.102.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.141 Safari/537.36
Content-Type: application/json
Origin: http://3.test
Referer: http://3.test/tree?
Accept-Encoding: gzip, deflate
Accept-Language: ja,en-US;q=0.9,en;q=0.8,es;q=0.7
Cookie: _xsrf=2|1120b20b|51fbe92a334cbd816742728c7ff542b0|1590415173; username-3-test="2|1:0|10:1590417087|15:username-3-test|44:NGQ5MzU0YjhkNjlmNDY5YWEwMTExZDAwNmIyNTU2MDU=|b8b86f39cdc6962141a5e26c7462bd6ccd34142d822651ac4d0cd0250c3bb89a"; username-172-17-0-3="2|1:0|10:1590593650|19:username-172-17-0-3|44:NmM0NzJiYjQ3MzdhNDE1ODkxYjFjNzM0MjU1NjcxMmE=|81f00e786eb319318d06a51adbf25fbd88383033759cb4184161b31138c4ea12"
そこでHost
ヘッダーを保持させる設定を追加します。
@@ -7,5 +7,6 @@ server {
location / {
proxy_pass http://172.17.0.$subdomain;
+ proxy_set_header Host $host;
}
}
これで新規作成ができるようになりました。
connection failed
それではさっそく新しいノートブック開いてみましょう!
と開いたはいいものの、いっこうにkernelに繋がらず、しばらくするとこんな通知が表示されます。
再度DevToolsでネットワークを確認すると、WebSocketのハンドシェイクで失敗してる様子。
という訳でWebSocketの設定を追加します。
@@ -1,3 +1,7 @@
+map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+}
server {
listen 80 default_server;
listen [::]:80 default_server;
@@ -6,7 +10,11 @@ server {
~^(?<subdomain>.+)\.test$;
location / {
+ proxy_http_version 1.1;
proxy_pass http://172.17.0.$subdomain;
+
proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
}
}
参照
- NGINX 公式
- Qiita
-
NginxのリバースプロキシでWebソケットを通す際の設定 - Qiita
- 丁寧に解説してくれています。
-
NginxのリバースプロキシでWebソケットを通す際の設定 - Qiita
- Jupyter公式
これでJupyterの基本的な機能は使えるようになりました。
あとは使っているうちに、X-Forwarded-For
などリバースプロキシで定番のヘッダーが実は必要だったなんてことがあるかもしれませんが、そん時はそん時対応することにします。
IPアドレスとURLを固定化する
ここまでで当初思い描いた環境は整いました!🎉
しかし人間とは欲深いもので、さらなる便利さを求めてしまうのです…。
という訳で、anacondaコンテナを永続化することを想定して固定のIPとURLでアクセスできるようにしてみます。
[docker] Static IPのアサイン
前提条件で記載した以下のような挙動は、
私の環境ではコンテナを走らせた順に、
172.17.0.2
、172.17.0.3
、…とIPアドレスが割り振らていくようです。
ネットワークを指定せず、コンテナがデフォルトのbridgeネットワークに接続された場合です。
(参考:Use bridge networks | Docker Documentation)
そして固定のIPをアサインするには、コンテナをユーザ定義のネットワークに接続させる必要があるようです。
You can also choose the IP addresses for the container with
--ip
and--ip6
flags when you start the container on a user-defined network.
docker run | Docker Documentation
という訳でまずはネットワークの作成。
docker network create --subnet=172.18.0.0/24 mynet
そしてanacondaコンテナ起動時に--net
オプションと--ip
オプションを追加します。
docker run -it \
--name container_name \
-v /home/username/apps/anaconda/mnt:/opt/notebooks \
--net mynet --ip 172.18.0.254 \
continuumio/anaconda3:2020.02 \
/opt/conda/bin/jupyter notebook \
--notebook-dir=/opt/notebooks \
--allow-root \
--ip='0.0.0.0' \
--port=80 \
--no-browser \
--NotebookApp.token='' \
--NotebookApp.password='' \
[NGINX] バーチャルホスト追加
今回はとりあえずjupyter.testで上のコンテナにプロキシするようにしてみました。
@@ -3,6 +3,20 @@ map $http_upgrade $connection_upgrade {
'' close;
}
server {
+ listen 80;
+
+ server_name jupyter.test;
+
+ location / {
+ proxy_http_version 1.1;
+ proxy_pass http://172.18.0.254;
+
+ proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+}
+server {
listen 80 default_server;
listen [::]:80 default_server;
無事固定URLでJupyterにアクセスできました🎊
chromebookで簡易DNSサーバを立てる
最後にchromebookで名前解決をカスタマイズ(?)する方法をご紹介します。
変な表現になってしまいましたが、他のOSで言う"hosts
を編集する"という作業に等しいです。
余談ですが、こういう微妙に痒いところに、頑張れば手が届くというのがchromebookの面白さだと思います!
さて、先述の通りchromebookではhosts
を編集できません。
chromeブラウザの設定(chrome://about)をみても当たりなし…
拡張機能はどうかとそれっぽい名前のやつを2,3試しましたが、どれもURLでリダイレクトする実装になっていて今回の要件では使い物にならない…
それならいっそのことcrostiniでDNSサーバを建てたらいいのでは!?という訳で以下手順です。
問い合わせ先DNSサーバの設定
詳細:How to Change the DNS Server on a Chromebook
こんなところで設定できるとは…!正直感動しました。
ネットワーク毎の設定になりますが、普段使いのネットワークは限られますし、ネットワークの切り替えや再起動などでも設定は保持されるので、今の所不便はないです。
ここで、1つ目(優先DNS)にcrostiniのIPアドレス(100.115.92.202
)、2つ目(代替DNS)に「自動ネームサーバー」のラジオボタンで設定されていたWi-FiのIPアドレス(=デフォルトゲートウェイ:192.168.128.1
)を設定しています。
crostiniで建てるDNSサーバで解決できなかった場合には、これまでどおりに問い合わせることを意図しています。
DNSMASQ設定
次にcrostiniでネームサーバーを建てるのですが、hosts
の代替という限られた機能しか求めてないので軽量なソフトはないかと探したらDNSMASQを発見しました。
軽量という割には多機能で色々できるようです6が、今回は設定の簡単さという点で使ってみることにします。
crostiniはdebianなので普通にapt
でgetすると、/etc/dnsmasq.conf
ファイルが作られます。
これを編集していくのが王道なんでしょうが、インストール直後は恐らく設定可能な全ての項目がコメントアウトされた状態(こんな感じ)でうざいので、オリジナルは退避させ同名ファイルを新規作成します。
そして必要な設定だけを記述したのがこちら。
以下逐行解説。
listen-address=100.115.92.202
# interface=eth0
log-queries
log-facility=/var/log/dnsmasq.log
port=53
no-resolv
address=/test/100.115.92.202
listen-address
or interface
この設定が最も重要です。
なぜなら、どちらも付けない状態で起動した場合、デフォルトではlocal-service
オプションを付けた場合と同じ挙動になり、ブラウザからの問い合わせに応答してくれません。
--local-service
Accept DNS queries only from hosts whose address is on a local subnet, ie a subnet for which an interface exists on the server. This option only has effect if there are no --interface, --except-interface, --listen-address or --auth-server options. It is intended to be set as a default on installation, to allow unconfigured installations to be useful but also safe from being used for DNS amplification attacks.
Man page of DNSMASQ
起動時のログでは以下のように出力されます。
DNS service limited to local subnets
Manの説明にあるlocal subnet
は多分同一ネットワークということでしょう。
実はブラウザを実行しているchromebookの本体(以下、ホスト)とcrositiniは、同一ネットワークに属していません。
以下のように、間にVM(バーチャルマシン)が挟まっているのです。
図の①:ホストのVMネットワーク
crosh> shell
chronos@localhost / $ ip addr show vmtap0
30: vmtap0: <BROADCAST,MULTICAST,ALLMULTI,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether 1a:9a:71:4f:7e:00 brd ff:ff:ff:ff:ff:ff
inet 100.115.92.25/30 brd 100.115.92.27 scope global vmtap0
valid_lft forever preferred_lft forever
inet6 fe80::189a:71ff:fe4f:7e00/64 scope link
valid_lft forever preferred_lft forever
図の②:VMのVMネットワーク
図の③:VMのLXDネットワーク
crosh> vmc start termina
(termina) chronos@localhost ~ $ ip addr show eth0
3: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether 22:4d:1b:2e:d7:de brd ff:ff:ff:ff:ff:ff
inet 100.115.92.26/30 brd 100.115.92.27 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::204d:1bff:fe2e:d7de/64 scope link
valid_lft forever preferred_lft forever
(termina) chronos@localhost ~ $ ip addr show lxdbr0
4: lxdbr0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 92:42:1b:cd:ed:0b brd ff:ff:ff:ff:ff:ff
inet 100.115.92.193/28 scope global lxdbr0
valid_lft forever preferred_lft forever
inet6 fe80::1c80:f1ff:fe84:6b28/64 scope link
valid_lft forever preferred_lft forever
で、ここからちょと奇妙な、というか自分でもまだ確証がない部分なんですが、crostiniではデフォルトでDNSの問い合わせ先がVMのLXDネットワークのIPアドレス(100.115.92.193
)になってるんですね。
$ cat /etc/resolv.conf
domain lxd
search lxd
nameserver 100.115.92.193
加えて、crostini内でのDockerコンテナも同様に、100.115.92.193
がデフォルトの問い合わせ先になっています。
crostini内でcurl
やwget
しても普通に通るしVMでネームサーバが動いているとは考えづらいので、VMが受信したDNSクエリはそのままホストで設定したDNSサーバに投げられているのだと思います。
実際、VMのresolv.conf
ではホストでのDNS設定が反映されています。
crosh> vmc start termina
(termina) chronos@localhost ~ $ cat /etc/resolv.conf
nameserver 100.115.92.202
nameserver 192.168.128.1
nameserver 0.0.0.0
options single-request timeout:1 attempts:5
このresolv.conf
は、ホストで設定を変更したりネットワークを切り替えたりすると動的に更新されます。
この仮説を検証するため、次のような実験をしてみました。
- 【準備】DNSMASQの設定で、
listen-address
やinterface
を削除し、 local subnetsのみで応答するようにする。 - 【準備】ホストでのDNS設定に、
100.115.92.193
を追加する。 - 【確認済み】ホストからcrostini(
100.115.92.202
)に直接問い合わせた場合は、ネットワークが異なるため応答がない。 - 【確認したい仮説】ホストからVM(
100.115.92.193
)に問い合わせた場合、VMではホストのDNS設定が反映されているので、100.115.92.202
に問い合わせを投げる。100.115.92.193
と100.115.92.202
は同一ネットワークなので、DNSMASQは応答を返す。
はい、この実験結果は仮説通りの挙動となりました。
ちょっとうまくまとまってないので読んでる方は🤔顔になるかも知れませんが…。chromebookユーザーは是非試してみてください。
さて話を戻して、この実験結果を踏まえれば結局listen-address
や interface
はいらないじゃん!となってしまいますが、さすがにここまで込み入った設定だと混乱してしまうので、ホストでのDNS設定は最初に戻しておきます。
そしてその場合にはやっぱりネットワーク外からの問い合わせを受け入れるということで、listen-address
や interface
の設定が必要です。
どちらもDNSMASQが問い合わせを受ける入り口の設定で、どちらか1つでも両方でも問題ありません。
今回はより明確になるようにlisten-address
のみ有効化しています。
log-queries
ログ出力する。
log-facility
ログの出力先。
port=53
DNSプロトコルは53番ポートを使うので設定。
DNSMASQもデフォルト53番を使うみたいですが一応明示しときます。
no-resolv
DNSMASQは応答を返せない場合、インストールされたマシンのresolv.conf
を読んで上位のサーバに問い合わせるようになっています。
今回は前述の通り"hosts
の編集"程度の機能のみ期待しているので、上位サーバへの問い合わせをさせないようにします。
address=/test/100.115.92.202
今回実現したかったこと。testドメインのURLは全て100.115.92.202
として名前解決します。
おまけ
今回の設定ファイル等githubで公開しているので、よろしければご活用ください。
- NGINX
- DNSMASQ
-
container_run.sh
- anacondaコンテナを走らせるだけのシェルスクリプトを作りました。
-
-s
オプションで固定IPかどうかを選択できます。 -
--name
オプションで任意のコンテナ名を設定できます。
-
flask app
- Dockerコンテナに届くHTTPのリクエストを確認したくて簡単なflaskアプリを作ってみました。意外に役立ちました。
-
開発者モードにすれば編集はできますが、再起動で変更内容はもとに戻ってしまいます。 ↩
-
-u root
オプションを付けてrunすればいけた気がしますが、公式イメージのポリシーに反している気がして…。加えて、以前alpineでapk addしてもリポジトリが見つからないみたいなエラーに遭遇してうんざりしたこともあり、普通にdebian slimとかでいいかなぁと最近思ってます。 ↩ -
Dockerfileもだいぶすっきりですね!alpineイメージ版と統一感がないのは気にしないことにします😇 ↩
-
dockerhubのUsageでは
'*'
、GitHubのUsageでは'0.0.0.0'
になってる。記述中のPythonのバージョン的にGitHubの方が新しいので後者推奨?
--NotebookApp.token='' \
及び
--NotebookApp.password='' \
:jupyter notebook
のオプション。アクセス時にログインを無効化。 ↩ -
dnsmasq は、軽量のDNS、TFTP、PXE、ルーター通知、およびDHCPサーバーです。DNSMASQのmanページ