Rust で SSL/TLS 通信をしてみる

Rust 勉強中です。

最近、スパムメールの数が増えてきてうざいので、受信メールのスパムチェックを自作しようと思って、メール受信処理を仲介する POP3 プロキシを作ってます。

POP3 サーバとの接続や、DNS over HTTPS (DoH) で、SSL/TLS 通信が必要になります。 Rust で SSL/TLS 通信をするにあたって、いくつかノウハウが得られたので、記事にします。

OS:
  Windows 11 Pro (23H2)

Rust toolchain:
  stable-x86_64-pc-windows-msvc (default)
  rustc 1.80.1 (3f5fd8dd4 2024-08-06)

OS 標準の SSL/TLS 機能を使う

個人的な印象なのですが、OpenSSL を使うといろいろ罠を踏みやすいので、まずは Windows 標準の TLS 通信機能である SChannel を使ってみます。なお、Linux では、OpenSSL が「標準機能」なので、OpenSSL が使われるみたいです。

native-tls クレートを使います。
https://crates.io/crates/native-tls

コードの流れは単純で、最初に TcpStream を作って、それを TlsConnector に渡して、TlsStream を得ます。この TlsStreamReadWrite を実装しているので、read()write_all() などが使えます。

ちなみに、単純に read_to_string() とかを呼ばずに read() を繰り返し呼んでいるのは、 POP3 プロトコルが「行単位」のプロトコルだからです。コネクションを維持したまま「1行だけ読み込み」とかしたいわけです。

native-tls = "0.2.12"
use std::io::{Read, Write, ErrorKind};
use std::net::TcpStream;

use anyhow::{anyhow, Result};
use native_tls::TlsConnector;

pub fn test_native_tls() -> Result<()> {
    let username = "foo@example.com";
    let hostname = "pop3.example.com";
    let port = 995;

    let connector = TlsConnector::new()?;
    let tcp_stream = TcpStream::connect((hostname.to_string(), port))?;
    let mut tls_stream = connector.connect(hostname, tcp_stream)?;

    let mut buf = Vec::new();
    read_some_lines(&mut tls_stream, &mut buf)?;
    println!("Greeting message from server: {}", String::from_utf8_lossy(&buf));

    println!("issue USER command");
    tls_stream.write_all(format!("USER {}\r\n", username).as_bytes())?;
    tls_stream.flush()?;

    let mut buf = Vec::new();
    read_some_lines(&mut tls_stream, &mut buf)?;
    println!("Response from server: {}", String::from_utf8_lossy(&buf));

    Ok(())
}

fn read_some_lines<R: Read>(reader: &mut R, buf: &mut Vec<u8>) -> Result<()> {
    let mut local_buf = [0u8; 1024];
    loop {
        let nbytes = match reader.read(&mut local_buf) {
            Ok(0) => return Err(anyhow!("steam is closed unexpectedly")),
            Ok(len) => len,
            Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
            Err(e) => return Err(anyhow!(e)),
        };
        buf.extend(&local_buf[0..nbytes]);
        if ends_with_u8(buf, b"\r\n") { // allow empty line
            break;
        }
    }
    Ok(())
}

rustls を使う

せっかく Rust でプログラムを書くなら、Rust で書かれた TLS ライブラリを使いたい! という気持ちがありました。あと、reqwest クレートで HTTP/2 を使いたい場合は rustls を使うのがいいらしい。というわけで、rustls クレートを使ってみます。
https://crates.io/crates/rustls

SSL サーバ証明書の検証のためのルート証明書ストアは、OS 標準のものや、Mozilla (Firefox) が使っているものが利用できます。とりあえず、OS 標準のルート証明書ストアを使ってみます。

rustls-native-certs クレートを使います。
https://crates.io/crates/rustls-native-certs

ひとつ罠として、環境変数 SSL_CERT_FILE を定義していると、システムの設定ではなくてそのファイルを参照してしまうので、注意です。わたしは、たまたま、OpenSSL の動作確認をしていた関係でこの環境変数を定義したままになっていて、ハマりました。

rustls = "0.23.12"
rustls-native-certs = "0.7.1"
use std::io::{Read, Write, ErrorKind};
use std::net::TcpStream;
use std::sync::Arc;

use anyhow::{anyhow, Result};
use rustls;
use rustls_native_certs;

pub fn test_tls() -> Result<()> {
    let username = "foo@example.com";
    let hostname = "pop3.example.com";
    let port = 995;

    let tls_root_store = {
        // use "rustls-native-certs" crate
        let mut roots = rustls::RootCertStore::empty();
        for cert in rustls_native_certs::load_native_certs()? {
            roots.add(cert).unwrap();
        }
        roots
    };
    let tls_config = Arc::new(
        rustls::ClientConfig::builder()
            .with_root_certificates(tls_root_store)
            .with_no_client_auth()
    );
    let host = hostname.to_string().try_into().unwrap();
    let mut tls_connection = rustls::ClientConnection::new(tls_config, host)?;
    let mut tcp_socket = TcpStream::connect(format!("{}:{}", hostname, port))?;
    let mut tls_stream = rustls::Stream::new(&mut tls_connection, &mut tcp_socket);

    let mut buf = Vec::new();
    read_some_lines(&mut tls_stream, &mut buf)?;
    println!("Greeting message from server: {}", String::from_utf8_lossy(&buf));

    println!("issue USER command");
    tls_stream.write_all(format!("USER {}\r\n", username).as_bytes())?;
    tls_stream.flush()?;

    let mut buf = Vec::new();
    read_some_lines(&mut tls_stream, &mut buf)?;
    println!("Response from server: {}", String::from_utf8_lossy(&buf));

    Ok(())
}

しかし、cargo build すると、aws-lc-sys クレートのビルドでエラーになってしまいました。「cmake が無いよ」とか言われています。

Windows 環境で cmake コマンドとかのビルドツール一式を準備するのは正直めんどくさいので、rustlsaws-lc-sys クレートを使わないようにします。デフォルトでは、rustls は暗号処理のために aws-lc-rc を使うのですが、ring というのも使えるらしいのです。というわけで、Cargo.tomldefault-features=falsefeatures=["ring] を指定してみます。

rustls = { version = "0.23.12", default-features = false, features = ["ring", "std"] }

これで cargo build が通りました。

しかし、実行してみると、read() でエラーが返ってきてしまいました。エラーメッセージは unexpected end of file となっています。 rustls のドキュメントを見ると、「TCP コネクションが突然切断されたときとかに UnexpectedEOF を返すよ」と書いてありました。

正直よくわかりません。

ネットで検索しても情報がないし、rustls のサンプルコードsimpleclient.rs などは問題なく動作します。

サンプルコードの HTTP クライアントは動くので、POP3 サーバ側がなにか特殊な条件を持っているのかも? と疑ってみましたが、思いつかず・・・。

しかし、あらためて rustls のドキュメントを眺めていたところ、“Crate features” の章に気になる記述がありました。
https://docs.rs/rustls/0.23.12/rustls/#crate-features

tls12 (enabled by default): enable support for TLS version 1.2. Note that, due to the additive nature of Cargo features and because it is enabled by default, other crates in your dependency graph could re-enable it for your application. If you want to disable TLS 1.2 for security reasons, consider explicitly enabling TLS 1.3 only in the config builder API.

そうです。Cargo.tomldefault-features=false を指定したために、本来はデフォルトで有効になっている TLS1.2 機能が無効化されていたのでした。 POP3 サーバが TLS1.3 に対応していなかったため、TLS コネクションが張れずに TCP 接続が切断されてしまっていたようです。

というわけで、Cargo.toml を以下のように修正:

rustls = { version = "0.23.12", default-features = false, features = ["ring", "std", "tls12"] }

無事に動作するようになりました!

ルート証明書ストアを Mozilla (Firefox) のものに切り替える

rustls-native-certs クレートの代わりに webpki-roots クレートを使います。
https://crates.io/crates/webpki-roots

先述のコードの tls_root_store を生成するコードを以下のように変えるだけ OK です。

let tls_root_store = {
    rustls::RootCertStore::from_iter(
        webpki_roots::TLS_SERVER_ROOTS
            .iter()
            .cloned(),
    )
};

おまけ: HTTP/2 を使いたい

DoH (DNS over HTTPS) では、HTTP/2 を使うのがお約束らしいです。

HTTP/2 is, in fact, the minimum recommended version of HTTP for use with DNS over HTTPS (DoH)

reqwest クレートで HTTPS/2 を使うには、rustls を使った上でリクエストにバージョン指定をすれば良いらしい:

・もしくは作者の案内の通り、TLS機能としてrustlsを指定するとhttp/2を使用できる

実際にコードを書いてみると、以下のようになります。

let client = reqwest::Client::builder()
    .use_rustls_tls()
    .https_only(true)
    .build()
    .unwrap();
let response = client.get(url).version(Version::HTTP_2).send().await;
println!("response.version(): {:?}", response.version());
let text = response.text().await?;

有用な情報に感謝!!

Docker を導入しました

いまさらながら、はじめて Docker を使ってみました。先日借りた Linode の VPS (Arch Linux) で Web サーバを動かすためです。単一プロセスの単一コンテナ運用なので、オーケストレーション(Kubernates とか Docker Swarm とか)は使っていません。

参考書

以前から雑誌やネット記事で Docker およびコンテナ技術については触れていましたが、あらためて書籍を読んでお勉強しました。

「セキュリティ実践ガイド」は、セキュリティに限らずいろいろ実用的なことが書いてあって、とても参考になりました。

「イラストでわかる DockerとKubernetes」は、Docker の使い方というよりは Docker の仕組みに関する解説書ですね。いままで雑誌やネット記事で断片的に知っていた内容が整理できて、よかったです。

これらの本で基本的な概念やツールの扱いを把握できたので、実際の作業の際には Docker 公式ドキュメント(マニュアルとリファレンス)を読むだけで OK でした。

docker のインストール

Linux (Arch Linux) に Docker をインストールします。 “Docker Desktop for xxx” ではありません(Linux 向けにもベータ版がリリースされていますが)。

パッケージ docker を pacman でインストールして、常時稼働するように設定します。

$ sudo pacman -S docker
$ sudo systemctl status docker
$ sudo systemctl enable docker
$ sudo systemctl restart docker
$ sudo systemctl status docker
$ sudo docker info
Server:
 Server Version: 20.10.14
 Storage Driver: overlay2
  Backing Filesystem: extfs
  Supports d_type: true
  Native Overlay Diff: false
  userxattr: false
 Logging Driver: json-file
 Cgroup Driver: systemd
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local logentries splunk syslog
 Swarm: inactive
 Runtimes: io.containerd.runc.v2 io.containerd.runtime.v1.linux runc
 Default Runtime: runc
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Registry: https://index.docker.io/v1/
 Experimental: false
 Live Restore Enabled: false
(※一部抜粋)
$ sudo docker run hello-world

containerd の設定については、必要と思わなかったのでスキップしました。 Docker 公式ドキュメントには systemctl enable containerd.service を実行しろと書いてあるけど、やってない。いまのところ、問題なく使えています。

IPv6 の設定もスキップ。なにもしなくてもデフォルトでインターネットとの IPv6 通信ができています。

bash の補完機能も追加しておいたけど、結局、常に sudo 経由で docker コマンドを叩くので、意味がなかった。

Dockerfile

Dockerfile は、公式リファレンスを読みながらゼロから手書き。

今回動かすアプリケーション (Caddy) の公式イメージの Dockerfile も参考になりました:

レイヤー数をできるだけ少なくするとか、そういった最適化は現時点ではやっていません。わかりやすさ優先。

Docker Compose

ポートのマッピングとか、volume の割り当てとか、docker コマンドのオプションで毎回指定するのはいろいろよろしくないと思ったので、 Docker Compose を使うことにしました。

まず、Docker Compose V2 をインストールします(せっかく新規にセットアップするのだから V2 にしておいた)。なお、ホームディレクトリ配下にインストールしたら sudo 時に使えなかったので(あたりまえ)、 /usr/local/lib/docker に移動しました。

$ mkdir -p ~/.docker/cli-plugins
$ cd ~/.docker/cli-plugins
$ curl -OL https://github.com/docker/compose/releases/download/v2.4.1/docker-compose-linux-x86_64
$ chmod a+x docker-compose-linux-x86_64
$ chmod a-w docker-compose-linux-x86_64
$ mv docker-compose-linux-x86_64 docker-compose
$ cd
$ docker compose version
Docker Compose version v2.4.1
$
$ sudo mv .docker /usr/local/lib/docker
$ sudo chown -R root.root /usr/local/lib/docker
$ sudo docker compose version
Docker Compose version v2.4.1
$

docker-compose.yml の書き方:

Docker 公式ドキュメントとしてはファイル名を docker-compose.yml ではなく compose.yaml にしたい意向みたいですが、世の中で広く docker-compose.yml という名前が使われていますし、いまさらファイル名を変えるのはわかりにくいだけだと思ったので、今回は docker-compose.yml にしました。(公式ドキュメントでもあちこちで docker-compose.yml と書いてある)

ログをどうするか?

コンテナ内で稼働させるアプリケーションのログの扱いです。

Docker の stdout に吐かせるか、コンテナにマウントさせたディレクトリにログファイルを吐かせるか。

パフォーマンス的には、コンテナにマウントさせた volume にアプリケーションプロセスが直接ログファイルを書くのが良さそうな気がします。ただし、ハッキングされてアプリケーションプロセスが乗っ取られた場合に、ログファイルを改ざんされてしまうリスクがあります。

fluentd が使えるかも? と思いましたが、アプリケーションプロセスがひとつしかないのに大げさな気がしました。

結局、アプリケーションプロセスのログは標準出力/標準エラー出力に全部吐かせて、 Docker のロギングドライバでなんとかすることにしました。

ここで、今回のアプリケーション (Caddy) はもともと JSON 形式でログを吐いてくれて、タイムスタンプも含まれています。 Docker でログドライバとして json-file を指定すると、

  • ネストした JSON (JSON in JSON) になってしまう
  • タイムスタンプが二重に記録される

というムダが生じます。とはいえ、前者については jq コマンドの出力をパイプで繋いでもう一度 jq コマンドに渡せば解決しますし、後者に関しては、まぁ、無視できるオーバーヘッドかと思われます。

一方、Docker のログドライバとして local を指定すると、

  • アプリケーションプロセスの標準出力と標準エラー出力が区別できない
  • Docker 独自形式なので生のログファイルをコピーしても読めない

という問題があります。

今回は、json-file を使うことにしました。

ログのバッファリングについては、実際にところ問題になるとは思われなかったので、デフォルトのままとしました。

logging:
  driver: json-file
  options:
    max-size: 20m
    max-file: 999

ちなみに、コンテナを起動したら、そのログファイルの実体(絶対パス)を docker inspect コマンドで確認できます。

$ sudo docker inspect $CONTAINER_NAME 2>/dev/null | jq -e -r '.[].LogPath // empty'

今回の環境では、/var/lib/docker/containers/コンテナID/コンテナID-json.log でした。

なお、ログローテーションした結果、どう変わるのかは、まだわかってません:

  • ログファイルの実体(パス)は変わるのか?
  • ローテートされた過去のログファイルも docker inspect コマンドで確認できるのか?
  • ローテートされた過去のログファイルの内容も docker logs コマンドで確認できるのか?

おまけ:

コンテナのログファイルは、コンテナを削除 (docker container rm) すると削除されてしまいます。残しておきたいなら、どこかに待避しておく必要があります。このとき、単にホスト上の別のディレクトリに待避したいのなら(同一パーティーション内であれば)、「コピーする」よりも「ハードリンクを作る」ほうが便利です。ログファイルの中身に一切触らないので、一瞬で終わります。 Docker がオリジナルのパスを削除 (unlink) しても、新しく作ったハードリンクがあるので、ファイルは消えません。ハードリンクを作るには、ln コマンドを実行します(シンボリックリンクを作るオプション -s は付けない)。

$ sudo docker inspect $CONTAINER_NAME | jq -r '.[].LogPath' | xargs -r -t sudo ln -f -t ./log-archive

ちなみに、コンテナ実行中にログファイルのハードリンクを作ることも可能です。オリジナルのログファイルのディレクトリには root 権限がないとアクセスできないので、適当なユーザのホームディレクトリ配下にハードリンクを作っておくと便利だったりします。ただし、このとき、新しく作ったハードリンクに対して chown したり、chmod u-w 相当のことをしたりするのは、 Docker の挙動に影響が生じる可能性があるので、やめておいたほうが安全だと思われます(下図スクリーンショット参照)。これは、ハードリンクの属性を変えると、オリジナルのログファイルの属性も変わってしまうためです(ファイルの属性情報はディレクトリエントリではなく inode にあるのです)。 chmod a+r だけならたぶん大丈夫。もちろん、ログファイルに誰でもアクセスできてしまってはセキュリティ上よろしくないので、ハードリンクを作ったディレクトリのパーミッションを 700 とかにしておくべきですね。

ディスクへの書き込みサイズを制限する

Docker コンテナを稼働させるにあたり、ディスクの使用量制限をかけたい。

コンテナ内のプロセスがハッキングされたときに、コンテナにマウントさせた volume に大量の書き込みをされると、ホストの disk full を招く恐れがあります。これを防ぎたい。

コンテナからの書き込みは、ホスト上では特定のユーザによる書き込みに見える(ように Docker を設定する)ので、ユーザ単位で quota がかけられれば OK かな、と考えました。ちょっと調べてみる:

上記の記事では ex4 のディスクイメージファイルを作って、それをマウントして操作しています。これができるなら、わざわざ quota を使わなくても書き込みサイズの制限が可能になります。

というわけで、64MB のイメージファイルを作って試してみました。

$ truncate -s 64M volume_caddy_ext4.img
$ mkfs.ext4 ./volume_caddy_ext4.img
$ dumpe2fs ./volume_caddy_ext4.img
$ mkdir mnt
$ sudo mount ./volume_caddy_ext4.img ./mnt
$ df ./mnt
Filesystem     1K-blocks  Used Available Use% Mounted on
/dev/loop0         56037    15     51436   1% /home/caddy/mnt
$
$ sudo mkdir ./mnt/caddy
$ sudo chown -R caddy:caddy ./mnt/caddy

実際に書き込みテストをしてみると、小さいサイズ (3MB) は問題なく書き込めて、イメージファイルのサイズを超える書き込み (100MB) はちゃんとエラーになります。

$ dd if=/dev/zero of=./mnt/caddy/z bs=1K count=3K
3072+0 records in
3072+0 records out
3145728 bytes (3.1 MB, 3.0 MiB) copied, 0.00651232 s, 483 MB/s
$
$ dd if=/dev/zero of=./mnt/caddy/z bs=1K count=100K
dd: error writing './mnt/caddy/z': No space left on device
51436+0 records in
51435+0 records out
52669440 bytes (53 MB, 50 MiB) copied, 0.133398 s, 395 MB/s
$
$ df ./mnt
Filesystem     1K-blocks  Used Available Use% Mounted on
/dev/loop0         56037 51451         0 100% /home/caddy/mnt
$

今回は、上記のように固定サイズのディスクイメージファイルをホスト上でマウントして、その中のディレクトリを Docker コンテナに volume としてマウントさせる、という方針にしました。

アプリケーションデータの扱い

アプリケーションデータの扱いは、いろいろ試行錯誤した結果、以下の方針にしました:

  • アプリケーションデータを置くディレクトリは、すべてコンテナの volume として与える。コンテナイメージ内には一切置かない。
  • アプリケーションが実行時に生成するファイル/ディレクトリは、ホスト上のディスクイメージファイルをホスト上でマウントしたディレクトリを「読み書き可能 volume」としてコンテナにマウントする。
  • アプリケーションが読み取るファイル/ディレクトリは、ホスト上のディレクトリを「read-only volume」としてコンテナにマウントして参照させる。

ここでのポイントは、コンテナ内のアプリケーションプロセスのユーザ ID (UID) と、ホスト上のファイル/ディレクトリの所有者の UID を一致させておくことです。「ユーザ名」自体は異なっていても問題ありません(いろいろ混乱しそうな気もしますが)。コンテナ内のアプリケーションプロセスの UID を指定するためには、 Dockerfile でイメージをビルドするときに ARG や環境変数で指定するか、あるいは、 docker-compose.yml の中で user プロパティで指定するか、あるいは、コンテナ起動時に docker コマンドのコマンドライン引数 -u (--user) で指定するか、いずれかの方法をとります。

【2022/Apr/26 追記】
Twitter で知ったのですが、docker-compose.ymlvolumes: のセクションで “short syntax” を使うと罠があります。 bind 元となるホスト側のパスが存在しない場合、Docker が勝手に自動生成してくれちゃうのです。 “long syntax” を使えば(create_host_path: true を指定しなければ)、コンテナ生成時にエラーになってくれます。

修正前の docker-compose.yml

volumes:
  - ./mnt/caddy/config:${XDG_CONFIG_HOME:?must be set}
  - ./mnt/caddy/env:${CADDY_ENV_DIR:?must be set}:ro

修正後の docker-compose.yml

volumes:
  - type: bind
    source: ./mnt/caddy/config
    target: ${XDG_CONFIG_HOME:?must be set}
  - type: bind
    source: ./mnt/caddy/env
    target: ${CADDY_ENV_DIR:?must be set}
    read_only: true

修正後は docker compose up コマンドがちゃんとエラーになってくれました:

$ sudo -E docker compose up -d
Container caddy  Creating
Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /home/caddy/mnt/caddy/config
$

Docker 公式ドキュメントにも、よく読めば注意書きがあります。 “short syntax” のこのおせっかいな挙動は、過去の docker-compose との互換性を保つためとのこと。

  • Compose specification | Docker Documentation
    https://docs.docker.com/compose/compose-file/#long-syntax-4

    create_host_path: create a directory at the source path on host if there is nothing present. Do nothing if there is something present at the path. This is automatically implied by short syntax for backward compatibility with docker-compose legacy.

CPU やメモリに制約をかける

コンテナ内のアプリケーションプロセスが高負荷になったとき、ホストの CPU やメモリを使い果たされてしまうと困ります。とくに、アプリケーションプロセスがハッキングされてしまったときのことを考えると、コンテナの CPU 優先度を下げておきたいです。

nice コマンドみたいに高負荷時にコンテナの CPU 優先度を下げるには、docker コマンドの --cpu-shares オプションを使うらしい。デフォルト値が 1024 なので、たとえば 512 とかを指定すればいいのかな?

メモリスワップは嫌いなので無効化しておきます。スワップが発動するのなんてアプリケーションプロセスがメモリリークしていたときとかハッキングされたときくらいなので、すなおに落ちてくれたほうがマシです。あと、ついでに PID の上限数も適当な値に設定しておきます(ハッキングされたときにホストのカーネルの PID 空間を使い果たされてしまうのを防ぐため)。すべて docker-compose.ymlservices: セクションの中に書きます。

# soft limit CPU "50%"
cpu_shares: 512

# hard limit memory 1GB
deploy:
  resources:
    limits:
      memory: 1gb
      pids: 99

# no memory swap
memswap_limit: 1gb

コンテナのファイルシステムを read-only にしておく

オペミスでコンテナイメージ内にファイルを作ってしまってコンテナ再ビルドでロストしてしまう問題の予防と、ハッキング対策です。万一、コンテナ内に侵入されたときに、ホストのファイルシステムが disk-full にされるリスクも軽減できるハズ。なお、コンテナにマウントさせた volume は、変わらず読み書きできます。

docker-compose.yml の中で services: セクションに1行書くだけです。

read_only: true

コンテナに与える Capabilities を制限する

コンテナに与える特権(一般的に root ユーザのみが可能な操作)を必要最小限に制限します。万一、コンテナにハッキングされてコンテナ内で root 権限を奪われたときの被害を軽減できます。(コンテナからの break-out とは別の話です)

Caddy を実行するだけなら NET_BIND_SERVICE 以外の capability は不要なので、いったんすべての capability を捨てて、NET_BIND_SERVICE のみ個別に許可するようにします。

具体的には、docker-compose.ymlservices: セクションの中に以下の記述を追加します:

cap_drop:
  - ALL
cap_add:
  - NET_BIND_SERVICE

ちなみに、コンテナ内のアプリケーションプロセスに NET_BIND_SERVICE capability を付与したい場合、もちろんコンテナ内の root 権限でプロセスを実行すれば自動的に付与されます。しかし、セキュリティをより強固にするため、root 権限ではなく一般ユーザ権限でアプリケーションプロセスを実行したい、というケースもあるかと思います。

こんなときには、実行ファイルへの capability の付与、という仕組みを使うと、簡単にアプリケーションプロセスに capability を付与できます。

具体的には、アプリケーションの実行ファイルに対して setcap コマンドを実行するだけです。 setcap コマンドの実行には root 権限が必要なので、Dockerfile の中で USER 命令を使う前に実行します。 setcap コマンドがインストールされていない場合は libcap パッケージをインストールします。

RUN type setcap >/dev/null 2>&1 || pacman -S libcap
RUN setcap CAP_NET_BIND_SERVICE+eip ./caddy

これをやっておくと、普通に一般ユーザ権限で実行ファイルを実行すれば(sudo とか無しで)、常に capability が付与された状態でプロセスが起動します。

実行ファイルに付与されている capability は、getcap コマンドで確認できます。

$ sudo docker exec -it $CONTAINER_NAME /bin/bash
[caddy@adbe070c2eac ~]$ getcap ./caddy
./caddy cap_net_bind_service=eip
[caddy@adbe070c2eac ~]$

Docker イメージを再ビルドしてくれない

Dockerfiledocker-compose.yml や環境変数を変更しても、起動コマンド docker compose up で再ビルドされず、しばし悩む。公式ドキュメントを読み直した結果、--build オプションを追加すれば良いとわかった。

--build オプションを指定すると再ビルドを実行してくれますが、 Dockerfiledocker-compose.yml の設定に変更がなければ(同じ設定でビルドした既存のイメージがあれば)、すべてのレイヤーが CACHED となって数秒で終わります。新規のイメージが作られることもなく、イメージ名やハッシュ値もそのまま変わりません。(タグ latest は再ビルドの結果のイメージに付け替えられます)

docker compose up でビルドされたイメージに名前を付ける

デフォルトでは、docker compose でビルドしたイメージの名前は「プロジェクト名_コンテナ名」になる模様。「プロジェクト名」は、docker-compose.yml で明示的に指定しなければ、カレントディレクトリ名になる模様。また、タグ latest が自動的に付与されます。

条件を変えて再ビルドすると、その結果のイメージはデフォルトの名前とタグ (latest) になり、古いイメージは「タグなし」になります(イメージ名は同じ)。

「タグなし」のイメージがあるのはいろいろよろしくないので、とりあえず、docker compose up の直後に docker tag コマンドを実行して、 latest タグのイメージに対して明示的にタグを打つようにしました。

シェルスクリプトの例:

IMAGE_NAME=$(sudo docker inspect $CONTAINER_NAME 2>/dev/null | jq -e -r '.[0].Config.Image // empty')
IMAGE_TAG=run$(date '+%Y%m%d_%H%M%S')_v${CADDY_VERSION}
sudo docker image tag ${IMAGE_NAME}:latest ${IMAGE_NAME}:${IMAGE_TAG}
sudo docker image ls -a

コンテナの自動スタート&リスタート

サーバ(ホスト)が起動したときに自動でコンテナも起動されるようにします。アプリケーションプロセスが落ちたとき or コンテナが落ちたときにも、自動的に再スタートしてほしい。

ためしに、Docker コンテナの restart policy に unless-stopped を設定してみる(docker-compose.ymlservices: セクションに以下の行を追加):

restart: unless-stopped

これで、サーバの起動時に自動的に Docker コンテナが起動されるようになりました。

しかし、ホスト上でディスクイメージをマウントしたりする前処理が走らないため、caddy が正しく動作しません。

あきらめて、systemd を使って制御するようにしました。(docker-compose.yml での restart policy はデフォルト値 "no" に戻しておきます)

上記の参考記事には「docker start コマンドに -a オプションを指定する」というアドバイスが載っていますが、要は ExecStart で指定したプログラムが exit しなければ良いという話です。いわゆる daemon mode というやつですね。 docker compose を使う場合は、docker compose up -d で起動&デタッチしておいて、起動用シェルスクリプトの最後で docker wait $CONTAINER_NAME を実行してあげれば OK です。ここで、docker compose up でデタッチしなければ exit しないので問題ないのでは? となりそうですが、そうすると Docker コンテナのコンソール出力がすべて systemd (journald) に取り込まれてしまいます。今回は、コンテナのコンソール出力が json-file ログドライバでコンテナのログファイルに記録されているので、ログが二重に保存されることになってしまいます。 docker compose up -d でデタッチすればアプリケーションプロセスの stdout/stderr は journald には吐かれなくなるので、この問題を回避できます。

さて、テキストファイル /etc/systemd/system/caddy.service を新規作成して、sudo systemctl daemon-reload を実行。これで、sudo systemctl start caddy とか sudo systemctl stop caddy とかができるようになりました。シェルスクリプトのログは sudo journalctl -u caddy で確認できます(行の先頭に長い文字列が付くのが邪魔ですが)。最後に、sudo systemctl enable caddy を実行して、サーバ起動時に自動的にコンテナが起動されるようにしておきます。

ためしに sudo docker kill caddy でコンテナを落としてみると、systemd が自動的にリスタートしてくれた。OK。

書いた caddy.service ファイルの中身は以下の通りです:

[Unit]
Description=Caddy web server on docker
Requires=docker.service
After=docker.service

[Service]
Type=simple
User=root
Group=root
Restart=on-failure
ExecStart=/home/caddy/run_caddy.sh daemon
ExecStop=/home/caddy/run_caddy.sh stop
SyslogIdentifier=Caddy
#StandardOutput=journal+console

[Install]
WantedBy=multi-user.target

なお、Caddy 稼働中にシェルスクリプトを書き替えると、コンテナが停止したときに挙動がおかしくなることがあります。これは、シェルスクリプトの最後で sudo docker wait $CONTAINER_NAME を実行しているけど、シェルスクリプトのファイル自体はオープンしたままになっていて、コンテナが停止してシェルスクリプトに制御が戻ったときにファイルを後続部分を読み直して、書き換わった後のデータを読んで、それを処理しようとしてしまって、動作がおかしくなるのです。この問題は、最後の行を exec コマンドで実行するようにすれば回避できます。わかっていれば簡単な話。

exec sudo docker wait $CONTAINER_NAME

おまけ:参考になりそうなドキュメント