4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

-pオプションと決別する

Last updated at Posted at 2020-06-06

概要

この記事では読者諸賢に以下の情報を提供できるかもしれません。

  • 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のアーキテクチャはざっくりと以下のような感じです。
chromebook0.png

①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.2172.17.0.3、...とIPアドレスが割り振らていくようです。

以上、このような環境を前提として検討を進めて参ります。

-pオプション設定時の挙動

という訳で、まずはちゃんと-pオプションを設定した場合の挙動を確認してみましょう。
図にするとこんな感じです。
(見づらい…。8000とか8080の赤い四角はcrostiniのポートだと思ってください…。)

chromebook1.png

この場合、container1へはpenguin.linux.test:8000で、container2へはpenguin.linux.test:8080でアクセスできることになります。

対処法① socatでプロキシ

-pオプションつけ忘れたって時に、手っ取り早く上のような構成を実現するにはsocat一択ではないでしょうか。
socatとは、(以下google翻訳)

Socatは、2つの双方向バイトストリームを確立し、それらの間でデータを転送するコマンドラインベースのユーティリティです。
socat(1):多目的リレー-Linux manページ

という、あらかた何でもプロキシできるやつです。
例によって図示するとこんな感じ。

chromebook2.png

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が楽しくて…)

chromebook3.png

  1. NGINXはcrostini内で80番ポートをリッスン
  2. 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.testX.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

さて、ブラウザから先程走らせたコンテナにアクセスして新しいノートブックを作成しようとすると、こんなエラーが表示されます。
Screenshot 2020-05-27 at 22.37.50.png

ターミナルでは以下。

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リクエストを確認すると、HostOriginは一致しているので、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;
        }
 }

Module ngx_http_core_module

これで新規作成ができるようになりました。

connection failed

それではさっそく新しいノートブック開いてみましょう!
と開いたはいいものの、いっこうにkernelに繋がらず、しばらくするとこんな通知が表示されます。
Screenshot 2020-05-25 at 23.42.51.png

再度DevToolsでネットワークを確認すると、WebSocketのハンドシェイクで失敗してる様子。
Screenshot 2020-05-30 at 16.53.09.png

という訳で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;
        }
 }

参照

これでJupyterの基本的な機能は使えるようになりました。
あとは使っているうちに、X-Forwarded-Forなどリバースプロキシで定番のヘッダーが実は必要だったなんてことがあるかもしれませんが、そん時はそん時対応することにします。

IPアドレスとURLを固定化する

ここまでで当初思い描いた環境は整いました!🎉
しかし人間とは欲深いもので、さらなる便利さを求めてしまうのです…。
という訳で、anacondaコンテナを永続化することを想定して固定のIPとURLでアクセスできるようにしてみます。

[docker] Static IPのアサイン

前提条件で記載した以下のような挙動は、

私の環境ではコンテナを走らせた順に、172.17.0.2172.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サーバの設定

ずばり
設定のここ↓の
無題の図形描画.png

↓ここの、
無題の図形描画 (1).png

↓ここです!
無題の図形描画 (2).png

詳細: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(バーチャルマシン)が挟まっているのです。
chromebook5.png

図の①:ホストの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内でcurlwgetしても普通に通るし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は、ホストで設定を変更したりネットワークを切り替えたりすると動的に更新されます。

この仮説を検証するため、次のような実験をしてみました。

  1. 【準備】DNSMASQの設定で、listen-addressinterfaceを削除し、 local subnetsのみで応答するようにする。
  2. 【準備】ホストでのDNS設定に、100.115.92.193を追加する。
  3. 【確認済み】ホストからcrostini(100.115.92.202)に直接問い合わせた場合は、ネットワークが異なるため応答がない。
  4. 確認したい仮説】ホストからVM(100.115.92.193)に問い合わせた場合、VMではホストのDNS設定が反映されているので、100.115.92.202に問い合わせを投げる。100.115.92.193100.115.92.202は同一ネットワークなので、DNSMASQは応答を返す。

はい、この実験結果は仮説通りの挙動となりました。
ちょっとうまくまとまってないので読んでる方は🤔顔になるかも知れませんが…。chromebookユーザーは是非試してみてください。

さて話を戻して、この実験結果を踏まえれば結局listen-addressinterfaceはいらないじゃん!となってしまいますが、さすがにここまで込み入った設定だと混乱してしまうので、ホストでのDNS設定は最初に戻しておきます。

そしてその場合にはやっぱりネットワーク外からの問い合わせを受け入れるということで、listen-addressinterfaceの設定が必要です。
どちらも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アプリを作ってみました。意外に役立ちました。
  1. 開発者モードにすれば編集はできますが、再起動で変更内容はもとに戻ってしまいます。

  2. 公式イメージの時点で実行ユーザがanacondaになっている。

  3. -u rootオプションを付けてrunすればいけた気がしますが、公式イメージのポリシーに反している気がして…。加えて、以前alpineでapk addしてもリポジトリが見つからないみたいなエラーに遭遇してうんざりしたこともあり、普通にdebian slimとかでいいかなぁと最近思ってます。

  4. Dockerfileもだいぶすっきりですね!alpineイメージ版と統一感がないのは気にしないことにします😇

  5. dockerhubのUsageでは'*'GitHubのUsageでは'0.0.0.0'になってる。記述中のPythonのバージョン的にGitHubの方が新しいので後者推奨?
     
    --NotebookApp.token='' \ 及び
    --NotebookApp.password='' \: jupyter notebookのオプション。アクセス時にログインを無効化。

  6. dnsmasq は、軽量のDNS、TFTP、PXE、ルーター通知、およびDHCPサーバーです。DNSMASQのmanページ

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?