プログラミング初心者がアーキテクトっぽく語る

見苦しい記事も多数あるとは思いますが訂正しつつブログと共に成長していければと思います

nginxコンテナを自己署名証明書でHTTPS化する

前回、Dockerでnginxを入れて基本的なことを試して以下の記事を書きました。

architecting.hateblo.jp

このときHTTPS化も試していたのですが話が長くなるので別の記事にすることにしました。


はじめに

今やサービスの入り口は全てHTTPS化するのが常識となりました。外部公開しないサービスも同様です。開発環境もHTTPS化しています。今後、HTTPSを設定をする機会は増えてくるのでしっかりと理解しておきたいところです。

今回はブラウザとnginxの間をHTTPS化します。

nginxとWebアプリケーションとの間は引き続きhttpにします。暗号化/復号化処理はnginxが実行してWebアプリケーションはアプリケーションの実行に専念する構図です。

最近ではコンテナ間通信もHTTPS化する流れがありますが、Webアプリケーション側まで手を入れると時間がかかるので今回はやめました。


証明書をどうするか?

HTTPS化するには証明書が必要です。

証明書ををどうするか考えてみました。選択肢は以下の3つです。

  1. 自己署名証明書
  2. 自己署名認証局が署名した証明書
  3. 本物の認証局が署名した証明書

この記事では自己署名証明書を使ったケースを紹介します。

2の自己署名認証局を使ったケースは別の記事で紹介しています。

architecting.hateblo.jp

3も試す予定でしたが2でお腹いっぱいになったのでやめました。


自己署名証明書

通称、オレオレ証明書です。

本来、サーバ証明書の署名欄にはその証明書を発行したCAの署名が入ります。サーバ証明書とは第3者のCAが「私はこのサーバを調査し、その結果、このサーバが信頼に値することを保証します。」と宣言している保証書なのです。

CAの署名の代わりにサーバ自身の署名が入るのが自己署名証明書です。「オレぁはいいヤツだぜぇ!オレがそう云うんだからまちげぇねぇ!」と自らが喧伝しているわけです。そんなことする輩がまともなわけありません。出会ったら、すぐ逃げて下さい。

危険な自己署名証明書ですが、証明書が欲しいときには最も簡単に作成できます。opensslコマンド3発で完成です。お金はかかりません。CAによる実存性確認もありません。

お手軽な反面、ブラウザがセキュリティ警告をガンガン出すので保護されている気分に全くなれないのが大きな欠点です。また、フロントエンドのAPIが動かなくなることもあります。

結論を言うとこの方法はおすすめしません。簡易的なHTTPS環境を作りたい場合は2の自己署名認証局を使うことを勧めます。


nginxコンテナのTCP 443番を公開

それでは構築していきます。

これまで作業してきたnginxコンテナは下記の通りTCP 80番を公開してました。

docker run -it --name nginx -p 8080:80 nginx /bin/bash

HTTPSTCP 443番を使うので、このままではHTTPS化できません。

新しいnginxコンテナを起動します。このとき下記の通りTCP 443番を公開します。

docker run -it --name nginx -p 443:443 nginx /bin/bash

秘密鍵と証明書を作成する

自己署名証明書秘密鍵を作ります。

過去のPKIに関する記事を参考にnginxコンテナの中でopensslコマンドを3発、叩きます。

architecting.hateblo.jp

色々聞かれますが基本、空欄のままEnter押下で問題ありません。ただし秘密鍵作成のときに要求されるpass phraseの入力は必須です。pass phraseは後で使うので忘れないで下さい。

# openssl genrsa -aes192 -out mykey.key 4096
<略>
Enter pass phrase for mykey.key:

また、Certificate Signing Requestを作成するときに下記の通りCommon Nameで「localhost」と入れるのも重要です。もしnginxにちゃんとしたホスト名を割りあてている場合は「localhost」ではなくそのホスト名を入れて下さい。

Common Name (e.g. server FQDN or YOUR name) []:localhost

最初、これをうっかり忘れてフロントエンドのfetch APIが動かなくなりました。fetch APIはSubjectのCommon Name(CN)がURLと一致するかチェックしているようです。

opensslコマンドが作成したファイルです。mycert.crtが証明書、mkey.keyが秘密鍵です。mycsr.csrCSRですがここから先は出番はありません。

# ls /etc/nginx/conf.d/ssl/
mycert.crt  mycsr.csr  mykey.key

証明書をチェック

証明書の内容をチェックします。

SubjectのCNが「localhost」になっているのでOKです。

Issuer(発行者)もlocalhostなので自己署名証明書であることがわかります。

# openssl x509 -text -noout -in mycert.crt 
<略>
        Issuer: CN=localhost
<略>
     Subject: C = AU, ST = Some-State, O = Internet Widgits Pty Ltd, CN = localhost

nginxの設定変更

/etc/nginx/conf.d/default.conf以下のように変更します。

vimが入っていない場合は前回の記事を参考にvimをインストールして下さい。

前回からの変更点は443をlistenしていることと、証明書と秘密鍵を指定していることの3点のみです。

server {
    listen 443 ssl;  <<< 変更1
    server_name localhost;

    ssl_certificate /etc/nginx/conf.d/ssl/mycert.crt;  <<< 変更2
    ssl_certificate_key /etc/nginx/conf.d/ssl/mykey.key;  <<< 変更3

    access_log  /var/log/nginx/host.access.log  main;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    location /api {
        proxy_pass   http://host.docker.internal:5000;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

設定に問題がないかconfigtestしてからnginxを再起動します。 このとき秘密鍵を生成するときに入力したpass phraseを要求されます。

/etc/init.d/nginx configtest
/etc/init.d/nginx restart

これでnginxは準備完了です。


フロントエンド修正

前回に続きTCP 5000番で待っている適当なWebアプリケーションをローカルPCで立ち上げています。

HTTP環境で作成したのでフロントエンドのfetch APIのアクセス先がhttpです。

const request_url = "http://localhost:8080/api";

これではフロントエンドがデータを取得できません。httpsに変更します。

const request_url = "https://localhost/api";

これでnginxもフロントエンドもHTTPSに対応しました。最後に動作確認をしましょう。


動作確認

https://localhostへアクセスします。

chromeの場合、以下のようにERR_CERT_AUTHORITY_INVALIDエラーが表示されます。

f:id:hogehoge666:20210116021719p:plain

「詳細設定」→「localhostにアクセスする(安全ではありません)」をクリックします。

無事、情報が表示されました。ChromeもフロントエンドのJavaScriptも問題なく動いてます。

ただしロケーションバーには「保護されていない通信」と表示されています。「https」には取り消し線が入ってます。

f:id:hogehoge666:20210116022016p:plain

HTTPSを導入したはずですが保護されている感じがしません。

保護されていない通信」をクリックすると証明書が「無効」となっています。

f:id:hogehoge666:20210116022118p:plain

「無効」をクリックするとオレオレ証明書であることがわかります。

f:id:hogehoge666:20210116022225p:plain


結論

短時間でHTTPS化することができました。その感想としては「これでいいの?」です。

外部公開するサービスでやるのは当然論外です。

しかし外部公開しない内輪向けのサービスだとしても信頼できない証明書を許容するようなことをユーザにさせていいのでしょうか?ユーザのセキュリティ感覚を麻痺させて危険にさらしていないでしょうか?

検証用だとしてもオレオレ証明書で動いていたフロントエンドが本番のサーバ証明書で動くでしょうか?安全なAPIに無理やりオレオレ証明書を許容させるようなコードや設定をしたときその検証に意味はあるのでしょうか?

次の記事では「自己署名認証局が署名した証明書」を紹介します。

<環境構築不要>お手軽にKubernetesを試す

Kubernetesの公式Tutorial環境で自作コンテナのデプロイを試した話です。


動機と結果まとめ

勉強のために自作の超簡単サービスをDockerやAWSに簡易的にデプロイしてみました。

k8sでも試したいと思ったのですが環境を用意するほどの気力と余裕がありません。

ひょっとしたらk8sの公式Tutorial環境でできのではないか?と思ったのがきっかけです。

manifestファイルを使わずにrun/expose/scale/rolloutのみで実施しました。

結果として、環境固有の制約はありましたが一応デプロイできました。

なお今回、デプロイしたコンテナは1つだけです。複数コンテナで構成されるサービスもできると思いますがmanifestファイルなしでやるのは厳しそうだし、雰囲気だけわかれば十分なのでやめました。


事前準備

Dockerhubに自作コンテナをpushしておきます。

ローリングアップデートを試したい場合はv1、v2などタグを2つ用意してくださいい。

私はなにかのTutorialで使ったNode.jsのコンテナを利用しました。

自作コンテナがない方はDockerhub上のnginx:<適当なタグ>とかでもよいと思います。

DockerhubへのPushはここで紹介しています。

architecting.hateblo.jp


k8sの公式Tutorial

ここです。

kubernetes.io


環境を立ち上げる

上記サイトにアクセスして適当なTutorialを選んで下さい。

Tutorialは複数ありますがどれでもOKです。

強いて勧めるなら「Kubernetesの基本を学ぶ」の「アプリケーションのデプロイ」が無難だと思います。minikubeが起動済みで、deploymentやserviceが事前に作成されてないのですぐに自作コンテナを立ち上げる作業に着手できます。

一番最初の「Hello Minikube」だけUIが特殊で使いづらいのでやめた方がよいです。

各Tutorialはテキストと「対話型チュートリアル」で構成されています。テキストは無視して「対話型チュートリアル」を選択してください。ブラウザにk8s環境が表示されます。


確認

以下のコマンドを実行してゴミがないか確認し、あれば削除します。なお「アプリケーションのデプロイ」を選んだ場合はゴミはないはずです。

kubectl get deploy
kubectl get pods
kubectl get svc

「kubectl get deploy」と「kubectl get pods」は空ならOKです。

「kubectl get svc」はデフォルトのkubernetes serviceのみ表示されればOKです。


自作コンテナのPodを立ち上げる

kubectl run my-deploy --image=myacount/myapp:v1 --port 3000

イメージ名やポート番号は自分の用意したコンテナに合わせて変更して下さい。

警告が出ることもありますが問題ありません。

Dockerhubからmyaccount/myapp:v1を取得してPodを起動します。

PodのTCPポート3000番がコンテナの3000番とマップされます。

PodだけでなくdeploymentとreplicaSetも同時に作成されます。

kubectl get deploy
kubectl get pods
kubectl describe pods

作成したPodを外部に公開する

この時点ではPodはk8sクラスタ内のみからアクセス可能な状態です。serviceを作成して外部からアクセスできるようにします。

本番環境のようにtype="LoadBalancer"を使いたいところですがminikubeではサポートされていないためNodePortで我慢します。

kubectl expose deployment/my-deploy --type="NodePort" --port 3000

ポート番号は自分の用意したコンテナに合わせて変更して下さい。

新しいserviceが作成されます。

kubectl get svc
kubectl desribe svc

「kubectl get svc」の「PORT(S)」欄に「3000:32215/TCP」みたいな表示があります。「32215」が外部に公開されているポート番号です。このポート番号はランダムに変わります。

minikubeのこのポート番号にブラウザでアクセスしたいところですが、残念ながらそれはできません。仕方ないのでブラウザ内のターミナルでcurlコマンドを実行して動作確認をします。

curl $(minikube ip):32215

期待したレスポンスがあれば成功です。

スケールする

Podを4台に増やします。

kubectl scale deployment/my-deploy --replicas=4

確認します。

kubectl get rs
kubectl get deploy
kubectl get pods

減らすこともできます。

kubectl scale deployment/my-deploy --replicas=2

ローリングアップデート

イメージをv1からv2にアップデートします。

kubectl set image deployments/my-deploy my-deploy=myaccount/myapp:v2

v2を使う新しいPodが順次作成され、古いPodは順次削除されます。

kubectl describe pods
kubectl rollout status deployments/my-deploy

うまく行けば「kubectl describe pods」で見えるimage名が変わってるはずです。

ロールバックもできます。

kubectl rollout undo deployments/my-deploy

削除

終了する場合は削除しなくても大丈夫です。

やり直したい場合はdeploymentとserviceを削除して下さい。

kubectl delete deploy my-deploy
kubectl delete service my-deploy

これでPodやreplicaSetも削除されます。


トラブルシューティング

よくある間違いがkubectl runでイメージ名を間違えることですが、これが気づきにくいです。

kubectl runコマンド自体は正常に終了します。

kubectl get deployで確認してもPodの立ち上げに時間がかかっているようにしか見えません。

kubectl get podsを確認するとコンテナの作成に失敗したことがわかります。

kubectl describe podsでログを確認するとイメージの取得に失敗したことがわかります。

deployを削除してkubectl runを再度実施しましょう。

docker-composeを使ってみる

dockerしか使ったことない人間がdocker-composeを使います。

動機

Flaskアプリケーション作成のTutorialをやったらコンテナ化したくなりました。

コンテナ化したら今度はnginxコンテナやMySQLコンテナと連携させたくなりました。

連携させたら複数のコンテナをビルド/実行/停止/削除する作業があまりに面倒でdocker-composeに行き着きました。


dockerとの比較

dockerは1つのコンテナを扱います。だからコンテナの数だけdockerコマンドを打つ必要があります。私のケースだとnginxコンテナ、Flaskコンテナ、MySQLコンテナに対して合計3回のdockerコマンドを打ちます。1回のビルド/実行/停止/削除には合計12回コマンド投入が必要です。

加えてコマンドのオプションが多いので1つ1つのコマンドが長いです。何回もビルド/実行/停止/削除を繰り返していると疲弊します。

docker-composeは複数のコンテナを束ねて1つのserviceとして扱います。複数のコンテナをdocker-composeコマンドを1つで操作できます。1回のビルド/実行/停止/削除に必要なコマンド投入回数は4です。

またオプションはYAMLファイルに記載するので1つ1つのコマンドが短いです。それでも何回もビルド/実行/停止/削除を繰り返していると疲弊しますがdockerで戦うより遥かに楽です。


Kubernetesとの比較

これまで「コンテナを組み合わせたらKubernetes」と安直に考えていて、docker-composeの立ち位置がわかりませんでした。ですが今回その有難みを痛感しました。

昨今の開発、デプロイでは小さなアプリケーションでも複数コンテナになります。小さなアプリケーションに対してKubernetesは大きすぎます。

Kubernetesは最大の拡張性と可用性をアプリケーションに提供することを念頭に作られたモンスターです。Kubernetesのパワーを必要としないアプリケーションは沢山あります。そんなときにはdocker-composeの方が便利です。

大規模なシステムをdocker-composeで運用することも可能です。しかし高い拡張性と可用性が求められる場面ではKubernetesの方が構築も運用も楽になるでしょう。


docker-compose.yml

YAMLファイルにサービスを定義します。

nginxコンテナ、Flaskコンテナ、MySQLコンテナで構成されるサービスの記述例です。

version: "3"
services:
  app:
    image: app
    build: ./app
    environment:
      - APP_ENV=docker
    working_dir: /app
    command: ["./wait-for-it.sh", "mydb:3306", "--", "python3", "app.py"]
  proxy:
    image: proxy
    build: ./proxy
    ports:
      - "8080:80"
    depends_on:
      - app
  db:
    image: mysql:latest
    environment:
      - MYSQL_ROOT_PASSWORD=mysecret
      - MYSQL_DATABASE=books
    volumes:
      - mydb-volume:/var/lib/mysql
volumes:
  mydb-volume:
    driver: local
  • version:docker-compose.ymlの仕様版数。本日時点で3.8が最新。
  • services:この中に複数のコンテナを定義する。
  • app、proxy、db:サービス名。各コンテナ定義の箱。
  • build:指定されたフォルダのDockerfileでビルドする。
  • depends_on:起動順序を指定。

残りはdockerの基本的なコマンドがわかればイメージできると思います。


ビルド

docker-compose build

キャッシュを使わない場合は--no-cacheオプションを付けます。キャッシュを使わないでビルドすると過去のイメージがdangling(tagなし)状態になるのでdocker image pruneで掃除しましょう。


実行

docker-compose up -d

ログ確認

docker-compose logs -f

サービス名を指定すればそのサービスのログのみ表示できます。


コンテナに接続

docker-compose exec -it <コンテナ名> /bin/<シェル>

シェルはベースがalpine系ならshash、buster等debian系ならbashです。


終了

docker-compose down

コンテナを終了、削除します。networkも削除します。imageとvolumeは残ります。

-vオプションをつけるとvolumeも削除します。


コンテナ間通信

上記のdocker-compose.ymlのようになにもnetworkを指定しないと自動的にbridge networkが作成されて全てのコンテナが接続されます。

各コンテナのサービス名(app、proxy、db)がnetwork-aliasとして自動登録されるので、コンテナ同士はサービス名で通信することができます。network-aliasはdocker container inspectで確認できます。

実験したところコンテナ名でも名前解決できました。よってコンテナ名で通信することもできます。ただしdocker container inspectで確認してもnetwork-aliasとして登録されていません。不思議です。

docker-compose downするとネットワークも削除されます。


コンテナ名

docker-composeを利用するとコンテナ名は自動で<プロジェクト名>_<サービス名>_<連番>になります。

例:docker-compose-study_app_1

環境変数COMPOSE_PROJECT_NAMEを変更するとプロジェクト名部分を変更できます。

名前全体を明示的に変更したい場合はcontainer_nameを使用します。


volume名

同様にvolume名も自動で<プロジェクト名>_<volume名>_<連番>になります。

nameで変更することができます。

name: my-volume

起動順序の管理

順序性は発生しないのがベストですが現実としてはコンテナを起動する順序は重要です。

nginxコンテナとFlaskコンテナの順序性

nginxは起動時にproxy_passで設定されたコンテナ名(proxy_pass https://myapp:5000の「myapp」)が名前解決できるか確認します。その前にFlaskコンテナが起動していないとコンテナ名で名前解決ができずnginxコンテナが終了してしまいます。

FlaskコンテナとMySQLコンテナの順序性

Flaskコンテナは起動するとDBの初期化(db.create_all())を実行します。このときMySQLサービスが利用不可能だとエラーが発生してFlaskコンテナは終了してしまいます。

今回、期待する起動順序

このため今回のサービスは以下の順序で起動させる必要があります。

MySQLコンテナ起動 → MySQLサービスアップ → Flaskコンテナ起動 → nginxコンテナ起動

depends_on

コンテナの起動順序を制御する仕組みとしてdocker-composeにはdepends_onがあります。depends_onは起動を開始する順序を保証してくれます。

下記のようにnginxコンテナをFlaskコンテナにdepends_onすればnginxコンテナをFlaskコンテナより先に起動してくれます。

  proxy:
    <省略>
    depends_on:
      - app

nginxコンテナが名前解決を始めた頃にはFlaskコンテナの名前は名前解決可能な状態になっています。これでnginxコンテナとFlaskコンテナの順序性の課題は解決されます。

残念ながらdepends_onは起動が完了する順序は保証してくれません。MySQLコンテナを先に起動してもMySQLのサービスが立ち上がる前にFlaskコンテナがDB初期化処理を開始してしまう可能性は残ります。

実際に試したところやはりMySQLサービスが起動完了する前にFlaskコンテナあDB初期化処理を開始してしまいFlaskコンテナは終了してしまいました。nginxコンテナとFlaskコンテナの順序性の課題はdepeds_onでは解決できません。

MySQLサービスの起動を待つ方法

docker-composeの仕組みでは難しく他のツールを活用します。dockerizeやwait-for-it.shが有名です。ここでは導入が容易なwait-for-it.shを紹介します。

docker-compose.ymlに以下のように記述すればwait-for-it.shがmydbの状態を監視してmydbが利用可能になった後に「python3 app.py」を実行します。

command: ["./wait-for-it.sh", "mydb:3306", "--", "python3", "app.py"]

wait-for-it.shはGitで公開されています。

https://github.com/vishnubob/wait-for-it

wait-for-it.shはbashが必要です。Flaskコンテナのベースがalpine(ash系)だと苦労します。当初、私はalpineベースのFlaskコンテナを利用していましたが、bashをインストールするとpipやapk addの動きがおかしくなったのでslim-busterベースに替えました。alpineベースのコンテナもMySQL対応で肥大化していたのでslim-busterに変えてもコンテナサイズは変わりませんでした。

DockerでMySQLを試してみる

先日、nginxのDockerを試しました。その勢いでデータベースのDockerも試してみます。

まずは一番有名なMySQLからです。MySQLは大昔に触ったことがありますが、Dockerでセットアップするのは始めてです。Docker固有のポイントがあると思うので勉強していきたいと思います。


コンテナ実行

docker run -d --name mydb --env MYSQL_ROOT_PASSWORD=mysecret mysql

MYSQL_ROOT_PASSWORDは必須の環境変数です。Rootのパスワードを指定します。

タグを指定していないのでmyslq:latestが使われます。myslq:latestのDockerfileを覗いてみるとdebian:buster-slimでMySQLのバージョンは8.0だそうです。


コンテナに接続

docker exec -it mydb /bin/bash

Dockerfileを確認したらENTRYPOINTを使っているので「docker run -it mysql /bin/bash」のようにしてシェルを起動することはできません。大人しくdocker execでコンテナに接続します。

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["mysqld"]

MySQLの起動に使われている/usr/local/bin/docker-entrypoint.shはかなり手の混んだシェルスクリプトで私には読めませんでした。


MySQLにログイン

mysql -u root -p

パスワードを要求されたらMYSQL_ROOT_PASSWORDで指定した「mysecret」を入力します。


データベース作成

create database test;
show databases;
connect test;

当たり前ですがmysqlの中に入ると普通のMySQLと同じです。


テーブル作成

create table books ( id int auto_increment not null primary key, title varchar(64) not null, in_stock boolean not null default 0);
show tables;
show create table books;

以下のようなテーブルを作成しました。

  • idは自動採番
  • titleは必須。型は文字列。
  • in_stockは真偽値でデフォルトはFalse。真偽値は内部ではtiny_intという数値。

エントリ作成

insert into books (title) values ("Docker 101");
insert into books (title, in_stock) values ("Introduction to MySQL", true);
select * from books;

コンテナが消えるとデータも消える

exit
docker stop mydb
docker start mydb
docker exec -it mydb /bin/bash
mysql -u root -p
<データはまだある>

コンテナの停止、開始ではデータは消えません。

exit
docker stop mydb
docker rm mydb
docker run -d --name mydb --env MYSQL_ROOT_PASSWORD=mysecret mysql
docker exec -it mydb /bin/bash
mysql -u root -p
<データはない>

コンテナを削除、再作成するとデータはありません。データを維持するにはvolumeを使います。


volume

volumeにはnamed volumeとbind mountがあります。bind mountは開発用、named volumeは本番用という気がするのでnamed volumeを使います。

docker volume create mydb-volume
docker volume ls
docker run -d -v mydb-volume:/var/lib/mysql --name mydb --env MYSQL_ROOT_PASSWORD=mysecret mysql

こうすればコンテナを削除、再作成してもデータは消えません。


終了

docker stop mydb

掃除

docker rm mydb
docker rmi mysql:latest
docker volume prune

docker volume pruneで未使用なvolumeを一括削除できます。


おまけ:Flaskから利用する

Flask_SQLAlchemy + sqlite3を使うFlaskアプリのTutorialは溢れていますがMySQLを使うFlaskアプリは少ない印象です。そこでFlask_SQLAlchemy + sqlite3構成をFlask_SQLAlchemy + MySQL構成に置き換えてみます。

Flask_SQLAlchemy + sqlite3のFlaskアプリがある前提です。

まずSQLALCHEMY_DATABASE_URIを書き換えます。

SQLALCHEMY_DATABASE_URI = 'mysql://root:mysecret@myapp-db/test'

MySQLではデータベース名に「.db」を付けません。sqlite3と違って「@myapp-db/test.db」とするとエラーになります。

これだけで済むかと思ったらもう少し手間が必要でした。

次にMySQLdbがないと怒られました。所謂「DBAPI」です。調べるとMySQLdbよりPyMySQLの方がよいそうなのでPyMySQLを入れます。

pipenv install PyMySQL

MySQL 8からデフォルトの認証方式が変更になりcryptographyがないと怒られます。入れます。

pipenv install cryptography

データベースがないと怒られます。sqlite3だとdb.create_all()で自動でデータベースが作成されますがMySQLでは予め作成しておく必要があります。

create database test;

後はいつもの初期化のおまじないです。

export FLASK_APP=myapp
flask shell
from myapp import db
db.create_all()
exit()

これでテーブルが作成されました。

ここから先はsqlite3と同じコードで動きました。

コードの変更はSQLALCHEMY_DATABASE_URIだけで済みましたがライブラリやDB側のセットアップ作業に少し追加作業が必要でした。

dockerでnginxを試してみる

nginxをよく見かけます。

1度くらい触ってみようと思って数年。ようやく重い腰を上げたいと思います。

とりあえず試して削除したいときに便利なのがdockerです。

dockerを利用してローカルPCで基本機能を試してみました。


コンテナ実行

docker run -it --name nginx -p 8080:80 nginx /bin/bash

ローカルにnginxのイメージがなければDockerhubから自動で取得して実行してくれます。

本来のnginxコンテナの使い方としては末尾の「/bin/bash」は不要ですが、今回は中に入って色々いじってみたいのでこのようにしました。

実行するとコンテナの中に自動で入ります。

タグを指定していないのでnginx:latestが使われます。nginx:latestのベースはdebian:buster-slimですのでコンテナの中ではdebian系のコマンドを使う必要があります。


nginx実行

docker runでコマンドを指定(/bin/bash)したので、デフォルトの「CMD ["nginx", "-g", "daemon off;"]」が上書きされしまい、nginxが起動していません。

手動で起動します。

/etc/init.d/nginx start
/etc/init.d/nginx status

確認

ブラウザでhttp://localhost:8080にアクセスしてWelcomeページが表示されることを確認します。

f:id:hogehoge666:20210115152621p:plain


設定確認

基本的な設定を確認します。

more /etc/nginx/nginx.conf

nginx.confのhttpディレクティブに以下の行があります。具体的なWebサイト関連の設定については/etc/nginx/conf.d/配下のconfファイルを参照する設定です。

include /etc/nginx/conf.d/*.conf;

/etc/nginx/conf.d/を見に行くとdefault.confがあります。

default.confを確認すると以下の行があります。/usr/share/nginx/htmlにあるファイルをクライアントに返す設定です。

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

Welcomeページの変更

/usr/share/nginx/html/index.htmlを編集してWelcomeページを変更しましょう。

buster-slimは軽量なのでエディタがありません。viをインストールします。

apt-get update
apt-get install vim

index.htmlを適当に編集します。

vi /usr/share/nginx/html/index.html

ブラウザを再読み込みしてWelcomeページが変更されたことを確認します。


リバースプロキシ

nginxといえばリバースプロキシとして利用されることが多いです。リバースプロキシを試します。

default.confを変更します。localhost:8080/へのアクセスをGoogleに転送する設定です。非現実的な構成ですが手っ取り早いです。

    location / {
        proxy_pass   http://www.google.com;
    }

設定ファイルのValidationをしてからnginxを再起動します。

/etc/init.d/nginx configtest
/etc/init.d/nginx restart

ブラウザを再読み込みしてGoogleが表示されることを確認します。


静的ファイル配信

APIコールはWebアプリケーションに転送して、静的ファイルはnginxが配信するというのもよく見る構成です。

TCP 5000番で待っている適当なWebアプリケーションをローカルPCで立ち上げます。

default.confを以下のように変更してnginxを再起動します。

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    location /api {
        proxy_pass   http://host.docker.internal:5000;
    }

localhost:8080にアクセスするとnginxが静的ファイルを返します。html、js、cssファイルやfavconなどを返す想定です。

localhost:8080/api配下へのAPIコールはTCP 5000番で待っているアプリケーションに転送されます。

「host.docker.internal」はdockerを実行しているローカルPCを表しています。Docker for Mac固有のようです。これでローカルPCの5000番で待機しているアプリケーションに転送されます。誤って「localhost」と記載するとコンテナ自身を表すのでnginxコンテナの5000番に転送してエラーになります。

「proxy_pass http://host.docker.internal:5000」の後ろに/をつけると厄介なことになります。このリンクに説明があります。

https://www.xmisao.com/2014/05/09/nginx-proxy-pass.html

アプリケーションもコンテナの場合

本番環境ではアプリケーションもコンテナ化してDockerで起動することでしょう。その場合はコンテナ同士はコンテナ名やnetwork-aliasで通信できます。

よって本番ではproxy_passディレクティブは以下のようにアプリケーションコンテナの名前(myapp等)にします。アプリケーションは5000番を外部にEXPOSEする必要はありません。

    location /api {
        proxy_pass   http://myapp:5000;
    }

コンテナの起動順序

nginxは起動時にupstream(上の例のmyapp)の名前解決を行っているようです。名前解決できないと「host not found in upstream 'myapp'」のようなエラーを出して起動に失敗します。

これはdocker-composeを利用するときに注意が必要です。myappコンテナの起動がmydbコンテナの起動待ちで遅れるとnginxが起動できなくなります。depends_onなどで起動順序を制御しましょう。

try_filesディレクティブ

try_filesディレクティブを使う場合はこうなります。

    location / {
        root   /usr/share/nginx/html;
        try_files  $uri $uri/ @webapp;
    }

    location @webapp {
        proxy_pass   http://host.docker.internal:5000;
    }
  • まず指定されたURIと同名のファイルを探します。
  • なければ指定されたURIと同じディレクトリを探します。
  • なければ@webappを使ってプロキシされます。

私が用意したアプリケーションは静的ファイルを持っていません。try_filesだとアプリケーションに不要な静的ファイル要求が多く飛ぶ気がするのでやめました。


プロキシヘッダの付与

アプリケーションのアクセスログを見ると要求を送信したクライアントではなくnginxのIPアドレスからアクセスがあったように見えます。nginxがパケットの送信元アドレスを書き換えてしまうからです。

本当に見たいのは要求を送信したクライアントのアドレスです。default.confを以下のように変更してnginxを再起動します。

    location /api {
        proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass   http://host.docker.internal:5000;
    }

nginxはパケットを書き換えるときにX-Forwarded-ForヘッダにクライアントPCのアドレスを追記します。多くのアプリケーションはX-Forwarded-Forヘッダがある場合はアクセスログにX-Forwarded-Forヘッダの値を出力します。これでアクセスログにクライアントのアドレスが出力されるようになります。

Macではまった

上記の設定でDocker for Mac(Docker version 20.10.0, build 7287ab3)ではうまく動いてくれずdocker bridgeのアドレスが出力されました。悩んだ末、Centosで試したら期待通りに動きました。バージョン20.10.0の不具合かDocker for Mac固有の問題かはわかりません。

bridgeがだめなら、と試したhost networkが動かなかったり、Mac上でのDockerは落とし穴が多いです。

その他のヘッダ情報の転送

アドレスだけでなくホスト名やスキームなど諸々の情報もアプリケーションへ転送したい場合は下記のようにします。

    location /api {
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-Host $host;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_pass   http://host.docker.internal:5000;
    }

Basic認証

認証をかけてみましょう。

さくっと試せるBasic認証を使います。

htpasswdコマンドを利用するためにパッケージを追加します。

apt-get install apache2-utils

ログインIDとパスワードを登録します。

htpasswd -c /etc/nginx/.htpasswd hoge
  New password: hoge
  Re-type new password:hoge 
  Adding password for user hoge

default.confのlocation /とlocation /apiに下記の2行を追加してngnxを再起動します。

auth_basic   "Login Required.";
auth_basic_user_file   /etc/nginx/.htpasswd;

ブラウザでlocalhost:8080/にアクセスするとログイン画面が表示されるようになります。ログインするとコンテンツが表示されます。

Postmanを使って認証なしでlocalhost:8080/apiをGETすると「401 Authorization Required」が返ってきます。Basic認証付きでGETすると値を取得できました。


HTTPS

長くなるので別の記事として投稿します。


ログ

default.conf内の下記行のコメントアウトを削除してnginxを再起動します。

    access_log  /var/log/nginx/host.access.log  main;

/var/log/nginx/host.access.logにアクセス情報が記録されます。


終了

exitするとnginxコンテナも終了します。

exit

掃除

終了したnginxコンテナを削除します。

docker rm nginx

nginxのDockerイメージを削除します。後でまた試したいなら残しておいてもいいです。

docker rmi nginx:latest

DockerhubへのイメージのPush

Dockerhubにアカウントを作成すると自分でBuidしたDockerイメージをリポジトリにpushすることができます。リポジトリのイメージはdocker pullで取得することができます。

Dockerhubは利用するけどアカウントは持っていない、という人は少なくないと思います。誰かが作った便利なイメージを取得するためだけにDockerhubを利用している人も多いのではないでしょうか。

それもよいのですが、リポジトリを持つことにもメリットがあります。まず自分のコンテナを世界中の様々な環境に瞬時に展開できるようになります。データセンタでもAWSでも同僚のPCでも一瞬です。大変、便利です。またコンテナ全盛の昨今、コンテナを公開するメリットや作成上の注意点を実感する意味でもよい勉強になります。

今回はDockerイメージをDockerhubにPushする方法を紹介します。


1. Dockerイメージを作成する

docker image build -t <イメージ名>:<タグ> .
docker image ls

タグ名はlatestとすることが多いです。


2. 無駄がないか確認(任意)

docker image history <イメージ名>

イメージの各レイヤの情報が表示されます。どのレイヤがどの程度、ディスクを消費するかも確認できます。無駄なレイヤ、順序性が悪い箇所があればDockerfileを修正して再度、ビルドします。


3. スキャン(任意)

docker scan <イメージ名>

脆弱性の有無を確認します。

イメージをDockerhubにアップするということはそのイメージは他人に見られたり、利用されたりする可能性があります。作成者として安全性を確認しましょう。


4. イメージにDockerhub用のタグを付与する

docker image tag <image id> <アカウント名>/<リポジトリ名>:<タグ名>

このタグ名はlatestとすることが多いです。


5. Dockerhubのアカウントへログインする

docker login

6. Pushする

docker image push <アカウント名>/<リポジトリ名>:<タグ名>

docker searchに反映されなくても心配はいりません。問題なくdocker pullできます。

TDDで役立つIntelliJ IDEA機能

cleancodersの動画を見ていて気がついた、TDDに便利なIntelliJ IDEAの機能を5つご紹介します。


1. Live Template

TDDでは沢山のテストメソッドを書きます。毎回、「public void test ...」なんてタイプするのは面倒です。

そんなときに便利なのがLive Templateです。

例えば「test」と入力して「tab」を押します。

f:id:hogehoge666:20210105224304p:plain

するとテストメソッドの雛形に変換されます。

f:id:hogehoge666:20210105224319p:plain

「test」はデフォルトでは登録されていないので自分でIDEに登録する必要があります。

Preferences / Editor / Live Templates / otherで「+」ボタンを押します。下の画像を参考に必要な情報を入力してください。個人的にハマったのは一番下の「Applicable in」を選択し忘れたことです。

f:id:hogehoge666:20210105224335p:plain


2. Show Context Actions

TDDでは存在しないクラスやメソッドをテストクラスで利用します。

例えば下のテストメソッドの例ではMyClassクラスがないのでIDEが赤字で警告を発しています。

f:id:hogehoge666:20210105224419p:plain

MyClassを作成してMyClass#executeを実装する必要があります。このとき便利なのがShow Context Actionsです。

警告が出ているMyClassにカーソルを合わせてoption + Enterと押すとエラーを解消するための選択肢が表示されます。「Create class 'MyClass'」を選べば簡単にMyClassクラスが作成できます。

f:id:hogehoge666:20210105224638p:plain

同様に、executeメソッドにカーソルを合わせてoption + Enterから「Create method 'execute' in 'MyClass'」を選択すればMyClassクラスにexecuteメソッドのひな形を作ってくれます。

f:id:hogehoge666:20210105224705p:plain

勿論、普通にMyClass.javaファイルを作成してMyClassクラスとexecuteメソッドを書くこともできます。でも書いたテストメソッドから直接、option + Enterで次の作業に移動する方がTDDとしてリズムがよいと感じます。


3. テストの再実行

TDDでは頻繁にテストを実行します。その度にマウス操作していてはリズムが悪くなります。

Ctrl + rで前回実行したテストを再実行できます。


4. Introduce Field

複数のテストを記述していく中でテスト間で重複処理が発生します。こうした部分はsetUpなどに移して重複を排除します。

TDDでは先にRefactorしてはいけないので重複が発生するとわかっていても最初からsetUpを書くことはできません。このため発生した重複をsetUpへ移す作業は頻繁に発生します。

変数をフィールドにしてsetUpに移すときに便利なのが「Introduce Field」。option + Command + Fです。

f:id:hogehoge666:20210325114938p:plain

右クリック、Refactorからもできますが頻繁に発生するのでショートカットの方が断然、効率がよいです。


5. Run with Coverage

ある程度、実装が進むと気になるのがCoverageです。テストクラスを右クリックして「Run <テストクラス> with Coverage」を選択することで簡単に確認することができます。

f:id:hogehoge666:20210105230610p:plain

テストが完了するとCoverageが以下のようにクラス名の隣に表示されます。StackクラスはInterfaceなのでCoverageが表示されません。BoundStackクラスのCoverageは低いですね。

f:id:hogehoge666:20210105224926p:plain

カバーされなかったコードを確認してみましょう。エディタでBoundStackクラスを開くとカバーされた行は緑、カバーされなかった行は赤が表示されます。

f:id:hogehoge666:20210105225037p:plain

リファクタリングで作成したNull Objectのテストに漏れがあったことがわかりました。