Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

Docker でデプロイをする方法

いくつかあるのですが、「これだ!」みたいな方法が意外と見当たらなかったので書きます。

Docker を使ったデプロイにはいくつか方法があります。

  • リモートへログインして Docker Hub 経由でイメージを取得してデプロイ
  • ローカルからリモートの docker daemon へデータを送ってデプロイ

1. リモートへ SSH 接続してデプロイ

これが一番お手軽です。

  1. ローカルでイメージを作成
  2. 作成したイメージをリモートリポジトリ(Docker Hub など)にプッシュ
  3. リモートのマシンへログイン
  4. イメージをリポジトリから pull
  5. pull したイメージをもとにコンテナを作成

メリット

まず、とても簡単にできます。リモートへログインして直接コンテナを生成するので、迷う所がありません。

また、リモート側で docker-compose.yml を設定できます。このため、リモートマシン上にあるディレクトリを volumes へマウントできます。

デメリット

最大のデメリットは手動であることです。ローカルでイメージを更新したあと、いちいちリモートへログインして手動で pull する必要があります。Docker Hub と組み合わせて CI/CD をうまく構成できればやれなくはないとは思いますが、そもそも次に紹介する手法をとればもっと楽に実現できます。

また、この方法はリモートリポジトリを経由しなければなりません。そのため、プライベートリポジトリの数など制限があります。たとえば、Docker Hub の無料プランではプライベートリポジトリは 1 つしか作れません。もちろん、Docker Hub 以外にもリポジトリは色々あり、自前で立てることもできます。そのため、頑張ればプライベートリポジトリをいくつも作ることができます。しかし、そもそも次に紹介する方法ならリモートリポジトリを経由することなくデプロイできます。

2. Docker Context を使ってデプロイ

Docker には Context という機能があります。Docker Context を使うと、リモートの docker daemon にコマンドを実行することができます。

$ docker --context remote ps
# リモートマシン上のコンテナ情報が取得できる
$ docker --context remote run nginx
# リモートマシンで nginxイメージのコンテナを立ち上げる
$ docker --context remote build .
# リモートの docker daemon に ビルドコンテキストのファイルが送信されて、リモートでイメージが作成される

しかも、コンテキストは簡単に作成できます。

$ docker context create remote --docker host=ssh://example.com --default-stack-orchestrator swarm

host には ~/.ssh/config に書かれた設定も利用できます。

docker-compose の host オプションでも似たことができます。

$ docker-compose --context remote up
$ # あるいはこれでも
$ docker-compose --host ssh://example.com up

context で一括管理をするとホストの変更が容易です。また、名前も自由につけられるのでわかりやすいです。そのため、利用できるのであれば host オプションより context をおすすめします*1

version: "3"
services:
  web:
    image: nginx
    volumes:
      - /usr/share/nginx:/usr/share/nginx:ro

上の docker-compose.yml を context や host オプションを使って立ち上げます。すると、リモートマシン上にコンテナが立ち上がります。また、コンテナの/usr/share/nginxにリモートマシンの/usr/share/nginxがマウントされます。

複数コンテナを同時に立ち上げるとエラーが起こる

たとえば、以下のように複数コンテナをまとめて立ち上げようとすると、SSHコネクションエラーが発生します。

version: '3'
services:
  serviceA:
    image: nginx
  serviceB:
    image: nginx
  serviceC:
    image: nginx
  # ...
  serviceZ:
    image: nginx

どうやら、コンテナの分だけSSHコネクションが張られるようで、SSHコネクション数の制限に引っかかるみたいです。結局、僕は解決できず、独立したファイルに分けてデプロイすることにしました。

いい感じにデプロイしてみる

以下のような構成でリモートへデプロイしてみます。

./
├ service-a
│ ├ docker-compose.prod.yml
│ └ docker-compose.yml
├ service-b
│ ├ docker-compose.prod.yml
│ └ docker-compose.yml
├ service-c
│ ├ docker-compose.prod.yml
│ └ docker-compose.yml
├ reverse-proxy
│ ├ docker-compose.prod.yml
│ └ docker-compose.yml
└ deploy.sh
  • service-a、b、cはそれぞれ独立したサービス
  • reverse-proxyを介して各サービスへアクセスする
  • コンフィグは docker-compose.yml + docker-compose.prod.yml を使う

このとき、以下のようなスクリプトを書きます。

#!/bin/bash
SCRIPT_DIR=$(cd $(dirname $0); pwd)

for dir in $(ls -d */ | sed -e 's!/!!'); do
  cd $SCRIPT_DIR/$dir
  files=""
  if [ -f "$SCRIPT_DIR/$dir/docker-compose.yml" ]; then
    files+="-f $SCRIPT_DIR/$dir/docker-compose.yml "
  fi
  if [ -f "$SCRIPT_DIR/$dir/docker-compose.prod.yml" ]; then
    files+="-f $SCRIPT_DIR/$dir/docker-compose.prod.yml "
  fi
  if [ -n "$files" ]; then
    docker-compose --context remote $files build
    docker-compose --context remote $files down
    docker-compose --context remote $files up -d
  fi
done

各ディレクトリに入ってデプロイを行っています。context オプションを外せば、そのままローカルでテストができます(適宜docker-compose.dev.yml を追加するなど調整は必要です)。

まとめ

デプロイ時には Docker Context を使いましょう。

$ docker context create remote --docker host=ssh://example.com --default-stack-orchestrator swarm
$ docker-compose --context remote up -d

*1:context に対応したのが 2020 年 6 月リリースのバージョンなので、バージョンによっては host オプションしか使えない可能性があります

正規表現の|のとき、周りは囲わなくて良い

一発ネタですが、意外と直感的じゃないので。

/(a|b|c)/

上の正規表現は、キャプチャをあとで使わない場合、以下と同じです

/a|b|c/
/(?:a|b|c)/

ただし、前後になにかくっついている場合はもちろん異なります。

/(a|b|c)x/
/a|b|cx/ // これとは同じでない

Docker で最速で nginxサーバーを立てる

サンプルリポジトリ

記事で紹介した内容をリポジトリにあげてあります。よろしければ参考にしてください。

github.com

nginx イメージを使う

たったこれだけでカレントディレクトリのpublicフォルダを公開するウェブサーバーを建てられます。

$ docker run -d -p 8080:80 -v $PWD/public:/usr/share/nginx/html:ro nginx

しかし、単にサーバーを立てるだけならわざわざ docker を使わずにもっと短くできます((python -m http.server 8080 ./public で代用できます))。せっかく Nginx を使うのですから、設定ファイルを使いたいですよね。設定ファイルをマウントするには次のようにします。

$ docker run -d -p 8080:80 -v $PWD/public:/usr/share/nginx/html:ro \
  -v $PWD/nginx.conf:/etc/nginx/nginx.conf:ro \
  -v $PWD/conf.d:/etc/nginx/conf.d:ro nginx

しかし、これだけでは立ち上がる Nginx の prefix が /etc/nginx になってしまうので、prefix を /usr/share/nginx にして起動します。

$ docker run -d -p 8080:80 -v $PWD/public:/usr/share/nginx/html:ro \
  -v $PWD/nginx.conf:/etc/nginx/nginx.conf:ro \
  -v $PWD/conf.d:/etc/nginx/conf.d:ro nginx nginx -p /usr/share/nginx -g 'daemon off;'

さらに --restart=always を付ければ再起動後も自動で立ち上がるようになります。

docker-compose を使う

上のコマンドを docker-compose.yml で書くと次のようになります。

version: '3'
services:
  web:
    image: nginx:alpine
    ports:
      - 8080:80
    volumes:
      - ./public:/usr/share/nginx/html:ro
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./conf.d:/etc/nginx/conf.d:ro
    command: nginx -p /usr/share/nginx -g 'daemon off;'

これなら restart: always をつけることで再起動後も自動で立ち上がるようにできます。

あとは docker-compose.yml と同階層でdocker-compose up -dを実行するとサーバーが立ち上がります。

リバースプロキシ

ネットワークをたててその中で完結させたい場合は以下のようにすると簡潔です。

$ docker create network -d bridge my-network
$ docker run -d -p 8080:80 -v $PWD/public:/usr/share/nginx/html:ro \
  --net my-network \
  -v $PWD/nginx.conf:/etc/nginx/nginx.conf:ro \
  -v $PWD/conf.d:/etc/nginx/conf.d:ro nginx \
  nginx -p /usr/share/nginx -g 'daemon off;'

docker-compose.yml を使うと以下になります。

version: '3'
services:
  web:
    image: nginx:alpine
    ports:
      - 8080:80
    volumes:
      - ./public:/usr/share/nginx/html:ro
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./conf.d:/etc/nginx/conf.d:ro
    command: nginx -p /usr/share/nginx -g 'daemon off;'

networks:
  default:
    external:
      name: my-network

external で指定してあるのは、このコンテナでネットワークを立ててしまうと、このコンテナを起点に依存関係ができてしまうためです。ネットワークを作るかわりに external で既存のネットワークを使えば、このコンテナは他のコンテナの起動状況に関わらず立ち上げることができます。また、他のコンテナもこのコンテナの起動状況に依存せずに済みます。

他のコンテナは、外部とはこのリバースプロキシを介して通信できるので、portsの代わりにexposeでポートをネットワーク内に公開するだけで良いです。

auth_requestをするときは変数に注意

3 時間くらいハマったのでメモしておきます。

ハマった状況

以下のようなコードを書いたら、変数が上書きされてうまくいきませんでした。

location /private {
    auth_request /auth/is_logged_in;

    resolver 127.0.0.11;
    set $upstream_server private;
    proxy_pass http://$upstream_server;
}
location = /auth/is_logged_in {
    resolver 127.0.0.11;
    set $upstream_server auth_server;
    proxy_pass http://$upstream_server;
    internal;
}

上の設定では、/private の proxy_pass が/auth/is_logged_in のステータスがいくつかに関わらず常にhttp://privateではなくhttp://auth_serverとなります。なんということでしょう…Nginx の変数の仕様は複雑怪奇ですね。

解決策

そもそも同じ変数名にしなければ良いです。

location /private {
    auth_request /auth/is_logged_in;

    resolver 127.0.0.11;
    set $private_server private;
    proxy_pass http://$private_server;
}
location = /auth/is_logged_in {
    resolver 127.0.0.11;
    set $auth_server auth_server;
    proxy_pass http://$auth_server;
    internal;
}

定数を定義できればこんな問題も起きなかったはずです。定数がほしいですね。

というか、評価順が直感的でなくて厄介です。動作から推察するに、おそらく次のような評価順です。

  1. /private をすべて評価
  2. $upstream_server が private になる
  3. /auth/is_logged_in を評価
  4. $upstream_server が auth_server になる
  5. ログインが確認できたら/private の proxy_pass を見てリバースプロキシ

直感的には「auth_requestが評価されたらただちにログイン判定」になりそうなものですが。なんじゃこりゃ…

dependencies と devDependencies の使い分け

混乱しがちだったのでまとめました。

なぜ使い分けたいのか?

ウェブアプリケーション開発においては、本番環境で不要なパッケージをインストールせずに済むからです。npm には dependencies に書かれたパッケージのみインストールする機能があるので、これを使ってインストール時間を短くできます。

また、本番環境に不要なものを置くのはセキュリティの観点から良くありません。

ライブラリ開発の場合は異なる

ライブラリ開発ではウェブアプリケーション開発と比べて、devDependencies の意味合いがやや異なります。devDependencies には、配布パッケージに含めるべきでないパッケージを書きます。ウェブアプリケーション開発ではビルド時間が縮むくらいの違いでした。しかし、ライブラリ開発では配布パッケージに含まれるか否かに関わるので、使い分けがとても重要です。

開発環境でしか使わないものは devDependencies

大抵、本番環境ではリポジトリをクローンしてきてビルドをするだけです。そのため、ESLint や Prettier は本番環境で使わないものは devDependencies に書きます。反対に、本番環境でも使いたいものは dependencies に書きます。

  • dependencies に書くパッケージ
    • ビルドに必要なパッケージ(webpack、TypeScript、Babelなど)
    • 使用するライブラリ・フレームワーク(React や Vue)
  • devDependencies に書くパッケージ
    • Linter・Formatter(ESLint、Prettier 関連のパッケージ)
    • テストフレームワーク(Jest など)

ただし、TypeScript には注意してください。TypeScript は build でも test でも使います。そのため、TypeScript関連のパッケージは一概に dependencies、devDependencies のどちらに含まれるか断定できません。一例をあげます。

  • @types/jset: devDependencies(テストでしか使わないため)
  • @types/react: dependencies(ビルド時に使うため)
  • typescript: dependencies(ビルド時に使うため)

さらに: 開発環境でデプロイする場合

実は、わざわざ本番環境でクローン&ビルドせずとも、開発環境でビルドしたものをそのまま本番環境へデプロイできます*1。この場合、そもそも本番環境でパッケージのインストールをしないので、そこまで使い分けを意識する必要はありません。

ただし、CI/CDをしている場合は注意が必要です。この場合は結局、本番環境でクローン&ビルドしているのと変わりません。そのため、上で紹介したように dependencies と devDependencies を使い分ける必要があります。

ライブラリ開発について

ライブラリ開発の場合は多少異なりますが、考え方としてはほぼ同じです。

  • devDependencies: 開発時に必要なもの(ビルドツール含む)
  • dependencies: publish 後に必要なもの(require しているパッケージ)

ビルド関連ツールも devDependencies に含まれる点が異なっています。

まとめ

まず、ウェブアプリケーション開発とライブラリ開発に大別されます。

ウェブアプリケーション開発

  • 開発環境でデプロイする場合:
    • 特に必要なし
  • CircleCI等でデプロイする場合、本番環境でクローン&ビルドする場合:
    dependencies
    ビルド時に使うもの
    devDependencies
    テスト時に使うもの、linter、formatter

ライブラリ開発

dependencies
配布物に含めるべきもの(require するパッケージなど)
devDependencies
開発時に使うもの(ビルド、テスト、linter、formatter)

*1:というより、本番環境でビルドせずに済むので、できることなら開発環境でデプロイできるようにするべきです