詩と創作・思索のひろば

ドキドキギュンギュンダイアリーです!!!

Dockerfile をベースイメージの更新に自動で追従させる

前回のエントリで作った Docker イメージ motemen/datastore-emulator は、google/cloud-sdk をベースにしているが、このベースイメージがけっこうな頻度で更新される。とうぜん自分はその追従に手を煩わせる気はなくて、全部自動でやってほしい。

やりたかったこと

  • google/cloud-sdk:x.y.z がリリースされたら、
  • リポジトリ中の ./Dockerfile と ./alpine/Dockerfile の FROM を google/cloud-sdk:x.y.z(-alpine) に更新し、
  • x.y.z タグを打って git push することで、
  • Docker Hub に x.y.z(-alpine) タグとしてリリースする

これを自動かつ無料で実現したい。

採用しなかった案: 自分でなんか作る

はじめは適当な GitHub Actions をこしらえて、Docker Hub のイメージ更新を検出する、ということをやるつもりだった。

更新フィードを Docker Hub が吐いてくれれば……と考えていたけど、イメージは日付が新しければよいというものではなく、*-slim みたいなバリエーションもある以上、タグをバージョンとして解釈して適切なものを選択する、としなくてはいけない。やればできそうなんだけど、Dockerflie の公式なパーザがないのはネックだった。それが理由じゃないんだけど、この案は不採用。もっと楽な方法があったからだ。

Renovate を使う

もうこれで説明しきったも同然なんだけど、最近試して気に入っていた Renovate が Dockerfile の更新に対応しているので、これを使う。簡単に言うと、依存関係を更新する Pull Request を作ってくれるやつです。

Whitesource Renovate - Automated Dependency Updates

Dockerfile を置いたリポジトリについて、Renovate を設定すれば勝手に更新を見つけてプルリクしてくれる。

docker:enableMajor という、Dockerfile でメジャーバージョンの更新を有効にする preset が効いていなかった不具合があったのだけど、つい最近解消されました

ちなみに Dependabot も Dockerfile の更新に対応しているが、複数の Dockerfile の更新をひとつの PR で処理する、というのができなさそうなので今回はそぐわなかった。他の理由もあって Renovate のほうが好みだというのもある。

自動マージを有効にするためにテスト書く

Renovate には automerge という設定項目があって、これを使うと(CI が通ったあとに)PR を自動マージするってことができる。CI をうまく構成すればハンズフリーで運用できそうだ。というわけで以下のようなテストを書く。

  • 全ての Dockerfile がベースイメージの同じバージョンに基づいていること
    • ./DockerfileFROM foo:1.2.3./alpine/DockerfileFROM foo:2.0.0-alpine だったらこまる
  • それぞれの Dockerfile が正しく派生イメージになっていること
    • ./alpine/Dockerfile*-alpine みたいなタグになってること、ということ

それぞれをテストするスクリプトを書いてGitHub Actions を仕込むFROM の解析は以下のような雑なワンライナーでお茶を濁している。

perl -nle 'print "$1\t$2\t$3" if /^FROM (\S+?):([0-9.]+)(?:-([^:-]+))?(?:$| )/' "$@"

うまく動いてマージされた Pull Request がこちらです。このエントリを書くために探すまで、バージョンが上がってたことに気づかなかったので成功ですね。

Update google/cloud-sdk Docker tag to v292 by renovate · Pull Request #6 · motemen/docker-datastore-emulator · GitHub

タグを打つ

最後に、master にマージされたら Dockerfile をベースに git tag を打ち、これをトリガに Docker Hub でビルドさせる。

これも先ほどの雑なワンライナーを使って、こんな感じにする。GitHub Actions の checkout@v2 であれば特に設定なしで git push できちゃうので、便利。

https://github.com/motemen/docker-datastore-emulator/blob/8aed69b/.github/workflows/tag.yml#L17-L23

tag=$(./scripts/parse-dockerfile-from Dockerfile | cut -f2)
echo "tag: $tag"
if ! git rev-parse --quiet --verify "refs/tags/$tag" > /dev/null; then
  git tag "$tag"
  git push --tags
fi

いかがでしたか? これが全部無料で実現できているだなんて、いったい自分は前世でどんな善行を積んできたんだ……とおののくばかりです。今生でもがんばりたいですね。

GitHub Actions から Cloud Datastore エミュレータを利用する

盆栽のテストを書くのに Cloud Datastore エミュレータ が必要になったので、GitHub Actions で利用してみた。

大きく分けて方法は2つある。

ジョブ中で明示的に gcloud beta emulators datastore start する

setup-gcloud というアクションで gcloud コマンドをインストールできる。これを使う方法。

このままでは必要なコンポーネントはついてこないので、加えて gcloud components install する必要がある。

また、エミュレータが起動したのを待つために、curl を --retry-connrefused つきで利用する、というわけで以下のようになる(全景はこちら)。

    - run: |
        gcloud components install cloud-datastore-emulator --quiet
        gcloud components install beta --quiet
    - run: |
        gcloud beta emulators datastore start --project prchecklist-test &
        curl http://localhost:8081 --silent --retry 30 --retry-connrefused --retry-delay 1
    - run: |
        eval $(gcloud beta emulators datastore env-init)
        ...

サービスコンテナを使う

上記の方法でもいいんだけど、本質的でないことに行数を費やしてしまっている気がしてしまってどうもよろしくない。

GitHub Actions にはサービスコンテナという概念があって、ジョブで利用したいサービスを宣言的に指定できるらしい。MySQL とか Redis とか使うならこれだろうから、Datastore もこれにしたい。

サービスコンテナは Docker イメージを起動するかたち。オフィシャルに google/cloud-sdk ってイメージがあって、これで gcloud コマンドを利用できるんだけど、見るかぎり GitHub Actions のサービスコンテナでは今のところ CMD を外から指定できないようなので、エミュレータを起動する CMD をもったイメージを作る必要がある。

雑に検索してみた感じだと、ベースのバージョンに追従しつつ信用できそうな(=リポジトリと連携して automated build が有効になってそうな)やつがないので自作する。ただ書いてみたかっただけかもしれない。

https://hub.docker.com/r/motemen/datastore-emulator

google/cloud-sdk に CMD と HEALTHCHECK を足しただけ。これを使うと、以下のように書けてスッキリした(全景)。

    services:
      datastore-emulator:
        image: motemen/datastore-emulator
        ports:
          - '8081:8081'
        env:
          CLOUDSDK_CORE_PROJECT: ...

そして

書いていたらまったく同じ課題を解決しようとしている記事をはっけんしました……(これを読んで自分の actions 設定を手直しした)。

budougumi0617.github.io

中間証明書のないサーバにアクセスする

不特定多数のウェブサイトにアクセスするアプリケーションを書いていると、ときおり SSL 証明書の検証エラーとなる URL に行き当たることがある。が、確認のためブラウザでアクセスしてみると、普通に見れてしまったりもする。そんな事例のひとつ、タイトルの通り中間CA証明書のないサーバについて。

https://incomplete-chain.badssl.com/ というわかりやすい例がある。これを curl してみると:

% docker run -it --rm buildpack-deps:buster bash
root@22f1788d53c7:/# curl --version
curl 7.64.0 (x86_64-pc-linux-gnu) libcurl/7.64.0 OpenSSL/1.1.1d zlib/1.2.11 libidn2/2.0.5 libpsl/0.20.2 (+libidn2/2.0.5) libssh2/1.8.0 nghttp2/1.36.0 librtmp/2.3
Release-Date: 2019-02-06
Protocols: dict file ftp ftps gopher http https imap imaps ldap ldaps pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp
Features: AsynchDNS IDN IPv6 Largefile GSS-API Kerberos SPNEGO NTLM NTLM_WB SSL libz TLS-SRP HTTP2 UnixSockets HTTPS-proxy PSL
root@22f1788d53c7:/# curl -sS https://www.example.com/ > /dev/null # 正常系として
root@22f1788d53c7:/# curl -sS https://incomplete-chain.badssl.com/ > /dev/null
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

となるわけだけど、手元のブラウザではたぶんとくにエラーもなく開ける(はず)。

この URL では、サーバはあえて中間証明書を配信していない。にもかかわらずブラウザで見えてしまうのは、badssl.com の説明によると、

  • ブラウザが中間証明書をキャッシュしている
  • または、ブラウザが AIA (= Authority Information Access) fetching に対応している

から、ということらしい。AIA は SSL 証明書の拡張仕様で、サーバの証明書の発行者の証明書へのアクセス方法が記述されるもの。ブラウザで証明書の情報を見てみると確認できるはず。見てみると証明書の URL が http: で記述されていることに最初おどろいたけど、平文で取得した証明書も検証されるので、安全性には問題ないのだった。それを自動的に取得してくるのが AIA fetching(なのだと思う)。AIA fetching は最近のメジャーなブラウザだと Firefox 以外は対応しているのだというがあったが、公式にアナウンスしているものはすぐには見つからなかった。ソースを読まないといけないだろう。

中間証明書をまとめて取得する

で、ブラウザで見られるページはアプリケーションからも取得できたい、というのは普通にありえる需要で、これをどのように実現するのか、という話。

AIA fetching を実装してしまう、というのも正攻法ではあって、そういう実装もある(未検証)。使えるライブラリがない場合や実装したくない場合に、すべてのサイトを尽くすことはできないかもしれないけど、多くをカバーできる方法として、あらかじめ中間証明書を手元に取得しておく、という手をここでは考える。

つまり、ルートCA証明書によって署名されている中間証明書をあらかじめダウンロードしておけばいいわけだ。そして、都合のいいことにそういうリストが作られている。

https://wiki.mozilla.org/CA/Intermediate_Certificates

Mozilla による Common CA Database (CCADB) プロジェクト のリソースのひとつで、Mozilla が信頼しているルート CA によって署名された中間証明書の一覧らしい。このページの権威は自分には確認できなかったけど、どのみちすでに信頼されている手元のルート証明書で検証するので、それっぽいものが網羅されていれば出自はなんでもいいのである。

先述のページから中間証明書を取得し、すでに手元にあるルート証明書で検証できたものだけ保存することにする。Dockerfile にするとこういう感じになる。楽したかったので、apt で入る csvtool というのを使ってみた。

FROM buildpack-deps:buster

SHELL ["/bin/bash", "-o", "pipefail", "-c"]

RUN apt-get update && \
    apt-get install -y csvtool
RUN mkdir -p /opt/intermediate-certs/certs && \
    curl -sL https://ccadb-public.secure.force.com/mozilla/PublicAllIntermediateCertsWithPEMCSV | \
    csvtool namedcol 'PEM Info' - | \
    csvtool drop 1 - | \ # ヘッダを落とす
    csvtool call "printf '%q\n'" - | \ # 改行が含まれているので1行に
    while IFS= read -r e; do \
        pem=$(eval "echo $e" | sed "s/'//"); \ # ' を除く
        if echo "$pem" | openssl verify; then \ # 既存のルート証明書で検証
            echo "$pem" | \
                tee -a /opt/intermediate-certs/ca-bundle.crt \ # 証明書バンドルを作る
                > /opt/intermediate-certs/certs/"$(echo "$pem" | openssl x509 -subject -noout | perl -pe 's/\W/_/g')".pem; \ # 個別の証明書も保存する
        fi; \
    done && \
    c_rehash /opt/intermediate-certs/certs

openssl verify して成功したものだけ保存する。今気づいたけど、中間証明書によって署名されている中間証明書はここで跳ねられてしまうな……。

証明書バンドルは、単にテキストとして並べて記述すればよい(opensslRFC)。取り回しがしやすいように、すべての証明書を1ファイルにまとめたものとそれぞれ別々のファイルにしたものとを作っている。

今回取得した中間証明書をシステムにそのまま組み込んでしまいたいなら、システムのルート(debian なら /etc/ssl/certs)に置いてしまえばいいのだけど、アプリケーション側で制御したいことがほとんどだと思うので、別の場所に置いておくのだいいだろう。

さっそく試してみる。--cacert がポイント。--capath /opt/intermediate-certs/certs:/etc/ssl/certs としてもいい。

% docker run --rm -it $(docker build -q .) bash
root@39401ca59f18:/# curl --version
curl 7.64.0 (x86_64-pc-linux-gnu) libcurl/7.64.0 OpenSSL/1.1.1d zlib/1.2.11 libidn2/2.0.5 libpsl/0.20.2 (+libidn2/2.0.5) libssh2/1.8.0 nghttp2/1.36.0 librtmp/2.3
Release-Date: 2019-02-06
Protocols: dict file ftp ftps gopher http https imap imaps ldap ldaps pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp
Features: AsynchDNS IDN IPv6 Largefile GSS-API Kerberos SPNEGO NTLM NTLM_WB SSL libz TLS-SRP HTTP2 UnixSockets HTTPS-proxy PSL
root@39401ca59f18:/# curl --cacert /opt/intermediate-certs/ca-bundle.crt -sS https://www.example.com/ > /dev/n
ull
root@39401ca59f18:/# curl --cacert /opt/intermediate-certs/ca-bundle.crt -sS https://incomplete-chain.badssl.com/ > /dev/null

やったね! 成功です。

ちなみに

ブラウザの確認だけで済ませてしまうと、こういう設定漏れに気づきにくいみたいです。Mackerel のようなサービスを使えば、証明書の期限なども含めて監視できます。わたしも使っています! どうぞご利用下さい。

mackerel.io

f:id:motemen:20200422224025p:plain

参考文献

はてなで一緒に働きませんか?