久々にこのブログを更新するついでに、masterにpushするだけで自動デプロイする環境を整理した。

シンプルで便利な形に押し込めたので記事としてまとめておく。

サーバーで動かすDockerイメージ

以下のとおりである。

イメージ名 説明
endaaman/endaaman.me 本ブログのNuxt製フロントエンド。endaaman.me
endaaman/api.endaaman.me 本ブログのGo製バックエンド。api.endaaman.me
nextcloud:stable-fpm 俺用Nextcloudのapp image
mariadb:10 俺用NextcloudのDB image
nginx:1.17 俺用Nextcloudのfrontend image
jwilder/nginx-proxy endaaman/endaaman.meendaaman/api.endaaman.menginx:latest をバーチャルホストとして束ねる
jrcs/letsencrypt-nginx-proxy-companion nginx-proxyの配下をまとめてSSL化するすごいやつ

これらをdocker-compose.ymlで全部まとめて、

$ docker-compose.yml pull && docker-compose.yml up -d --build

で更新&再起動できるようにしている。

デプロイの流れ

  1. masterのpushをトリガーとしてDocker HubでDockerfileをビルド
  2. Webhookでサーバーにビルド完了通知としてPOSTリクエストが飛ぶ
  3. POSTをもらったらイメージをpullしてコンテナを再起動

以上の工程となっている。

1. masterのpushをトリガーとしてDocker HubでDockerfileをビルド

GitHubのリポジトリとDocker Hubのリポジトリを連携させる(BuildsLink to GitHub)。

Configure Automated Buildsで設定を画面を開き、BUILD RULESから連携したリポジトリのmasterへのpush時に自動的にビルドが走るように設定できる。

手動でビルドを回すこともできる。ここまではやってる人も多いかもしれない。

2. Webhookでビルド完了を通知

Docker HubのWebhookはイメージビルド完了時に雑にPOSTを投げてくれる。リポジトリページのWebhooksでそのPOSTの発行先を登録しておく。

3. サーバーでイメージをpullしてコンテナを再起動

ビルドが終わるとPOSTが飛んでくるので、それを受け付けるスクリプトを書いた。

import argparse
import logging
import os
import subprocess as sp
import http.server


fmt = '[%(asctime)s]%(levelname)s: %(message)s'
logging.basicConfig(level=logging.INFO, format=fmt, datefmt='%Y-%m-%d %H:%M:%S',)
logger = logging.getLogger(__name__)

parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, default=8080)
parser.add_argument('--host', type=str, default='127.0.0.1')
parser.add_argument('--script', type=str, required=True)
args = parser.parse_args()

HOST = args.host
PORT = args.port
SCRIPT = os.path.abspath(args.script)

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.log_client()
        self.respond(501)

    def do_POST(self):
        self.log_client()
        self.log_message(f'Starting {SCRIPT}')
        result = sp.Popen(['bash', SCRIPT]).wait()
        if result == 0:
            self.log_message(f'Done script.')
            status = 200
        else:
            self.log_error(f'Script failed.')
            status = 500
        self.respond(status)

    def respond(self, status):
        self.send_response(status)
        self.send_header('Content-length', 0)
        self.end_headers()

    def log_client(self):
        host, port = self.client_address
        self.log_message(f'{self.requestline} from {host}:{port}')

    def log_request(self, code='-', size='-'):
        return

    def log_message(self, fmt, *args):
        logger.info(fmt % args)

    def log_error(self, fmt, *args):
        logger.error(fmt % args)

if __name__ == '__main__':
    if not os.path.exists(SCRIPT):
        logger.error(f'Target script ({SCRIPT}) does not exist.')
        exit(1)

    httpd = http.server.HTTPServer((HOST, PORT), Handler)
    logger.info(f'Starting server at {HOST}:{PORT}')
    logger.info(f'Target script: {SCRIPT}')
    httpd.serve_forever()
    logger.info(f'Server closed.')

ログ周りのごちゃごちゃが多いが大したことはしてない。

たとえばpython webhook.py --script hoge.shで起動すると、POSTをもらうとbash hoge.shが実行される。Pythonの標準ライブラリだけで動くようにしてるのがミソ。

systemdでデーモン化

まずdocker-composeで最新イメージをpullしてコンテナを再起動するスクリプトを用意する。

set -eu
cd $(realpath $(dirname "$0"))

docker-compose pull -q
docker-compose up -d --build --quiet-pull

echo done

POSTをトリガにこのスクリプトを実行するPythonスクリプトをデーモン化するsystemd serviceを作る。ただし、スクリプトのディレクトリを決め打ちしたくないのでテンプレートを書く。

[Unit]
Description=webhook

[Service]
WorkingDirectory=${DIR}
ExecStart=python webhook.py --host '0.0.0.0' --port '45454' --script ./restart-docker-compose.sh
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

このようにワーキングディレクトリを$DIR変数にしておいて、envsubstであとから流し込むようにする。

このテンプレートに変数を埋め込んでserviceファイルとして配置するスクリプトを書く。

set -eu

DEST_DIR=$HOME/.config/systemd/user
DEST_PATH=$DEST_DIR/webhook.service
mkdir -p $DEST_DIR

export DIR=$(realpath $(dirname "$0"))
cat ./files/webhook.template.service | envsubst > $DEST_PATH

echo Wrote "$DEST_PATH"

install-systemd-unit.shrestart-docker-compose.shと同じ場所にあるのを確認してから、bash install-systemd-unit.shでserviceファイルを配置して、webhook.serviceを有効化&起動するだけ。

さらに、ユーザーレベルのサービスはログアウト時に潰れてしまうので、それを回避するために

$ sudo loginctl enable-linger <USER NAME>

しておく。これであとは何も考えずにGitHubにソースをpushするだけ。

Webhook余談

読めば分かるとおり、POSTに対して認証などのフィルタ機構がないので、誰か勝手に野良POSTしてもリスタートのスクリプトが走ってしまう。 docker-composeなので実際は<container name> is up-to-dateとなり再起動はかからないが、どうにも気持ち悪い。

今回は無視しているがWebhooksDocker Hub Webhooks | Docker Documentationの中身を見ても認証に使えそうなものはないし、POSTを発行するホストも普通のAWSのサーバーなのでどれが正式なリスタート要求のPOSTなのか識別するすべがない。

一応"callback_url"フィールドがあるのでこのエンドポイントにPOSTした可否でもって実際にリスタートするかどうか決めてもいいが、そこまで実装するのも手間なので端折った。ただ、WebhooksのView HistoryでPOSTリクエストの履歴を見るとこのPOSTの成功の可否自体が記録されているので、コールバックを使って認証するのも違うようにも思われる。

どうするのが正解なのだろうか。

感想

動いているのはしょうもないPythonスクリプトだけでやっていることも単純なので気楽。ただしDocker Hubのビルドがくっそ遅いので、気の短い人は手元でdocker buildしとdocker pushを叩けば時短になる。