【Docker / nginx / Bash】Docker で動作する Markdown パーサを作成してイメージを公開してみた

※ この記事は 2022年7月 に作成したものを一部改稿したものです。

**Docker** は、アプリケーションとそれが動作する最低限の環境を1つにまとめたコンテナと呼ばれる仮想環境を作成・配布・実行するためのソフトウェアおよびプラットフォームで、Go 言語で実装されています。
ホストマシンのカーネルを利用して仮想化を行うため、ハイパーバイザを利用してゲストOSを動作させる VirtualBox などの仮想マシンと比べて軽量で、環境の導入や配布、廃棄を手軽に行うことが可能です。

理解を深めるためにイメージを自作してみたので、概要や構成を紹介したいと思います。

以下、WSL 2 (Ubuntu) 上の Docker Engine で動作を確認しています。
```
$ docker version
Client: Docker Engine - Community
 Version:           20.10.17
 API version:       1.41
 Go version:        go1.17.11
 Git commit:        100c701
 Built:             Mon Jun  6 23:02:57 2022
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.17
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.17.11
  Git commit:       a89b842
  Built:            Mon Jun  6 23:01:03 2022
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.6.6
  GitCommit:        10c12954828e7c7c9b6e0ea9b0c02b01407d3ae1
 runc:
  Version:          1.1.2
  GitCommit:        v1.1.2-0-ga916309
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
```

## Docker イメージの概要
今回作成したアプリケーションは、Markdown 形式で記述されたテキストを HTML または PDF に変換してブラウザ上で表示する、という単純なものです。

作成したイメージは `md-parser` という名前で Docker Hub に公開しています。

https://hub.docker.com/repository/docker/cralfa/md-parser

イメージはコンテナの土台 (雛形) となるテンプレートのようなものです。
Docker Hub は Docker イメージを公開・配布・取得するためのプラットフォームで、無料で利用する (ただしプライベートリポジトリは1つまで) ことができます。

まずは、作成したイメージを実際に使用してみます。
Docker のサービスを起動し、変換したい Markdown (.md) ファイルがあるディレクトリに移動して以下のコマンドを実行します。
```
$ docker run -d -p 80:80 -v $PWD:/tmp cralfa/md-parser
Unable to find image 'cralfa/md-parser:latest' locally
latest: Pulling from cralfa/md-parser
461246efe0a7: Pull complete
060bfa6be22e: Pull complete
b34d5ba6fa9e: Pull complete
8128ac56c745: Pull complete
44d36245a8c9: Pull complete
ebcc2cc821e6: Pull complete
24b7d80b6341: Pull complete
bcfa06b783d2: Pull complete
dd9c98fffa19: Pull complete
d275f7bd70eb: Pull complete
a7cd589031b4: Pull complete
13443ee00f50: Pull complete
91c8d63e27b9: Pull complete
Digest: sha256:d113ccc231c95f30b84f4f39444de6ae60e1e585d886476660f0fef83a88d72e
Status: Downloaded newer image for cralfa/md-parser:latest
3e77e7e0de428a04e5c945ddd270c4896a2541afbc4e8de985df1117cee98b51
```
`docker run` コマンドを使用すると Docker イメージを基に新しいコンテナを作成しアプリケーションを実行することができます。
コマンドラインの末尾の `cralfa/md-parser` で実行するイメージをリポジトリ名で指定しています。

Docker Hub のリポジトリではタグを用いてイメージのバージョンを区別しますが、イメージの取得時や作成時にタグを省略すると `latest` タグが使用されます。
タグを指定するには「<リポジトリ名>:<タグ名>」のようにします。

`cralfa/md-parser:latest` のイメージがローカルに存在しない場合、Docker Hub からイメージがダウンロードされます。
1度ダウンロードしたイメージはローカルに保管され次回以降はローカルのイメージが使用されます。

`-d` はコンテナをバックグラウンドで起動するオプション、
`-p 80:80` はホスト側の80番ポートをコンテナの80番ポートに接続する指定、
`-v $PWD:/tmp` はホスト側のカレントディレクトリをコンテナの `/tmp` ディレクトリにバインドマウントする指定です。

コンテナの起動が完了すると、コンテナのID (SHA-256 ハッシュ値、上記の例では `3e77e7e0de42...`) が表示されます。
起動が完了したら、ブラウザでローカルホストの80番ポート ( <http://localhost/> ) に接続します。

![2022-07-31 19_05_47-Markdown Parser.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765253/89fa91cf-3111-fcb0-ce7a-dba32ec8ca5c.png)

すると、上の画像のように Markdown を変換した HTML が表示されます。
(Markdown のサンプルとして Ruby の [日本語版 README](https://github.com/ruby/ruby/blob/master/README.ja.md  ) を使用しています。)

今度は、クエリ文字列として `?pdf` を付加したURL ( <http://localhost/?pdf> ) を表示してみます。

![2022-07-31 19_14_40-localhost__pdf.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3765253/6ecfc2a1-6380-dbf9-c5ad-367a4966c21d.png)

すると、少しの待ち時間の後 Markdown を変換した PDF が埋め込まれたページが表示されます。
(上の画像は Google Chrome での表示です。ブラウザによって表示や挙動が異なる場合があります。)

ページ上部のダウンロードボタンをクリックすると「名前を付けて保存」のウィンドウが表示され、PDFファイルをダウンロードすることができます。

## Docker イメージの作成
Docker イメージを新しく作成するには、Dockerfile というテキストファイルを作成し、イメージを作り上げるために実行するコマンドライン命令を記述します。
`md-parser` のイメージ作成時に使用した Dockerfile の内容は以下になります。
```dockerfile
FROM nginx:1.23.1

RUN apt -qq update \
    && apt -qq -y install --no-install-recommends fcgiwrap spawn-fcgi pandoc wkhtmltopdf unzip \
    && apt clean \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /usr/share/fonts
RUN curl https://moji.or.jp/wp-content/ipafont/IPAexfont/IPAexfont00401.zip -O \
    && unzip IPAexfont00401.zip \
    && chmod a+r IPAexfont00401/*.ttf \
    && fc-cache -fv IPAexfont00401 \
    && rm -rf IPAexfont00401.zip

EXPOSE 80

COPY default.conf /etc/nginx/conf.d/

WORKDIR /usr/share/nginx
RUN mkdir -p cgi-bin/output \
    && chown -R nginx:nginx .
COPY index.cgi cgi-bin/
COPY template/ cgi-bin/template

COPY docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
```
まず、`FROM` 命令でベースとなるイメージを指定します。
今回はブラウザからリクエストを受けてレスポンスを返すWebサーバが必要になるため、nginx のイメージを利用しています。

nginx のイメージは Alpine Linux をベースとしたものと Debian をベースにしたものが配布されています。
最初は軽量な Alpine Linux ベースのイメージを利用しようと考えていましたが、他に利用するツールのサイズが大きく最終的なイメージのサイズに大きな差がないことと、Bash が入っていないことから Debian ベースのイメージを利用しています。

次の `RUN` 命令では `apt` コマンドを使用して 追加で必要になるソフトウェアのパッケージをインストールしています。
インストール後に `apt clean` を実行したりキャッシュファイルを削除したりすることで、極力イメージのサイズが小さくなるようにしています。

続いて、`WORKDIR` 命令で作業ディレクトリを移動して日本語フォント (IPAフォント) のインストールを行っています。
今回 PDF への変換に利用している `wkhtmltopdf` パッケージは日本語フォントを含んでいないため、インストールしないと日本語が脱落した PDF が生成されます。

次の `EXPOSE` 命令で80番ポートを listen するよう指定し、`COPY` 命令で nginx の設定ファイルをコンテナ内にコピー (上書き) しています。
コピー元の default.conf は Dockerfile と同じディレクトリに置いておきます。

続いて nginx のドキュメントルートに移動して必要なファイルを配置し、
最後にサービスの起動スクリプトをコピーしエントリーポイントとして実行するよう指定しています。

Dockerfile を作成したら、`docker build` コマンドを実行して Dockerfile からイメージをビルドします。
```
$ docker build -t cralfa/md-parser .
Sending build context to Docker daemon  83.97kB
Step 1/12 : FROM nginx:1.23.1
1.23.1: Pulling from library/nginx
461246efe0a7: Already exists
060bfa6be22e: Already exists
b34d5ba6fa9e: Already exists
8128ac56c745: Already exists
44d36245a8c9: Already exists
ebcc2cc821e6: Already exists
Digest: sha256:bd06dfe1f8f7758debd49d3876023992d41842fd8921565aed315a678a309982
Status: Downloaded newer image for nginx:1.23.1
 ---> 670dcc86b69d
Step 2/12 : RUN apt -qq update     && apt -qq -y install --no-install-recommends fcgiwrap spawn-fcgi pandoc wkhtmltopdf unzip     && apt clean     && rm -rf /var/lib/apt/lists/*
 ---> Running in bdb4c8bcb641
                        ︙
Step 5/12 : EXPOSE 80
 ---> Running in a5c77d81e807
Removing intermediate container a5c77d81e807
 ---> 91f7331dc918
Step 6/12 : COPY default.conf /etc/nginx/conf.d/
 ---> f4ca26576ebc
Step 7/12 : WORKDIR /usr/share/nginx
 ---> Running in 92bad22dd555
Removing intermediate container 92bad22dd555
 ---> 409e87f820fd
Step 8/12 : RUN mkdir -p cgi-bin/output     && chown -R nginx:nginx .
 ---> Running in 7e70bc592216
Removing intermediate container 7e70bc592216
 ---> 462af5572727
Step 9/12 : COPY index.cgi cgi-bin/
 ---> 083b271cfbff
Step 10/12 : COPY template/ cgi-bin/template
 ---> 4b8dafa074e5
Step 11/12 : COPY docker-entrypoint.sh /
 ---> b20677154f1e
Step 12/12 : ENTRYPOINT ["/docker-entrypoint.sh"]
 ---> Running in 787f61330883
Removing intermediate container 787f61330883
 ---> ed6d70667e34
Successfully built ed6d70667e34
Successfully tagged cralfa/md-parser:latest
```
作成したイメージは `docker images` コマンドで確認できます。
```
$ docker images
REPOSITORY                                TAG                   IMAGE ID       CREATED         SIZE
cralfa/md-parser                          latest                ed6d70667e34   2 minutes ago   582MB
nginx                                     1.23.1                670dcc86b69d   11 days ago     142MB
public.ecr.aws/sam/emulation-nodejs16.x   rapid-1.52.0-x86_64   0625e2ecab4c   2 weeks ago     398MB
                                            ︙
```
イメージを Docker Hub で公開するためには、`docker login` コマンドで Docker レジストリにログイン後、`docker push` コマンドを実行します。
```
$ docker push cralfa/md-parser
Using default tag: latest
The push refers to repository [docker.io/cralfa/md-parser]
c56311591fde: Pushed
c2113d2a192a: Pushed
193a90899511: Pushed
f360a2bf7769: Pushed
9063d6aaccf5: Pushed
689661a3e16e: Pushed
0861aaab41bb: Pushed
abc66ad258e9: Layer already exists
243243243ee2: Layer already exists
f931b78377da: Layer already exists
d7783033d823: Layer already exists
4553dc754574: Layer already exists
43b3c4e3001c: Layer already exists
latest: digest: sha256:a08625584f8b4f5ad7600ff59d779e9915d1b66d6e44851aebaa4e8738bb6f2b size: 3030
```

## Webアプリケーションの構成
続いて、`md-parser` の中核となるWebアプリケーション部分の構成を紹介します。

`md-parser` はブラウザからリクエストを受けたタイミングで動的に変換を行うつくりにしており、nginx では静的ファイルをホストするのではなくCGIスクリプト (Bash シェルスクリプト) を実行するようにしています。
nginx で CGI (Common Gateway Interface) を実行するためには `fcgiwrap` と `spawn-fcgi` という FastCGI 上で CGI を実行するためのツールが必要になるため、apt でインストールしています。

エントリーポイントの起動スクリプトでは、以下のように FCGI Wrap と nginx のサービスの起動を行っています。
```sh
#!/bin/bash -eu

spawn-fcgi -u nginx -g nginx -s /var/run/fcgiwrap.socket -M 766 -- /usr/sbin/fcgiwrap -f
nginx -g 'daemon off;'
```
nginx の設定ファイルは以下のようになっています。
```nginx
server {
    listen       80;
    server_name  localhost;

    access_log  /var/log/nginx/access.log;
    error_log   /var/log/nginx/error.log warn;

    location / {
        root   /usr/share/nginx/cgi-bin;
        index  index.cgi;
    }

    location ~ \.cgi$ {
        root           /usr/share/nginx/cgi-bin;
        fastcgi_pass   unix:/var/run/fcgiwrap.socket;
        fastcgi_index  index.cgi;
        include        fastcgi_params;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}
```
1つ目の `location` ディレクティブでルートURLリクエスト時にCGIスクリプトを実行するよう指定し、
2つ目で FastCGI 用の設定値を指定しています。

リクエスト時に実行されるCGIスクリプトは以下のようになっています。
```sh
#!/bin/bash -x

readonly DATA_DIR='/tmp'
readonly BASE_DIR="$(dirname $0)"
readonly OUT_DIR="$BASE_DIR/output"

get_markdown () {
    find $DATA_DIR/ -name '*.md' | head -n 1
}

serve_error () {
    echo -e 'Content-Type: text/html\n'
    cat $BASE_DIR/template/error.html | sed "s/\{\}/$1/"
    exit 1
}

serve_html () {
    local md=$(get_markdown)
    [ -z "$md" ] && serve_error 'Please specify Markdown (.md) file.'

    echo -e 'Content-Type: text/html\n'
    pandoc -f gfm --template $BASE_DIR/template/template.html "$md"
}

serve_pdf () {
    local md=$(get_markdown)
    [ -z "$md" ] && serve_error 'Please specify Markdown (.md) file.'

    local pdf_name=$(basename ${md%.md}.pdf)
    pandoc -f gfm -t html5 "$md" -o $OUT_DIR/output.pdf || exit $?

    echo 'Content-Type: application/pdf'
    echo -e "Content-Disposition: inline; filename=\"${pdf_name}\"\n"
    cat $OUT_DIR/output.pdf
}

if [[ "$QUERY_STRING" == **pdf** ]]; then
    serve_pdf
else
    serve_html
fi

exit 0
```
リクエストURLのクエリ文字列に「pdf」が含まれているかどうかで HTML に変換するか PDF に変換するかを場合分けし、`Content-Type` ヘッダと変換後のファイルを含む HTTP レスポンスを標準出力に出力しています。
Markdown から HTML, PDF への変換には `pandoc` コマンドを使用しています。

PDF のレスポンス時にセットしている `Content-Disposition: inline` のヘッダで、ファイルをすぐにダウンロードさせるのではなくページとして表示するようブラウザに伝えています。

Dockerfile は基本的に root ユーザで実行されますが、CGIスクリプトは nginx ユーザで実行されます。
そのため、CGIスクリプトで読み書きするファイルやディレクトリは nginx ユーザ向けに権限を設定する必要があります。
Dockerfile でフォントファイルに読み取り権限を付与したり `/usr/share/nginx` ディレクトリ以下の所有者を変更したりしているのはそのためです。