さくらVPSでdocker環境 (2) 遠隔操作の落とし穴
さくらVPSサーバにCoreOSをインストールできたので、いよいよ docker を使ってコンテナを立てていく。
dockerをどこから操作するか
コンテナ上でアプリを動かすためには、以下のステップを経る。
- GitHubなどからアプリのソースコードとDockerfileを入手する (git clone)
- ソースコードからdockerイメージを作る (docker build)
- dockerイメージを実行する (docker run)
このうち、1と2のdockerイメージを作るところはDockerHubやQuay.ioなどのリポジトリサービスを使って自動化できる。とはいえ、自動化するためのDockerfileを作るまでは、何度もDockerfileを編集してbuildのサイクルを繰り返すことになる。
普通にやると、さくらVPSサーバにsshでログインし、ターミナル上でDockerfileを編集したりdockerコマンドを実行したりすることになる。でも、さくらVPSサーバにインストールしたCoreOSは軽量OSなので、ホスト側には最小限のパッケージしか入っていない。やっぱり慣れた環境から操作したいよね、ということでさくらVPSサーバ上のdockerを手元のMacから操作したくなる。幸い、dockerには遠隔から操作するためのリモートAPIが用意されているので、これを使えるようにする。ざっくり言うと、herokuコマンドを実行するようにdockerコマンドを実行したいということ。
落とし穴その1: 認証
ざっとググってみると、以下のような情報が見つかる。
- Customizing docker
- boot2dockerを使わない。「Remote API」でローカルからクラウドのDockerを実行 - さくらのナレッジ
- Docker Remote APIを使ってみる #apijp - nobusueの日記
まずはCoreOSの公式ドキュメントを参考に、 /etc/systemd/system/docker-tcp.socket
を作成する。新たにTCP 2375ポートでサービスを待ち受けるための設定となる。
[Unit]
Description=Docker Socket for the API
[Socket]
ListenStream=2375
BindIPv6Only=both
Service=docker.service
[Install]
WantedBy=sockets.target
これはdockerがポート2375を直接bindするのではなく、systemdのsocket activationという仕組みを使っている。xinetdのようなもので、systemdを経由してdockerを呼び出している。そのため、systemctlコマンドを使ってこの設定を有効にする。
# systemctl enable docker-tcp.socket
# systemctl stop docker
# systemctl start docker-tcp.socket
# systemctl start docker
さくらVPSサーバ上からtcp経由でdockerに接続すると、ちゃんと応答が返ってくる。
$ docker -H 127.0.0.1:2375 ps -q
c1c5882fd47b
f0aa9b77616e
8a7cfde533d5
次に手元のMac環境からさくらVPS上のdockerに接続すると、同じように応答が返ってくる。
$ docker -H 160.16.81.168:2375 ps -q
c1c5882fd47b
f0aa9b77616e
8a7cfde533d5
これでめでたしめでたし、とはならない。dockerコマンドが実行できるということは、任意のイメージを作ったり、そのイメージを動かしたりできるということ。つまり、root権限をフリーで与えていることと同じになる。僕以外の誰かが勝手にイメージを立てて、Bitcoinの採掘やサーバの踏み台に使われかねないということだ。
dockerデーモンを実行するときにも、ちゃんとTLSを使えと警告がでる。
# docker -d -H tcp://0.0.0.0:2376
INFO[0000] +job init_networkdriver()
INFO[0000] +job serveapi(tcp://0.0.0.0:2376)
INFO[0000] Listening for HTTP on tcp (0.0.0.0:2376)
INFO[0000] /!\ DON'T BIND ON ANY IP ADDRESS WITHOUT setting -tlsverify IF YOU DON'T KNOW WHAT YOU'RE DOING /!\
INFO[0000] -job init_networkdriver() = OK (0)
Docker command line - Docker Documentationにも、認証しないことの危険性について書かれている。
If you need to access the Docker daemon remotely, you need to enable the tcp Socket. Beware that the default setup provides un-encrypted and un-authenticated direct access to the Docker daemon - and should be secured either using the built in HTTPS encrypted socket, or by putting a secure web proxy in front of it.
ということで、ローカルの仮想化環境や、信頼できるネットワーク環境で動かすのでない限り、dockerを遠隔操作するときはTLSでクライアント認証は必須となる。ちなみに、boot2dockerはローカル環境にも関わらず、ちゃんとTLS認証を使うように設定されている。えらい。
$ boot2docker shellinit
Writing /Users/machu/.boot2docker/certs/boot2docker-vm/ca.pem
Writing /Users/machu/.boot2docker/certs/boot2docker-vm/cert.pem
Writing /Users/machu/.boot2docker/certs/boot2docker-vm/key.pem
export DOCKER_HOST=tcp://192.168.59.103:2376
export DOCKER_CERT_PATH=/Users/machu/.boot2docker/certs/boot2docker-vm
export DOCKER_TLS_VERIFY=1
落とし穴その2: systemdのsocket activation経由ではTLSが使えない
TLSクライアント認証の手順は、やっぱりdockerの公式ドキュメントが参考になる。
Running Docker with HTTPS - Docker Documentation
やっていることはシンプル。
- オレオレ認証局を作る
- サーバ証明書を発行する
- クライアント証明書を発行する
もちろん、サーバ証明書はサーバ側に、クライアント証明書はクライアント側に配置する。オレオレ認証局だけど、自分しか使わないので問題ない。
TLSを使う設定は、コマンドラインからだとこう。 --tldverify
などのオプションをつけている。
$ docker -d --tlsverify --tlscacert=ca.pem --tlscert=server-cert.pem --tlskey=server-key.pem \
-H=0.0.0.0:2376
CoreOSなので、まずは2376ポートを待ち受けるように先ほど作った docker-tcp.socket
ファイルを修正する。ListenStreamを2375から2376へ書き換えた。
[Unit]
Description=Docker Socket for the API
[Socket]
ListenStream=2376
BindIPv6Only=both
Service=docker.service
[Install]
WantedBy=sockets.target
docker起動時に --tlsverify
などをオプションで渡せるようにするために、 /etc/systemd/system/docker.service.d/tls.conf
ファイルを新たに作る。
[Service]
Environment='DOCKER_OPTS=--tlsverify --tlscacert=/home/core/tls/ca.pem --tlscert=/home/core/tls/server-cert.pem --tlskey=/home/core/tls/server-key.pem'
さっきと同じように設定ファイルの再読み込みとdockerサービスの再起動を実行。ちゃんとTLSオプション付きでdockerが実行されている。
$ ps aux | grep docker
root 29146 0.3 0.9 205164 18628 ? Ssl 00:16 0:00 docker --daemon --host=fd:// --tlsverify --tlscacert=/home/core/tls/ca.pem --tlscert=/home/core/tls/server-cert.pem --tlskey=/home/core/tls/server-key.pem
ところが、TLSを有効にしているはずなのに、クライアントからは接続できない。tls: oversized record received
というエラーメッセージが出ている。
$ env | grep DOCKER
DOCKER_HOST=tcp://160.16.81.168:2376
DOCKER_TLS_VERIFY=1
$ docker ps -a
FATA[0001] An error occurred trying to connect: Get https://160.16.81.168:2376/v1.18/containers/json?all=1: tls: oversized record received with length 20527
しかもTLSを使わないと、接続できてしまう。
$ unset DOCKER_TLS_VERIFY
$ env | grep DOCKER
DOCKER_HOST=tcp://160.16.81.168:2376
$ docker ps -q
c1c5882fd47b
f0aa9b77616e
8a7cfde533d5
オプションの指定が間違っているのだろうか、とここでかなり悩んだ。
問題の切り分け
boot2docker環境を参考にして、systemdを使わずにdockerを起動した場合と比べてみる。
- systemdのsocket activationを使った場合 (
--host=fd://
)- TLSを使わない … 接続できる
- TLSを使う … 接続できない
- dockerデーモンを直接起動した場合 (
--host=tcp://0.0.0.0:2376
)- TLSを使わない … 接続できる
- TLSを使う … 接続できる
どうも、systemdのsocket activation経由の場合だけ、TLSが有効にならないようだ。
socket activation経由ではTLSが有効にならない理由
試行錯誤した挙句、ふと気がついた。 systemdを経由してdockerを起動する場合、docker側は fd://
というプロトコルで待ち受けている。ローカル接続でのUNIX domain socketや遠隔接続でのTCPソケットはsystemd側が受け付けてdockerに渡す仕組み。図にするとこうなる。
systemd
+-------------------+
docker client <- (unix domain socket) -> | docker.socket |
| | <- (fd://) -> docker server
docker client <- (tcp port 4376) -> | docket-tcp.socket |
+-------------------+
一方、systemdを経由せずにdockerを起動する場合は、こうなる。
docker server
+---------------------------+
docker client <- (unix domain socket) -> | --host=fd:// |
| |
docker client <- (tcp port 4376) -> | --host=tcp://0.0.0.0:2376 |
+---------------------------+
TLSが有効になるのは、遠隔接続するTCPソケットの場合のみ。UNIXドメインソケットを使うローカル接続ではTLSは使わない。dockerデーモンになったつもりで考えてみると、systemdから fd://
経由で接続を受け取ると、その接続がUNIXドメインソケットからきたのか、それともTCPソケットから来たのか分からないのではないか。分からないから、TLSが有効にならないのではないか。
dockerのソースコード (server_linux.go)を読むと、プロトコルごとに分岐していて、tcpソケットの場合のみtlsverifyの警告をだしている。
switch proto {
case "fd":
ls, err := systemd.ListenFD(addr)
// 中略
case "tcp":
if !s.cfg.TlsVerify {
logrus.Warn("/!\\ DON'T BIND ON ANY IP ADDRESS WITHOUT setting -tlsverify IF YOU DON'T KNOW WHAT YOU'RE DOING /!\\")
}
if l, err = NewTcpSocket(addr, tlsConfigFromServerConfig(s.cfg), s.start); err != nil {
return nil, err
}
// 中略
case "unix":
if l, err = NewUnixSocket(addr, s.cfg.SocketGroup, s.start); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("Invalid protocol format: %q", proto)
}
CoreOS上でdockerを遠隔操作するための設定
話が長くなった。systemdを経由するとTLSが使えないのが分かったので、TCPソケットはdockerが直接待ち受けるようにすればいい。
まず、 docker-tcp.socket
は無効化し、ファイルも削除する。
# docker disable docker-tcp.socket
# rm /etc/systemd/system/docker-tcp.socket
次に、 /etc/systemd/system/docker.service.d/tls.conf
を編集し、起動オプションに --host=tcp://0.0.0.0:2376
を追加する。
[Service]
Environment='DOCKER_OPTS=--host=tcp://0.0.0.0:2376 --tlsverify --tlscacert=/home/core/tls/ca.pem --tlscert=/home/core/tls/server-cert.pem --tlskey=/home/core/tls/server-key.pem'
dockerデーモンを再起動して、TCPソケットでの待ち受けが有効になっている (--host=tcp://) ことを確認する。
# systemctl daemon-reload
# systemctl stop docker
# systemctl start docker
# systemctl status docker
● docker.service - Docker Application Container Engine
Loaded: loaded (/usr/lib64/systemd/system/docker.service; enabled; vendor preset: disabled)
Drop-In: /etc/systemd/system/docker.service.d
└─tls.conf
Active: active (running) since Mon 2015-06-08 09:13:50 JST; 1min 44s ago
Docs: http://docs.docker.com
Main PID: 14729 (docker)
Memory: 17.1M
CGroup: /system.slice/docker.service
├─14729 docker --daemon --host=fd:// --host=tcp://0.0.0.0:2376 --tlsverify --tlscacert=/home/core/tls/ca.pem ...
└─14768 docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 80 -container-ip 172.17.0.2 -container-port 80
これで手元のMac環境からもTLS接続でさくらVPSサーバ上のdockerに接続できるようになった。もちろん、クライアント証明書がない環境からは繋がらない。
$ env | grep DOCKER
DOCKER_HOST=tcp://160.16.81.168:2376
DOCKER_TLS_VERIFY=1
$ docker ps -q
c1c5882fd47b
f0aa9b77616e
8a7cfde533d5
dockerデーモンは常時起動しているので、systemdを経由して起動するメリットはあまりない…はず。
まとめ
長くなったけど、CoreOS上でdockerを遠隔操作する場合は、以下2点に気をつけなければいけない。
- 必ずTLSを有効にすること (CoreOSサイトのサンプルは参考にしない)
- TCP接続はsystemdを経由せず、docker側で待ち受ける
つまり、以下の内容の /etc/systemd/system/docker.service.d/tls.conf
を作ればいい。まとめちゃうと簡単だね。
[Service]
Environment='DOCKER_OPTS=--host=tcp://0.0.0.0:2376 --tlsverify --tlscacert=/home/core/tls/ca.pem --tlscert=/home/core/tls/server-cert.pem --tlskey=/home/core/tls/server-key.pem'