at posts/single.html

さくらVPSでdocker環境 (2) 遠隔操作の落とし穴

さくらVPSサーバにCoreOSをインストールできたので、いよいよ docker を使ってコンテナを立てていく。

dockerをどこから操作するか

コンテナ上でアプリを動かすためには、以下のステップを経る。

  1. GitHubなどからアプリのソースコードとDockerfileを入手する (git clone)
  2. ソースコードからdockerイメージを作る (docker build)
  3. 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: 認証

ざっとググってみると、以下のような情報が見つかる。

まずは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

やっていることはシンプル。

  1. オレオレ認証局を作る
  2. サーバ証明書を発行する
  3. クライアント証明書を発行する

もちろん、サーバ証明書はサーバ側に、クライアント証明書はクライアント側に配置する。オレオレ認証局だけど、自分しか使わないので問題ない。

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点に気をつけなければいけない。

  1. 必ずTLSを有効にすること (CoreOSサイトのサンプルは参考にしない)
  2. 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'

関連する日記