20200526のdockerに関する記事は24件です。

Dockerで不要なイメージやコンテナを一括削除する方法

Dockerで整理をしていないと不要なイメージやコンテナがどんどん溜まってきますよね。

無駄なディスクスペースを削除しようと思い、調べました。

Dockerで不要なイメージやコンテナを一括削除するには?

$ docker system prune [オプション]

のコマンドを使います。

使用していないリソースを全て削除する

全て削除するには、以下のコマンドを実行します。

$ docker system prune -a

強制的に削除するには。-fオプションをつけるた削除できます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DockerコンテナをもとにDockerイメージを作成に関するコマンドまとめ

Dockerコンテナは、Dockerイメージをもとに作成することもできます。

公式のイメージをもとに、コンテナを作成、環境に合うように設定変更したコンテナから再度イメージ作成することも、、。

コンテナからイメージ作成

$ docker container commit [オプション] コンテナ識別子 [イメージ名[:タグ名]]
オプション 説明
--author, -a 作成者を指定する
--message, -m メッセージを指定する
--change, -c コミット時のDockerfile命令指定
--pause,-p コンテナを一時停止してコミットする

コンテナをtarファイル出力

$ docker container export コンテナ識別子 

Dockerでは、動作しているコンテナのディレクトリ/ファイル群をまとめてtarファイルを作成することができます。

このtarファイルをもとにして、別のサーバでコンテナを稼働させることができます。

tarファイルからイメージ作成

$ docker container import ファイルまたはURL - [イメージ名[: タグ名]]
  • tar
  • tar.gz
  • tgz
  • bzip
  • tar.xz
  • txz

ファイルであれば指定可能

イメージの保存

$ docker image save [オプション] 保存ファイル名 [イメージ名]

Dockerイメージをtarファイルに保存することができます。
-o オプションで、保存するファイル名を指定できます。

イメージの読み込み

$ docker image load [オプション] 

-iオプションで、読み込みファイル名を指定できます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

稼働中のDockerコンテナの操作コマンドのまとめ

本番で運用するときには、稼働中のDockerコンテナに対して操作をします。

その時に使えるコマンドをまとめます。

稼働コンテナへの接続

$ docker container attach コンテナ識別子

終了させる時は、Ctr+PかCtr+Qキーを入力します。

稼働コンテナへのプロセス実行

$ docker container exec [オプション] コンテナ識別子 実行するコマンド [引数]
オプション 説明
--detach, -d コマンドをバッググラウンドで実行する
--interactive, -i コンテナの標準入力を開く
--tty, -t false
--user, -u ユーザ名指定

docker container execコマンドは、起動中のコンテナにのみ実行できます。
停止中のコンテナは、docker container start コマンドを使ってコンテナを起動します。

稼働コンテナへのプロセス確認

$ docker container top コンテナ識別子

実行しているプロセスのPIDとUSERと実行しているコマンドが表示されます。

稼働コンテナのポート転送確認

$ docker container port コンテナ識別子

コンテナの名前変更

$ docker container rename

名前変更できたかを確認するには、
bash
$ docker container ls

をします。

コンテナ内のファイルをコピー

$ docker container cp コンテナ識別子:コンテナ内のファイルパス ホストのディレクトリパス
$ docker container cp ホストのファイル コンテナ識別子:コンテナ内のファイルパス

コンテナ操作の差分確認

$ docker container diff コンテナ識別子

変更の区分

区分 説明
A ファイル追加
D ファイル削除
C ファイル更新
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Dockerコンテナのネットワーク操作コマンドまとめ

Dockerコンテナ同士が通信するとき、Dockerネットワーク経由してます。

そのDcokerコンテナのネットワーク操作コマンドをまとめました。

ネットワークの一覧表示

$ docker network ls [オプション]
オプション 説明
--filter=[], -f 出力をフィルタする
--no-trunc 詳細を出力する
--quiet, -q ネットワークIDのみを表示する

Dockerはデフォルトでbridge/host/noneの3つのネットワークを作成します。

filterで利用できるキー

項目 説明
driver ドライバーの指定
id ネットワークID
label ネットワークに設定されたラベル
name ネットワーク名
scope ネットワークのスコープ
type ネットワークのタイプ

ネットワークの作成

$ docker network create [オプション] ネットワーク
オプション 説明
--driver, -d ネットワークブリッジまたはオーバレイ(デフォルトはbrige)
--ip-range コンテナに割り当てるIPアドレスのレンジを指定
--subnet サブネットをCIDR形式で指定
--ipv6 IPv6ネットワークを有効にするかどうか
-label ネットワークに設定するラベル

ネットワークへの接続

$ docker network connect [オプション] ネットワーク コンテナ
オプション 説明
--ip IPv4アドレス
--ip6 IPv6アドレス
--alias エイリアス名
--link 他のコンテナへのリンク

ネットワークからの切断

$ docker network disconnect [オプション] ネットワーク コンテナ

ネットワークの詳細確認

$ docker network inspect [オプション] ネットワーク

ネットワークの削除

$ docker network rm [オプション] ネットワーク
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Macのdockerが遅いストレスから解放されよう

Mackのdocker遅い問題

Macで開発している時、主にRailsなのですがdockerを使うと遅くなるのが嫌でずっと避けてましたが、とあるプロジェクトでBFF構成にし開発環境とデプロイ環境をECS化したりとでdocker化の流れに抗えない状況になりました。

ただ、やはり遅い。

dockerが不慣れなこともありますが、Macのdockerなかなか遅い。

そしてMacが遅い件を調べてみてもみなさん困られるのが伺えます。

そんな中で対策としてよく見かけるのが

  • docker-sync
  • vagrantで別立て

の2つでした。

どちらが最適なのかな?と思い悩んでると、

ありました。dockerのドキュメントにまさにこの件に対しての内容が。

https://docs.docker.com/docker-for-mac/osxfs-caching/

ボリュームマウントのチューニング

端的にまとめるとv17.04からcacheddelegatedというオプションが追加され、書き込み読み込みの一貫性を担保しない(※)代わりにパフォーマンスが向上されるものです。

※コンテナ側で発生した変更がホスト側に書き込みされるまで遅延を許容する。

即時性を求められたり大規模な処理を組むこともなく、開発環境だけで使うことから現状大きな支障はないと思いこちらのオプションを利用することにしました。

導入の仕方

  • volumesの指定の最後に:delegatedをつけていつも通りdocker-compose upを実行
services:
  app:
    volumes:
      - .:/project/sample_app:delegated

tips

linuxやwindowsユーザーの方と一緒に開発する場合は必要ないかと思うので、そんな時は通常使うdocker-compose.ymlに基本的な設定そのままにdocker-compose-local.ymlのように、別ファイル名のymlを用意します。

そこに例のようにservices:サービス名:vpluems:マウント先:に:delegateオプションを追加したものをのみを残し

docker-compose -f docker-compose.yml -f docker-compose-local.yml up

とオプション指定すればvolumesの指定のみ上書かれるのでMacユーザーの方はこちらを利用したら住み分けできて便利です。

※docker-compose.ymlにversion指定されていたら、versionの指定は合わせます。

改善の結果

ただのindexのレコード取得でも数秒、初回アクセスで10秒オーバーするケースがあったものが普通にサクサクと動くようになりました。

プロジェクトの内容や要件に合わせてですが、遅すぎてストレスが激しくdocker嫌いになる前に是非試してみてください。

まず困ったら公式ドキュメントをしっかり読むことの大事さを改めて実感する一件でした。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Windows10上のVirtualBoxで稼働するUbuntuにDockerをインストール

概要

Ubuntuで、pythonを実行できる環境を整備したいが、pythonは頻繁にバージョンアップするし、プログラムの目的ごとにpipで追加したいパッケージのバージョンが異なったりする。
venv(または、pyenv等)で切り替えることも検討したが、もういっそコンテナの方が便利かも・・・
ということで、Windows10上のVertualBoxで稼働するUbuntuにDockerをインストールする手順・・・

基本的には、以下に記載の手順通り。
「Install Docker Engine on Ubuntu」
https://docs.docker.com/engine/install/ubuntu/

Windows10のVirtualBoxインストール手順はこちら
VirtualBox

VirtualBoxにUbuntuをインストールする手順はこちら
Ubuntu

前準備

既存Dockerの確認

Ubuntuにログイン。
[アクティビティ]-[アプリケーションを表示する]を選択。
[端末]を起動し、以下のコマンドを実行し、Dockerがinstallされていないことを確認する。

apt list --installed | grep docker

001.png

もしインストールされていたら、先に削除しておくことをお勧め。

sudo apt remove docker docker-engine docker.io containerd runc

Dockerフォルダの確認

以下のコマンドを実行し、旧バージョンの残骸がないことを確認する。

ls /var/lib

002.png
dockerフォルダがないことを確認

もし存在していたら、削除しておく。

sudo rm -rf /var/lib/docker

リポジトリの設定

必要なコマンドの追加

以下のコマンドを実行し、リポジトリ追加のために必要なコマンドがインストールされているか確認する。

apt list --installed | grep apt-transport-https
apt list --installed | grep ca-certificates
apt list --installed | grep curl
apt list --installed | grep gnupg-agent
apt list --installed | grep software-properties-common

003.png
上記の結果を確認し、インストールされていないものをインストール。
(例では、ca-certificates、software-properties-commonはインストールされているのでインストール不要。)

sudo apt install apt-transport-https
sudo apt install ca-certificates
sudo apt install curl
sudo apt install gnupg-agent
sudo apt install software-properties-common

004.png
005.png
006.png

apt-keyの設定

以下のコマンドを実行。

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

確認

sudo apt-key fingerprint 0EBFCD88

007.png

リポジトリ追加

sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

008.png

インストール

以下のコマンドを実行。

sudo apt install docker-ce

101.png
途中確認ダイアログが表示されたら、yを入力し、[Enter]キーを押下。
102.png
インストール終了。

動作確認

以下のコマンドを実行。

sudo docker version

103.png
バージョン情報が正しく表示されていることを確認。

以下のコマンドを実行。

sudo docker run hello-world

104.png
正常にDockerが動作することを確認。

Docker実行権限の追加

一般ユーザエラー

デフォルトの状態では、rootユーザしかdockerを使用できない。

201.png

権限追加

一般ユーザでdockerを使用するためには、以下の権限追加が必要。

sudo usermod -a -G docker (権限追加するユーザ)

反映させるためには、再起動。
(systmctl restart dockerを実行し、当該ユーザのターミナルを再接続してもOK。)

sudo reboot

202.png

動作確認

以下のコマンドを実行。

docker run hello-world

203.png
一般ユーザでdockerを使用できるようになった。

参考

https://qiita.com/zembutsu/items/bedb18e1061303e217b8

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Qiitaはじめました。

概要

4月から新社会人になった駆け出しエンジニアです。
研修も全面リモートワークということもあり、何かはじめてみようと思い、自分がやってみた(やらなきゃいけない)ことをQiitaにアウトプットしていくことにしました。

初投稿は自分のモチベーションのために、技術的な内容ではなく、これからやってみたいこととか書いていければと思います。

これから何するの?

やっていきたいことは多々あるんですけど、なかでもバックエンドにフォーカスしていければと思います。(とか言いながら最近はVueに挑戦している)

と言うのも、就活をしていたときぐらいから何となくバックエンドの技術に興味があり、ちょっと調べているうちにやってみたいと思うようになったからで。

また、会社の配属先の部署で自動化やネットワークプログラマビリティなどのような高度な技術が必要とのことで、そこらへんに関連するバックエンドを中心に勉強していけたらと思っております。

具体的には?

具体的に取り組んでみたい内容を簡単に書き出してみました。

・Ruby on Rails(Javaの復習がてら)
・AWSのLambdaとかGreengrassとラズパイ
・Dockerでコンテナ型の仮想化を理解
・Kubernetes(ムズい)
などなど。。。

挙げだしたらキリがないです。。。
若干独学で出来るのか不安なところはありますが、会社に詳しい人がいると思うので、色々手を動かしながらやっていきたいです。

おい、資格の勉強はどうした?

資格て応用情報のことですよね。結論としては多分受けないです。
その代わり(?)といっては何ですが、今年から始まったCiscoのDevNet認定に今興味があり、色々調べています。

というのも、カリカリ勉強するよりパチパチコード書いてアウトプットしていったほうが圧倒的にスキルになるなと最近感じ始めたからで。

DevNet認定ではネットワークの基礎知識だけではなく、冒頭に申し上げたネットワークプログラマビリティや自動化が範囲としてあり、Python/Git/APIなどの幅広いモダンな開発知識が求められます。
個人的にIoT/IoEやDevOpsに興味があるので、ピッタリな内容かなと思いました。

お前、英語のこと忘れてるだろ

はい、最近気付きました。
チリツモを信じて毎朝Duoのチャプターひとつやるようにしてます。。。頑張ります。。。

最後に一言

これから長い長いエンジニア人生がはじまりますが、クリエイティブな思考を忘れないようにしたいものです。

ということで、これから触ってみた技術や知見をQiitaに書き留めていきたいと思います。
はじめはクオリティ低いと思いますが、よろしくお願いします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

システム日時を変更してcurlで証明書検証エラーが出たら--insecureをつける

タイトルの通りです。結論はこちら

問題が発生した状況

PHPUnitを利用して、以下のようにexec()でテスト対象のAPIを叩くという内容のテストを作成していました。(デバッグ用のオプションは省略->こちらが参考になります)

curl -X POST https://[テスト対象APIのURL] -d [POSTするデータ] > [出力先ファイル]

そのテストにおいて、システム日時を変更する必要があったので、

date -s "2020-01-01 00:00:00"

とコマンドを実行してコンテナ内のシステム日時を変更する処理を追加しました。
すると、以下のようなエラーが出ました。

curl: (60) Peer certificate cannot be authenticated with known CA certificates
More details here: http://curl.haxx.se/docs/sslcerts.htmlcurl performs SSL certificate verification by default, using a "bundle"
 of Certificate Authority (CA) public keys (CA certs). If the default
 bundle file isn't adequate, you can specify an alternate file
 using the --cacert option.
If this HTTPS server uses a certificate signed by a CA represented in
 the bundle, the certificate verification probably failed due to a
 problem with the certificate (it might be expired, or the name might
 not match the domain name in the URL).
If you'd like to turn off curl's verification of the certificate, use
 the -k (or --insecure) option.

証明書が認証されていないから、検証がいらないなら飛ばしたほうがいいよ、というようなことが書いてあります。

どうやって解決したか

テストコードなので証明書検証をスキップしました。
上記のエラーメッセージに従って --insecure のオプションをつけます。

curl -X POST https://[テスト対象APIのURL] -d [POSTするデータ] --insecure > [出力先ファイル]

これで証明書を検証せずにテストコードを走らせることができました。

まとめ

システム日時を変更することで、証明書検証が通らなくなることがあるみたいです。
今回はテストコードだったので証明書検証をスキップできましたが、本番環境だったら、日時の変更も考慮に入れて証明書を用意しないといけませんね。まあ、本番環境でそうそう日時変えないと思いますが。。。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Docker コンテナ内で何をやっても Permission denied と言われた時の対処法

急にコンテナが牙をむいた

何がきっかけか分からないが、
sudo docker-compose run コンテナ名 bash
で、bashに入ると、入れるには入れるが 「.bashrc なんとかかんとか Permission denied」というエラーがついてきた。

aaaaaaa.PNG

その後、コンテナ内でプログラムをコンパイルしようとすると、こちらもPermission denied。pythonを動かそうとしてもPermission denied。ことごとく何もできなくなってしまった。

挙句の果てに

aaaaaaa2.PNG

rootなのにsudoさせてもらえなくなってしまった。root権限とはいったい...

コンテナ側が悪いのか、ホスト側が悪いのか

まずは、コンテナ側の権限周りがおかしいと考えた。
sudoersの権限を確認したりと、いろいろ試したがどうにも原因らしきものが見つからない。

仕方がないので、コンテナを組みなおしてみる

sudo docker-compose build --no-cache っと...
aaaaaaa3.PNG

こ こ で も か Permission denied

解決

https://stackoverflow.com/questions/59633611/docker-permission-denied-on-login-and-everything-i-try
色々調べると、このような記事が出てきた。

sudo apt install docker.io
をして、再度buildを回してみる。

buildが始まった!

動いた!

困ったことに

困ったことに、このようになったトリガーが今一つ分かっていない。
ubuntuのアップデート?
一つ心当たりがあるのは、Dockerfileを変更後、--no-chacheをせずにdocker-composeしてしまったこと。
原因をご存じの方は、教えていただけると幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

目で見て覚えるDocker用語集

いつまでたってもKubernetesの正しい読み方が分からない今日この頃です。

概要

  • 簡単なDocker入門記事です。
  • Dockerの書籍を読む前に見てもらえればいいな、ぐらいのニュアンスで作られてます。
  • 文章少なめ、イメージ図多めです。
  • そんなに深い話はしません。

ターゲット

  • これからDockerを触ってみようという人
  • Docker触ってるけど用語が多すぎて頭がパンクしそうな人

Dockerとは

image-20200525-102249.png

  • Docker社が開発している、コンテナ型の仮想環境を作成、配布、実行するためのプラットフォームです。

コンテナって何

image.png

  • コンピュータの仮想化の方式の一つです。
  • 従来の仮想化と違い、ゲストOSを用意しません。
  • ホストOSの一部を分離して使用し、他と隔離された専用のエリアを用意します。
  • 隔離された領域のことをコンテナといいます。

コンテナだと何がいいの?

スクリーンショット 2020-05-26 10.59.57.png

  • 既存の仮想化を二世帯住宅やマンションに例えると、コンテナ型仮想環境はキャンプ場に近いです。
  • キャンプ場はコンテナ技術が搭載されているサーバで、キャンプ場に設置されたテント一つ一つがコンテナです。
  • テントはキャンプ場のルール(サーバスペック)さえ守れば、大きさ・場所含めて自由に設置ができます。
  • 家とは違い、トイレや水道などの共同箇所(OS)は各テント共同で使用しなければいけません。
  • 共同箇所の変更はキャンプ場のオーナーしかできません。
  • トイレや水道をテント内に設置しなくてすむので、設置に時間がかかりません。
  • テントはキャンプが終わり次第片付けるので、場所(リソース)の再利用が容易です。

用語集

Dockerエンジン

エンジンとは、一般的には内燃機関を指すが、IT用語としては、ある特定の処理を行うための機能を提供する、ひとまとまりになった処理装置のことである。プログラムを指す場合が多いが、カスタムICなどのハードウェアを指す場合もある。
出典:https://www.sophia-it.com/content/%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%B3

コンテナ型仮想化技術(機能)を提供するプログラムを指します。

Dockerホスト

image.png
Dockerエンジンが動作しているサーバを指します。
Docker社公式アイコンのクジラ部分(赤枠)が該当します。

Dockerコンテナ

image.png
Dockerエンジンによって提供されたコンテナ型仮想環境を指します。
Docker社公式アイコンのコンテナ部分(赤枠)が該当します。
Dockerホスト上に複数のコンテナを搭載することができます。

Dockerイメージ

image.png
Dockerコンテナ構成をまとめたもの(テンプレート)を指します。
Dockerイメージを元にDockerコンテナを作成します。
Dockerイメージは使い回しができるので複数コンテナをまとめて作成できます。
別のDockerホストに同じDockerコンテナを作成することもできます。

Dockerfile

image.png
Dockerfileは、Dockerイメージを自動で作成してくれるファイルを指します。
基本となるイメージを設定することができます。
追加で入れたいアプリや環境変数・コマンドなどを設定できます。

docker image build

image.png
Dockerfileを元にDockerイメージを作成することを指します。
作成されたDockerイメージを元にDockerコンテナを作成します。
作成されたDockerイメージは後述のDocker Registryに登録・管理することもできます。

Docker Repository

ソフトウェア開発などに用いるプロジェクト管理システムやバージョン管理システムなどで、プロジェクトを構成するプログラムのソースコードやドキュメント、関連する各種のデータやファイルなどを一元的に管理する格納場所のことをリポジトリという。
出典:http://e-words.jp/w/%E3%83%AA%E3%83%9D%E3%82%B8%E3%83%88%E3%83%AA.html

同名のDockerイメージの集まりを指します。
差分管理やバージョン管理をタグをつけることで区分けしています。

Docker Registry

image.png
Dockerイメージを持つDocker Repositryを保管・管理するサービスを指します。
publicなもの(Docker Hub etc)とprivateなものの2種類があります。
privateなものについては、Docker上で別途Docker Registry専用のコンテナを構築する必要があります。

Docker Hub

image.png
Docker Registryの1種でコンテナ共有サービスを指します。
作成したDocker Repositryをアップすることで、Git Hubのような配布・変更管理などの機能を提供します。

Data Volume

image.png
Dockerコンテナ内のディレクトリをDockerホストのディスクに永続化する仕組みを指します。
上記を使用することで、Dockerホスト・コンテナ間でのディレクトリの共有・再利用ができます。
ステートフルなアプリケーションをコンテナで実現する際に使用されます。

Data Volume コンテナ

コンテナ間でディレクトリを共有する仕組みを指します。
Data Volume用のコンテナを用意し、他のコンテナは全てData Volume用のコンテナを参照します。
仲介役のコンテナがあるため、アプリケーション用コンテナはDockerホスト側の情報を知る必要がありません。

Docker Compose

image.png
複数コンテナを構築・実行する手順の自動化・管理を容易にするツールを指します。
複数のコンテナを使って1つのアプリケーションを構築する場合などに使用します。(例)master - slave etc

docker-compose.yml

image.png
複数コンテナの構築・実行手順をまとめたファイルを指します。
構築したいコンテナのイメージ(or Dockerfile)が定義されています。
他にも起動順・条件やコンテナ間の接続情報なども定義することができます。

Docker Swarm

image.png
複数のDockerホストを束ねてクラスタ化するツールを指します。(ロゴも大勢のクジラでコンテナを運んでいます)
Docker Swarmを使ってクラスタ化されたクラスタをSwarm Clusterといいます。
Docker Swarmはコンテナオーケストレーションシステムの1つです。

オーケストレーションとは、複数の統合されていないシステムにわたる多数のステップを含む
プロセスやワークフローを自動化する方法を指します。
出典:https://www.redhat.com/ja/topics/automation/what-is-orchestration

Docker Node

image.png
Swarm Clusterを構成するDockerホストのことを指します。
Docker Nodeには『manager』と『worker』の2種類があります。

manager

Swarm Cluster内のDocker Nodeを管理するDocker Nodeを指します。
後述のworkerの機能も兼業することができます。
アイコンの黄枠で囲まれたクジラが該当します。(上に乗っているクジラの面倒を見ているイメージです)

worker

コンテナを実行するDocker Nodeを指します。
アイコンの赤枠で囲まれたクジラが該当します。

Service

image.png
アプリケーションを構成する一部のコンテナを制御するための単位を指します。(アイコンの赤枠が該当)
webアプリケーションを例にすると、Serviceは『web』と『API』の2つで構成することができます。

Stack

image.png
複数のServiceをグルーピングした単位を指します。(アイコンの赤・黄・青枠がそれぞれ該当)
Stackはアプリケーション全体の構成を定義することができます。
Stackで利用するoverlayネットワークを設定しないと、Stackの数だけoverlayネットワークが作成されます。

overlayネットワーク

image.png
コンテナ間通信が可能なネットワークのことを指します。
Stack作成時にデフォルトでネットワークが作成されます。
設定で別のoverlayネットワークに対象のStackを所属させることもできます。
同じoverlayネットワークに所属していれば、異なるStackのコンテナ間でも通信ができます。

stack file

Stack内の情報をまとめたファイルを指します。
内容として、Service情報、Dockerイメージ、構築するコンテナ台数、所属するoverlayネットワーク情報があります。

Kubernetes

image.png
Google社主導で開発されたコンテナオーケストレーションシステムです。
読みは「クバネティス/クバネテス/クーべネティス」、表記は「k8s」などがあります。
現在、主流のコンテナ管理ツールです。
機能としては、docker-composeやdocker swarmの機能を全て持っています。
さらにコンテナ間のネットワークルーティング管理等もできます。
公式アイコンにあるようコンテナ船の舵取りのような位置付けです。

詳細は別途Kubernetesの用語集記事を作成予定です。

まとめ

  • コンテナ技術はサーバ仮想化技術の一つです。
  • コンテナ技術を提供するプログラムがDockerです。
  • DockerコンテナはDockerイメージを元に構築されます。
  • Dockerイメージを管理するツールがDocker Registry(Docker Hub)です。
  • Docker Composeは複数のコンテナを管理・操作するツールです。
  • Docker Swarmは複数のDockerホストを束ねクラスタ化するツールです。
  • Docker ComposeやDocker Swarmの高機能版がKubernetesです。

おまけ

コンテナ技術も仮想化技術の一種なので、もちろんクラウドサービスの対象になります。
代表的なサービスを以下に紹介します。

※AWS贔屓なのはAWSぐらいしかクラウドサービスを触ったことがないためです汗

代表的なコンテナクラウドサービス

AWS

ECS(EC2 Container Service)

AWS が開発したコンテナ管理サービスを指します。
コンテナの実行環境として、EC2・Fargateのどちらも選択できます。

EKS(Elastic Container Serice for Kubernetes)

Kubernetesのマネージドサービス(コンテナ管理サービス)を指します
コンテナの実行環境として、EC2・Fargateのどちらも選択できるようになりました。

Fargate

サーバレスのコンテナ可動サービスを指します。
コンテナを登録するだけでリソースを気にせずに利用することができます。
コンテナ単位に課金されます。

Microsoft Azure

AKS(Azure Kubernetes Service)

KubernetesをAzureで動かすためのコンテナ可動サービスを指します。

ACR(Azure Container Registry)

コンテナのイメージ用に利用できるプライベートレジストリサービスを指します。

Google Cloud

GKE(Google Kubernetes Engine)

Kubernetesをベースとしたコンテナ管理サービスを指します。

Cloud Run

フルマネージド環境でステートレスコンテナを実行できるコンテナ可動サービスを指します。
サーバーレスなのでインフラストラクチャ管理が一切不要です。
コード実行時に課金されます。

最後に

Dockerは用語の説明見るよりも、各ツールのアイコンを見た方がイメージしやすいです。
最近の技術というだけあって、アイコンにもユーザビリティが溢れている点はとてもいいですね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

multi stage build でpythonのC言語依存モジュールをインストールする

背景

alpineでC言語依存モジュールを pip install すると激重になる話

  • alpineだと上記事象が避けられない
    • multi stage buildで生成物を引っ張ってくるのが折衷案として良さそう
  • 生成物を再利用する方法が安全ではない
    • ベースイメージから生成物をベタにCOPYしている部分をやめる
    • *.whlを持ってきて安全にインストールする方法を採ってみる
  • ついでに小手先技でimageを軽くしてみる

手順

  1. alpineイメージ上で必要モジュールをビルド
    1. 生成物(wheelファイル)は再利用しやすいように一箇所にまとめる
  2. ビルド完了済みイメージとして docker hub に push
  3. 実行環境用のalpineに multi stage build でwheelのディレクトリだけ取得しインストール
  4. 実行確認とお掃除

実装

まずは、必要なモジュール郡の取得から
今回は以下のモジュールを導入してみる

requirements.txt
cycler==0.10.0
Cython==0.29.17
h5py==2.10.0
joblib==0.14.1
kiwisolver==1.2.0
matplotlib==3.2.1
numpy==1.18.4
pandas==1.0.3
Pillow==7.1.2
pyparsing==2.4.7
python-dateutil==2.8.1
pytz==2020.1
scikit-learn==0.22.2.post1
scipy==1.3.3
six==1.14.0

必要モジュールを一箇所に集める

alpineの場合だとc言語依存モジュールはtarやzipなどの圧縮形式が落ちてくる。
これらをwhl形式に変換しておく必要がある。

下準備

whlに変換時に必要なライブラリが存在するはずなので、apk経由でinstallしておく

apk update \
  && apk add --virtual .build --no-cache openblas-dev lapack-dev freetype-dev 
...
  && apk add --virtual .community_build --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/community hdf5-dev

必要なwhlファイルを用意する

pip downloadでもモジュールのダウンロードを実行できるが、
pip wheelコマンドだとダウンロードとtar/zipファイルを自動展開してビルドまで行ってくれるのでこちらを使用。
pip wheel-rオプションを使用できるのでpip freeze > requirements.txtなどでバージョニングファイルを指定する。

pip wheel --no-cache --wheel-dir=./whl -r requirements.txt
  • オプション補足
    • --no-cache-dir: キャッシュを使用・作成をしない。指定しないと~/.tmpとかにもりもりキャッシュされる。ビルド時の生成物もキャッシュされる。
    • --wheel-dir: wheelファイルのアウトプット先。

pip wheelを使用する場合の補足

残念ながら今回の場合は途中で失敗する
別alpine環境で構築しpip freezeしたrequirements.txtを使用しているのだが、
numpy,scipyが使用できる環境でないためscikit-learnビルド中に落ちる。

pip install -r requirements.txtだとpip側がよしなにインストールしてくれるが1
今回に限っては先に依存モジュールを入れておくしか無い。2

pip install cython numpy==1.18.4 scipy==1.3.3
pip wheel --no-cache --wheel-dir=./whl -r requirements.txt

numpyやscipyのビルドを回避するために別途イメージを作ろうとしていたのに
なんだか無意味なことをしている気がしてきたぞ...?

ビルド完了後docker hubにpush

適当にタギングしてpush

docker tag 123456789a hoge/builder-image:latest
docker push hoge/builder-image:latest

実行環境用に生成物を持ってくる

ここからは実行環境用のdockerfile上で作業していく。

ローカルディレクトリのwheelをインストール

pip installで複数モジュールを指定するにはベタ書きしていくか--requirementでテキストファイルを指定する。
適当なディレクトリにwhlを集めて丸ごとインストールできるような仕様は無い。

今回はマルチステージビルドでwheelが入ったディレクトリをCOPYし下記コマンドを実行することでローカルのwheelからインストールする。

pip install --no-index --no-deps --no-cache-dir -f ./whl -r requirement.txt
  • オプション補足
    • --no-index: PyPiのようなインデックスサイトを使わない。オンラインを経由したくない時に使う
    • --no-deps: 依存モジュールをインストールしない。ただし、モジュール側で明確に指定されている場合はその限りでない模様。
    • -f,--find-links: モジュールの検索先を指定。ローカルパスを指定したい時はこれを使う

--upgrade で対応するモジュール

pipやsetuptoolsなど--upgradeオプションをつけて導入したいモジュールはアップグレード用のテキストファイルに分けて導入する。
-rオプションで参照するテキストファイルはバージョン指定無しでもインストールは可能だ。

upgrade.txt
pip
setuptools
wheel

下記コマンドで特定ディレクトリにまとめたモジュールをupgrade

pip install -U --no-index --no-deps --no-cache-dir -f ./upgrade  -r upgrade.txt

ただ、管理するファイルも増えるのでオフライン環境下でもない限りはdockerfileに直接書いた方がいい。

実行確認

importできるか確認。shellファイルにしておいてRUNコマンド実行に直接叩く。

import_test.sh
#!/bin/sh
python -c "import numpy"
python -c "import scipy"
python -c "import h5py"
python -c "import pandas"
python -c "import matplotlib"
python -c "import sklearn"

後始末

dockerイメージの軽量化のため余分なファイル削除
whlのビルドに使っていたイメージは生成物さえ残っていればいいので他は全部消す。

builder-image
apk del --purge .build .testing_build
pip freeze | xargs pip uninstall -y
pip cache purge

余分なファイルを削除した事で、ビルドしたイメージがどれだけ軽くなったか確認。360MBほど減量に成功した模様

# docker images
REPOSITORY              TAG                 IMAGE ID            CREATED             SIZE
naka345/wheel_build     latest              b6c9df898334        9 minutes ago       1.04GB 
naka345/wheel_build     latest              3236cf2f87de        2 days ago          639MB

次は実行環境側の整理。
公式のpython dockerがとてもスマートなので、これに倣ってファイルを消し込んでいく。3

execution-image
# モジュールに必要なファイルだけ新しい仮想パッケージとして括り、
find /usr/local -type f -executable -not \( -name '*tkinter*' \) -exec scanelf --needed --nobanner --format '%n#p' '{}' ';' \
    | tr ',' '\n' \
    | sort -u \
    | awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \
    | xargs -rt apk add --no-cache --virtual .module-rundeps && \
  # ビルド時に使っていたパッケージ群は全て消す
  apk del --purge .build .community_build
# python側の余分なファイルやゴミの削除
find /usr/local -depth \
        \( \
            \( -type d -a \( -name test -o -name tests -o -name idle_test \) \) \
            -o \
            \( -type f -a \( -name '*.pyc' -o -name '*.pyo' \) \) \
        \) -exec rm -rf '{}' + 

# 今回の実行範囲分のゴミ掃除
rm -rf /tmp/whl

実行環境側で消さなかった時との比較をしてみる。

# docker images
REPOSITORY              TAG                 IMAGE ID            CREATED             SIZE
naka345/wheel_install   latest              f0df8a9887de        3 hours ago         1.29GB
↓
naka345/wheel_install   latest              27b4805053f2        3 hours ago         968MB

なんとか1GB以下に抑えることができた。

dockerfileにしてみる

上記を踏まえてdockerfileに書き下す。
長くなるのでgithubのリンクを貼っておしまい。

まとめ

時間がかかるモジュールもpip経由で安全に比較的手早く持ち込めるようにした。
dockerイメージも多少軽量化できた。

ただし、イメージを複数持たないといけない部分は据え置き。
requirements.txtの整合性が要になるので、
こいつが更新されたタイミングでdocker hubに両イメージがpushされる仕組みがあれば楽になれるのかな?

参考文献


  1. pipのインストール順序は依存ライブラリや優先順位などを考慮せず一気通貫で実行されるため、pip installでも同様の事象は起こる。代わりに"circular dependency"なため途中で失敗したモジュールは、他全てのモジュールの導入が済み次第もう一度ビルドを実行し直すことで回避している。 

  2. 手元の環境でscipy~=1.4だとエラーが出て失敗するため、素直に入ってくれた1.3系を指定 

  3. -no-cache-dirを指定してインストールした実行環境だとpip cache purgeを実行するとcacheファイルが見つからずエラーコードを返す。地味に使いづらい。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

multi stage build でpythonのC言語依存モジュールを wheel形式でインストールする

背景

alpineでC言語依存モジュールを pip install すると激重になる話

  • alpineだと上記事象が避けられない
    • multi stage buildで生成物を引っ張ってくるのが折衷案として良さそう
  • 生成物を再利用する方法が安全ではない
    • ベースイメージから生成物をベタにCOPYしている部分をやめる
    • *.whlを持ってきて安全にインストールする方法を採ってみる
  • ついでに小手先技でimageを軽くしてみる

手順

  1. alpineイメージ上で必要モジュールをビルド
    1. 生成物(wheelファイル)は再利用しやすいように一箇所にまとめる
  2. ビルド完了済みイメージとして docker hub に push
  3. 実行環境用のalpineに multi stage build でwheelのディレクトリだけ取得しインストール
  4. 実行確認とお掃除

実装

まずは、必要なモジュール郡の取得から
今回は以下のモジュールを導入してみる

requirements.txt
cycler==0.10.0
Cython==0.29.17
h5py==2.10.0
joblib==0.14.1
kiwisolver==1.2.0
matplotlib==3.2.1
numpy==1.18.4
pandas==1.0.3
Pillow==7.1.2
pyparsing==2.4.7
python-dateutil==2.8.1
pytz==2020.1
scikit-learn==0.22.2.post1
scipy==1.3.3
six==1.14.0

必要モジュールを一箇所に集める

alpineの場合だとc言語依存モジュールはtarやzipなどの圧縮形式が落ちてくる。
これらをwhl形式に変換しておく必要がある。

下準備

whlに変換時に必要なライブラリが存在するはずなので、apk経由でinstallしておく

apk update \
  && apk add --virtual .build --no-cache openblas-dev lapack-dev freetype-dev 
...
  && apk add --virtual .community_build --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/community hdf5-dev

必要なwhlファイルを用意する

pip downloadでもモジュールのダウンロードを実行できるが、
pip wheelコマンドだとダウンロードとtar/zipファイルを自動展開してビルドまで行ってくれるのでこちらを使用。
pip wheel-rオプションを使用できるのでpip freeze > requirements.txtなどでバージョニングファイルを指定する。

pip wheel --no-cache --wheel-dir=./whl -r requirements.txt
  • オプション補足
    • --no-cache-dir: キャッシュを使用・作成をしない。指定しないと~/.tmpとかにもりもりキャッシュされる。ビルド時の生成物もキャッシュされる。
    • --wheel-dir: wheelファイルのアウトプット先。

pip wheelを使用する場合の補足

残念ながら今回の場合は途中で失敗する
別alpine環境で構築しpip freezeしたrequirements.txtを使用しているのだが、
numpy,scipyが使用できる環境でないためscikit-learnビルド中に落ちる。

pip install -r requirements.txtだとpip側がよしなにインストールしてくれるが1
今回に限っては先に依存モジュールを入れておくしか無い。2

pip install cython numpy==1.18.4 scipy==1.3.3
pip wheel --no-cache --wheel-dir=./whl -r requirements.txt

numpyやscipyのビルドを回避するために別途イメージを作ろうとしていたのに
なんだか無意味なことをしている気がしてきたぞ...?

ビルド完了後docker hubにpush

適当にタギングしてpush

docker tag 123456789a hoge/builder-image:latest
docker push hoge/builder-image:latest

実行環境用に生成物を持ってくる

ここからは実行環境用のdockerfile上で作業していく。

ローカルディレクトリのwheelをインストール

pip installで複数モジュールを指定するにはベタ書きしていくか--requirementでテキストファイルを指定する。
適当なディレクトリにwhlを集めて丸ごとインストールできるような仕様は無い。

今回はマルチステージビルドでwheelが入ったディレクトリをCOPYし下記コマンドを実行することでローカルのwheelからインストールする。

pip install --no-index --no-deps --no-cache-dir -f ./whl -r requirement.txt
  • オプション補足
    • --no-index: PyPiのようなインデックスサイトを使わない。オンラインを経由したくない時に使う
    • --no-deps: 依存モジュールをインストールしない。ただし、モジュール側で明確に指定されている場合はその限りでない模様。
    • -f,--find-links: モジュールの検索先を指定。ローカルパスを指定したい時はこれを使う

--upgrade で対応するモジュール

pipやsetuptoolsなど--upgradeオプションをつけて導入したいモジュールはアップグレード用のテキストファイルに分けて導入する。
-rオプションで参照するテキストファイルはバージョン指定無しでもインストールは可能だ。

upgrade.txt
pip
setuptools
wheel

下記コマンドで特定ディレクトリにまとめたモジュールをupgrade

pip install -U --no-index --no-deps --no-cache-dir -f ./upgrade  -r upgrade.txt

ただ、管理するファイルも増えるのでオフライン環境下でもない限りはdockerfileに直接書いた方がいい。

実行確認

importできるか確認。shellファイルにしておいてRUNコマンド実行に直接叩く。

import_test.sh
#!/bin/sh
python -c "import numpy"
python -c "import scipy"
python -c "import h5py"
python -c "import pandas"
python -c "import matplotlib"
python -c "import sklearn"

後始末

dockerイメージの軽量化のため余分なファイル削除
whlのビルドに使っていたイメージは生成物さえ残っていればいいので他は全部消す。

builder-image
apk del --purge .build .testing_build
pip freeze | xargs pip uninstall -y
pip cache purge

余分なファイルを削除した事で、ビルドしたイメージがどれだけ軽くなったか確認。360MBほど減量に成功した模様

# docker images
REPOSITORY              TAG                 IMAGE ID            CREATED             SIZE
naka345/wheel_build     latest              b6c9df898334        9 minutes ago       1.04GB 
naka345/wheel_build     latest              3236cf2f87de        2 days ago          639MB

次は実行環境側の整理。
公式のpython dockerがとてもスマートなので、これに倣ってファイルを消し込んでいく。3

execution-image
# モジュールに必要なファイルだけ新しい仮想パッケージとして括り、
find /usr/local -type f -executable -not \( -name '*tkinter*' \) -exec scanelf --needed --nobanner --format '%n#p' '{}' ';' \
    | tr ',' '\n' \
    | sort -u \
    | awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \
    | xargs -rt apk add --no-cache --virtual .module-rundeps && \
  # ビルド時に使っていたパッケージ群は全て消す
  apk del --purge .build .community_build
# python側の余分なファイルやゴミの削除
find /usr/local -depth \
        \( \
            \( -type d -a \( -name test -o -name tests -o -name idle_test \) \) \
            -o \
            \( -type f -a \( -name '*.pyc' -o -name '*.pyo' \) \) \
        \) -exec rm -rf '{}' + 

# 今回の実行範囲分のゴミ掃除
rm -rf /tmp/whl

実行環境側で消さなかった時との比較をしてみる。

# docker images
REPOSITORY              TAG                 IMAGE ID            CREATED             SIZE
naka345/wheel_install   latest              f0df8a9887de        3 hours ago         1.29GB
↓
naka345/wheel_install   latest              27b4805053f2        3 hours ago         968MB

なんとか1GB以下に抑えることができた。

dockerfileにしてみる

上記を踏まえてdockerfileに書き下す。
長くなるのでgithubのリンクを貼っておしまい。

まとめ

時間がかかるモジュールもpip経由で安全に比較的手早く持ち込めるようにした。
dockerイメージも多少軽量化できた。

ただし、イメージを複数持たないといけない部分は据え置き。
requirements.txtの整合性が要になるので、
こいつが更新されたタイミングでdocker hubに両イメージがpushされる仕組みがあれば楽になれるのかな?

参考文献


  1. pipのインストール順序は依存ライブラリや優先順位などを考慮せず一気通貫で実行されるため、pip installでも同様の事象は起こる。代わりに"circular dependency"なため途中で失敗したモジュールは、他全てのモジュールの導入が済み次第もう一度ビルドを実行し直すことで回避している。 

  2. 手元の環境でscipy~=1.4だとエラーが出て失敗するため、素直に入ってくれた1.3系を指定 

  3. -no-cache-dirを指定してインストールした実行環境だとpip cache purgeを実行するとcacheファイルが見つからずエラーコードを返す。地味に使いづらい。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

コンテナ設計方針をまとめてみた

Docker化にあたって

全てDocker化できるわけではない

  • ステートレスなものは向いている
    • コンテナはステートレス
  • 極力手作業なしの形で実装する

環境変数を活用する

アプリケーションの挙動を環境変数で制御する

手法
- 実引数
- 設定ファイル
- 設定ファイルに環境変数を埋め込む

永続化データの扱い

  • Data Volumeとしてホストと共有する
  • Data Volumeコンテナとして独立させる

セキュリティ

  • OFFICIALイメージを使う
  • Docker Bench for Securityでチェックを行う
  • 脆弱性が混入する恐れがあるのでdokerfileにはADDは使わない

    • COPY推奨
    • ADDを使うならチェックサムを併用する
  • 適切なアクセス制御を設定する

    • rootログイン禁止
    • ACLを設定する
    • VPNを併用する
  • Dockerデーモンにアクセスできることになるので、/var/run/docker.sockを共有しない

  • アプリケーション実行ユーザを用意し、rootユーザのままで実行しない

機密情報の扱い

  • 環境変数(要暗号化)
  • 外部から(暗号化S3バケット等)

ポータビリティ(≒冪等性)

絶対ではない

冪等性を損ねる要素

コンテナの粒度

1コンテナ = 1関心事

  • 1コンテナ=1プロセスではうまくいかないことも

  • 1つのロール or 1つのドメインに収まる粒度で

より軽いコンテナを

コンテナが重いことの弊害

  • オートスケールでのサービスインまで時間がかかる

  • ビルドに時間がかかる

  • プッシュに時間がかかる

  • ホストへにデプロイするのに時間がかかる

  • ディスク容量を無駄に消費する

  • CIに要する時間が増加する

→ 塵も積もれば、生産性が低下する

軽量ベースイメージ

  • scratch

    • 機能が限定的すぎる
  • BusyBox

    • 標準Cライブラリ別にイメージが作られている
    • パッケージマネージャなし
  • Alpine Linux

    • デファクトスタンダード
  • dlstroless

コンテナサイズの削減手法

  • アプリケーションのサイズを削減する

    • 不要ファイルの削除
    • 不要プログラムの削除
    • 依存ライブラリの削減
    • web asset(主に画像)のサイズ削減
  • .dockerignoreを定義する

  • Doker build時のレイヤ構造を削減する

    • RUNで個別に実行する → "&&"で結合して実行する

      FROM alpine:xx.yy
      RUN apk add --no-cache wget 
      RUN wget https:// hoge.com/fuga.tgz
      RUN tar -xvzf fuga.tgz
      RUN rm fuga.tgz
      RUN mv fuga /bin/fuga
      RUN chmod +x /bin/fuga
      RUN fuga --symlink
      

      FROM alpine:xx.yy
      RUN apk add --no-cache wget &&\
        wget https:// hoge.com/fuga.tgz &&\
        tar -xvzf fuga.tgz &&\
        rm fuga.tgz &&\
        mv fuga /bin/fuga &&\
        chmod +x /bin/fuga &&\
        fuga --symlink
      

      可読性とトレードオフになる

    • multi-stage builds

      • ビルド用コンテナと実行コンテナを分離する
      • ビルド用コンテナで制作したファイルは実行コンテナにコピーし、ビルド用コンテナは破棄される
        = ビルド用にのみ必要なツールをインストールするレイヤは、実行コンテナに存在しなくてよい
      FROM golang:xx AS build
      WORKDIR /
      COPY ./go/src/hoge.com/fuga/piyo
      RUN go get github.com/go-sql-driver/mysql
      RUN go get hoge.in/gorp.v1
      RUN cd /go/src/hoge.com/fuga/piyo && go build -o bin/piyo cmd/main.go
      
      FROM alpine:yy
      COPY --from = build /go/src/hoge.com/fuga/fuga/bin/piyo /usr/local/bin/
      CMD ["piyo"]
      

本番環境適用以外のコンテナ活用法

  • 開発環境の統一・共有
  • CLIの利用
    • Win・MacでLinuxでしか動かないツールを使う手段
  • シェルスクリプト
  • 負荷テスト

参考

Docker/Kubernetes 実践コンテナ開発入門

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

docker back to the basic

TL;DR

以下のようなコマンドを使いこなそう

参考) http://docs.docker.jp/engine/reference/commandline/index.html

$ docker image ls
$ docker container ls
$ docker container ls -a
$ docker run -id --name hoge --rm -v $(pwd):/home
$ docker container exec
$ docker container attach
$ docker build -t [image-name] .

基礎知識

  • コンテナ型の仮想化では、コンテナはホストOSのカーネルを利用する。

  • 1コンテナ1プロセスで使用する。

  • Web server container やDB server container など複数のコンテナを立てる。

  • コンテナにはそれぞれ仮想NIC(eth0)が割り当てられる。

  • コンテナ同士で通信することが可能である。

NIC(ニック) : 別名 LANカード、Ethernetカード
NICとは、コンピュータなどの機器を通信ネットワーク(LAN)に接続するためのカード型の拡張装置。筐体背面や側面などに用意された拡張スロットなどに挿入して使用する。単にNICといった場合は最も普及しているイーサネット(Ethernet = 有線LANのこと)に接続するためのコネクタ(RJ45)のことを指す。(引用 http://e-words.jp)

What is image and what is container?

docker には、imagecontainerの2つがある。

OSのインストールDVDを仮想化したものがDockerイメージであり、

OSが起動しているサーバを仮想化したものがコンテナに相当する。

基本コマンド

まず、一番簡単なHello Worldをやってみます。

hello-worldというDockerイメージが用意されていますので、docker run hello-worldコマンドで起動します。

$ sudo docker run hello-world

pullしてきたDockerイメージの一覧docker image lsコマンドで確認できます。

$ docker image ls
$ docker container ls -a

作成済Dockerコンテナの一覧はdocker container ls -aで確認できます。

"-a"は「コンテナ一覧をすべて表示」するためのオプションです。これをつけないと起動中のコンテナしか表示されません。

dockerイメージの削除コマンドはdocker image rm [Repository]
コンテナの削除コマンドはdocker container rm [containerID]

$ docker image rm hello-world
$ docker container rm 484810ae0812

注意) docker runからの状況から脱するには「ctrlキーを押しながら'pq'と押す」

ホスト側との「ディレクトリ共有」

「ホストOSのディレクトリ」と「コンテナ内の/home/」をつなぐ。

「-v」オプションは、ポートフォワード設定の「-p」オプションと同様、コロンの左側(/root/tomcat-container/logs)がホスト側、右側(/share/logs)がコンテナ内のディレクトリを表していて、そのディレクトリが共有されるかたちとなります。

$ docker run -it -d --name hogehoge -v $(pwd):/home ubuntu:16.04 bash

docker container exec

docker container ls -aでまだコンテナが起動している場合、またコンテナに入ることができるはずである。起動しているコンテナに入る方法は2つある。

  • docker container attachコマンドを使う方法
  • docker container execコマンドを使う方法

attachは「ゲストOSの標準入力/標準出力を、ホストOSの標準入力/標準出力とつなげる」という意味です。attachは「既に起動している」1つのプロセスへつなぎに行きます。

$ docker container attach 5
$ ps aux
# ctrlキー + 'pq'

execは「引数のコマンドを実行する」という意味です。

execでは現在起動しているbashとは別に、新たにbashを起動します。

$ docker container exec -it 5 bash
$ ps aux
$ exit

DockerFile の作り方

Docker imageからDocker containerを作成する際に「必要なパッケージのインストール手順や実行コマンドなど」をスクリプト化したもの。

$ docker build -t workcheck

FROM

FROM 命令は、以降の命令で使う ベース・イメージ を指定します。あるいは、有効な Dockerfile は、1行めを FROM 命令で指定する必要があります。イメージとは、あらゆる有効なものが利用できます。 パブリック・リポジトリ から イメージを取得する 方法が一番簡単です。

# ubuntu 14.04のイメージを使う
FROM ubuntu:14.04

MAINTAINER

MAINTAINER 命令は、生成するイメージの Author (作者)フィールドを指定します。

RUN

イメージ作成時に実行するコマンドを書く。

RUN 命令は既存イメージ上の新しいレイヤで、あらゆるコマンドを実行し、その結果をコミットする命令です。コミットの結果得られたイメージは、 Dockerfile の次のステップで使われます。

#  update後にapache2をインストール
RUN apt update .
RUN apt install apache2 -y

CMD

DockerfileCMD 命令を一度だけ指定できます。複数の CMD がある場合、最も後ろの CMD のみ有効です。

CMD の主な目的は、 コンテナ実行時のデフォルトを提供します 。 デフォルトには、実行可能なコマンドが含まれているか、あるいは省略されるかもしれません

# nodeでサーバを起動する
CMD [ "node", "server.js" ]

EXPOSE

指定のポートを外部に公開する。

EXPOSE 命令は、特定のネットワーク・ポートをコンテナが実行時にリッスンすることを Docker に伝えます。 EXPOSE があっても、これだけではホストからコンテナにアクセスできるようにしません。アクセスするには、 -p フラグを使ってポートの公開範囲を指定するか、 -P フラグで全ての露出ポートを公開する必要があります。

# 80番ポートを外部に公開する
EXPOSE 80

ENV

ENV 命令は、環境変数 と 値 のセットです。値は Dockerfile から派生する全てのコマンド環境で利用でき、 インラインで置き換え も可能です。

ENV 命令は2つの形式があります。1つめは、 ENV であり、変数に対して1つの値を設定します。はじめの空白以降の文字列が `` に含まれます。ここには空白もクォートも含まれます。

2つめの形式は ENV = ... です。これは一度に複数の変数を指定できます。先ほどと違い、構文の2つめにイコールサイン(=)があるので気を付けてください。コマンドラインの分割、クォート、バックスラッシュは、空白スペースも含めて値になります。

ENV <key> <value>
ENV <key>=<value> ...

ADD

ADD 命令は <ソース> にある新しいファイルやディレクトリをコピー、あるいはリモートの URL からコピーします。それから、コンテナ内のファイルシステム上にある 送信先 に指定されたパスに追加します。

複数の <ソース> リソースを指定できます。この時、ファイルやディレクトリはソースディレクトリ(構築時のコンテクスト)からの相対パス上に存在しないと構築できません。

それぞれの <ソース> にはワイルドカードと Go 言語の filepath.Match ルールに一致するパターンが使えます。例えば、次のような記述です。

ADD hom* /mydir/        # "hom" で始まる全てのファイルを追加
ADD hom?.txt /mydir/    # ? は1文字だけ一致します。例: "home.txt"
ADD test relativeDir/          # "test" を `WORKDIR`/relativeDir/ (相対ディレクトリ)に追加
ADD test /absoluteDir/          # "test" を /absoluteDir/ (絶対ディレクトリ)に追加

COPY

COPY 命令は <ソース> にある新しいファイルやディレクトリをコピーするもので、コンテナ内のファイルシステム上にある <送信先> に指定されたパスに追加します。

COPY hom* /mydir/        # "hom" で始まる全てのファイルを追加
COPY hom?.txt /mydir/    # ? は1文字だけ一致します。例: "home.txt"

例) Node.jsのアプリケーションを起動する

FROM node:12

# アプリケーションディレクトリを作成する
WORKDIR /usr/src/app

# アプリケーションの依存関係をインストールする
# ワイルドカードを使用して、package.json と package-lock.json の両方が確実にコピーされるようにします。
# 可能であれば (npm@5+)
COPY package*.json ./

RUN npm install
# 本番用にコードを作成している場合
# RUN npm install --only=production

# アプリケーションのソースをバンドルする
COPY . .

EXPOSE 8080
CMD [ "node", "server.js" ]
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Dockerで構築したRailsアプリをGitHub Actionsで高速にCIする為のプラクティス(Rails 6 API編)

Rails on GitHub Actions(或いは {Django,Laravel} on GitHub Actions)のCI事例として、

  • ホストランナー上にRuby(Python, PHP)をセットアップ
  • MySQLやRedisはサービスコンテナで立ち上げ
  • 依存ライブラリのインストール(bundle install) や ユニットテスト(rspec) もホストランナー上で直接実行

という事例は多く見かけるのですが、開発をDockerベースで行っていて、GitHub ActionsのCI Pipelineも同じくDockerベースで構築したい...というケースの事例があまり見当たらなかったので、自分が関わったプロジェクト(Rails 6 API mode)での事例を紹介します。

Requirements

  • 開発環境は全てDocker(Dockerfile/docker-compose.yml)で構築&管理したい
    • bundle installrails srspec などのコマンドは全てdocker-compose {run,exec} を介してコンテナ内で実行する(したい)
  • Dockerイメージのサイズを小さくしたい
    • Dockerイメージ自体でファイルを抱え込むような処理はなるべく書かない。実行したいコマンドはコンテナ起動時に都度コマンドとして付与し、成果物を保存・永続化したければvolumesを活用する
    • bundle install, rails db:prepareを実行して環境をセットアップする為のコンテナ」「rails sする為のコンテナ」という具合に用途でコンテナを分ける "1イメージ : Nコンテナ" 想定で
  • GitHub ActionsでのCIも開発環境と同じ Dockerfile/docker-compose.yml をそのまま活用してセットアップしたい
    • CIでも bundle installrails srspec などのコマンドは全てdocker-compose {run,exec} を介してコンテナ内で実行する(したい)
    • 「CI用のDockerfile」とか「CI時だけoverrideする為のdocker-compose.yml」は極力作りたくない
  • GitHub ActionsではDockerイメージや依存ライブラリのキャッシュを有効活用してCIを高速化したい

Version

  • Ruby 2.7.1
    • gem 3.1.3
    • bundler 2.1.4
  • Rails 6.0.3 (API mode)
  • Docker (Docker for Mac)
    • Engine 19.03.8
    • Compose 1.25.5
  • MySQL 8.0.20

Dockerfile

./Dockerfile
# このDockerfileとdocker-compose.ymlの書き方については、
# TechRachoさんの記事 https://techracho.bpsinc.jp/hachi8833/2019_09_06/79035
# で紹介されていた手法をベースにしています。(感謝)

ARG ARG_RUBY_VERSION

FROM ruby:${ARG_RUBY_VERSION}-alpine3.11

# hadolint ignore=DL3008,DL3018
RUN apk update && \
  apk add --update --no-cache \
    build-base \
    bash \
    curl \
    git \
    less \
    tzdata \
    mysql-client \
    mysql-dev && \
  rm -rf /var/cache/apk/*

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

ARG ARG_TZ=Asia/Tokyo
RUN cp /usr/share/zoneinfo/${ARG_TZ} /etc/localtime

ARG ARG_GEM_HOME=/bundle
ENV GEM_HOME=${ARG_GEM_HOME}

ENV BUNDLE_JOBS=4 \
  BUNDLE_RETRY=3 \
  BUNDLE_PATH=${GEM_HOME} \
  BUNDLE_BIN=${GEM_HOME}/bin \
  BUNDLE_APP_CONFIG=${GEM_HOME} \
  LANG=C.UTF-8 \
  LC_ALL=C.UTF-8

ARG ARG_BUNDLER_VERSION="2.1.4"
RUN gem update --system && \
    gem install bundler:${ARG_BUNDLER_VERSION}

ENV APP_ROOT=/app
RUN mkdir ${APP_ROOT}
ENV PATH=${APP_ROOT}/bin:${BUNDLE_BIN}:${GEM_HOME}/gems/bin:${PATH}
WORKDIR ${APP_ROOT}

ENV RAILS_ENV=development

ARG ARG_COMPOSE_WAIT_VER=2.7.3
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${ARG_COMPOSE_WAIT_VER}/wait /wait
RUN chmod +x /wait

アプリのファイルをイメージに含めない

CMDENTRYPOINTによる処理は定義せず、Railsアプリが動く環境を整える事にのみ特化してイメージのサイズを小さくしています。bundle installrails s といった処理は docker-compose {run,exec}を介してコンテナ内で実行し、ライブラリやアプリのファイルはvolumesでマウントしてコンテナにコピーする(イメージには含めないようにする)事を意図しています。

ufoscout/docker-compose-wait でDB起動を待つ

ARG ARG_COMPOSE_WAIT_VER=2.7.3
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${ARG_COMPOSE_WAIT_VER}/wait /wait
RUN chmod +x /wait

Dockerfileの末尾3行部分で ufoscout/docker-compose-wait をインストールしています。これはmysql等のミドルウェア・コンテナのポートがLISTEN状態になるのを待ってくれるRust製のツールで、名前の通りdocker-compose.ymlとの併用が意図されています。
依存ミドルウェア起動をどうやって待つか?については、netcatやdockerizeを使った例だったり、公式のPostgresの例ではシェルスクリプトを書いて頑張る例が紹介されていたりしますが(Badの反応が多いのが気になりますが...)、このツールはミドルウェアの追加・削除時もdocker-compose.ymlに少し記述を追加するだけで対応できますし動作も確実性が高くて使い勝手が良かったです。具体的な使い方は後述のdocker-compose.ymlやGitHub Actionsに関する節で解説します。

docker-compose.yml

./docker-compose.yml
version: "3.7"

services:
  db:
    image: mysql:8.0.20
    command: --default-authentication-plugin=mysql_native_password
    environment:
      - MYSQL_ROOT_PASSWORD
      - MYSQL_ALLOW_EMPTY_PASSWORD
    volumes:
      - mysql-data:/var/lib/mysql
    ports:
      - ${MYSQL_FORWARDED_PORT:-3306}:3306
      - ${MYSQL_FORWARDED_X_PORT:-33060}:33060

  base: &base
    build:
      context: .
      dockerfile: ./Dockerfile
      cache_from:
        - rails6api-development-cache
      args:
        ARG_RUBY_VERSION: ${ARG_RUBY_VERSION:-"2.7.1"}
    image: rails6api-development:0.1.0
    tmpfs:
      - /tmp

  wait-middleware: &wait-middleware
    <<: *base
    environment:
      WAIT_HOSTS: db:3306
    depends_on:
      - db
    command: /wait

  backend: &backend
    <<: *base
    stdin_open: true
    tty: true
    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache
    depends_on:
      - db

  console:
    <<: *backend
    ports:
      - 3333:3000
    command: /bin/bash

  server:
    <<: *backend
    ports:
      - 3333:3000
    command: bash -c "rm -f tmp/pids/server.pid && rails s -b 0.0.0.0"

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

1つのDockerfileでdocker-composeの複数サービスを定義する

  base: &base
    build:
      context: .
      dockerfile: ./Dockerfile
      cache_from:
        - rails6api-development-cache
      args:
        ARG_RUBY_VERSION: ${ARG_RUBY_VERSION:-"2.7.1"}
    image: rails6api-development:0.1.0

docker-compose.ymlのbaseサービス以降の記述が先程のDockerfileを利用するサービスの設定になります。Dockerfileの冒頭で入力を期待しているARG_RUBY_VERSION ARGについては、「環境変数で指定されていたらその内容を、未設定時のデフォルト値は2.7.1を」指定するようにargsにて定義しています。

baseサービスは、それ自体がcommandやentrypointによる処理を行ってはおらず、単にビルドする為だけのサービスとして定義しています。&base とエイリアスを定義している事からも分かるように、これを後続サービスでマージして利用しています(後述)。ビルドに関する設定はこのbaseサービスにのみ集約してあるので、buildセクションの設定はこれ以降のサービスには出てきません。

baseサービスのポイントはcache_fromrails6api-development-cacheを指定している事です。この設定は開発作業時ではなくCI時での利用を想定したものです。詳細は後述します。

  wait-middleware: &wait-middleware
    <<: *base
    environment:
      WAIT_HOSTS: db:3306
    depends_on:
      - db
    command: /wait

このサービス定義が、Dockerfileの最後でインストールしたufoscout/docker-compose-waitを使ってdbサービスの起動を待つ為のサービスです。ymlの定義方法は公式を参照ください。先に定義したbaseサービスをmergeし、docker-compose-waitで必要な設定とdbサービスとの関連を定義しています。単独で実行したい場合はdocker-compose runすればOKです。

$ docker-compose run --rm wait-middleware

Creating network "rails6api_default" with the default driver
Creating rails6api_db_1    ... done
--------------------------------------------------------
 docker-compose-wait 2.7.3
---------------------------
Starting with configuration:
 - Hosts to be waiting for: [db:3306]
 - Timeout before failure: 30 seconds
 - TCP connection timeout before retry: 5 seconds
 - Sleeping time before checking for hosts availability: 0 seconds
 - Sleeping time once all hosts are available: 0 seconds
 - Sleeping time between retries: 1 seconds
--------------------------------------------------------
Checking availability of db:3306
Host db:3306 not yet available...
Host db:3306 is now available!
--------------------------------------------------------
docker-compose-wait - Everything's fine, the application can now start!

上記実行例は筆者のMacでのもので、ほとんど待ちが発生せずdbが立ち上がります。この速さならdepends_on で起動順さえ意識しておけば「DBが立ち上がっていない状態でアプリが動きそうになってエラー」という状況はほぼ発生しないのですが、GitHub ActionsのCI環境ではこの速さでは起動してくれず、wait-middlewareの効果が大きくなります。これについても後述します。

  backend: &backend
    <<: *base
    stdin_open: true
    tty: true
    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache
    depends_on:
      - db

  console:
    <<: *backend
    ports:
      - 3333:3000
    command: /bin/bash

  server:
    <<: *backend
    ports:
      - 3333:3000
    command: bash -c "rm -f tmp/pids/server.pid && rails s -b 0.0.0.0"

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

bashログインしてのプロンプト作業や rails s する為のサービス定義と、volumeの定義部分です。TechRachoさんの記事 で紹介されていた書き方を流用させてもらっています。

consoleserverの両サービスがbackendというサービス定義をマージしているのですが、このbackendのvolumesで ${GEMS_CACHE_DIR:-bundle-cache}:/bundle と定義されているvolumeはbundle install先のディレクトリで、「環境変数GEMS_CACHE_DIRがセットされていればその内容で、セットされていなければbundle-cacheという名前のnamed volumeでマウント」する事を意図しており、CIの為にこのような設定を行っています。これも詳細は後述します。

.env

./.env
MYSQL_ROOT_PASSWORD=root
MYSQL_ALLOW_EMPTY_PASSWORD=1
DB_HOST=db

MYSQL_FORWARDED_PORT=3806
MYSQL_FORWARDED_X_PORT=38060

docker-compose.ymlのdbサービス内の環境変数.envの内容から展開 しています。

.github/workflows/ci.yml

./.github/workflows/ci.yml
on:
  push:
    branches:
      - master
    paths-ignore:
      - '**/*.md'
      - 'LICENSE'
  pull_request:
    paths-ignore:
      - '**/*.md'
      - 'LICENSE'

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache
  IMAGE_CACHE_DIR: /tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image

jobs:
  image-cache-or-build:
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Docker tag and save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: mkdir -p ${IMAGE_CACHE_DIR}
        && docker image tag ${APP_IMAGE_TAG} ${APP_IMAGE_CACHE_TAG}
        && docker image save -o ${IMAGE_CACHE_DIR}/image.tar ${APP_IMAGE_CACHE_TAG}


  test-app:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      GEMS_CACHE_DIR: /tmp/cache/bundle
      GEMS_CACHE_KEY: cache-gems

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker compose build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Wait middleware services
      id: wait-middleware
      run: docker-compose run --rm wait-middleware

    - name: Confirm docker-compose logs
      id: confirm-docker-compose-logs
      run: docker-compose logs db

    - name: Cache bundle gems
      id: cache-bundle-gems
      uses: actions/cache@v1
      with:
        path: ${{ env.GEMS_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Setup and Run test
      id: setup-and-run-test
      run: docker-compose run --rm console bash -c "bundle install && rails db:prepare && rspec"


  scan-image-by-trivy:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      TRIVY_CACHE_DIR: /tmp/cache/trivy

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Scan image
      id: scan-image
      run: docker container run
        --rm
        -v /var/run/docker.sock:/var/run/docker.sock
        -v ${TRIVY_CACHE_DIR}:/root/.cache/
        aquasec/trivy
        ${APP_IMAGE_CACHE_TAG}

BuildKitでビルドをちょっと高速化

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1

グローバルな環境変数でdocker-compose向けにBuildKitを有効化しています。今回のDockerfileはmulti-stageでも無くBuildKitによる恩恵はそこまで大きくはないのですが、有効化した事でビルド時間が速くなった(12%程度削減)ので有効化しています。

先にイメージのキャッシュ・リストアを実行し、このキャッシュで後続ジョブを並列に動かす

jobs:
  # Dockerイメージのキャッシュ・リストア
  image-cache-or-build:

  # アプリのテスト
  test-app:
    needs: image-cache-or-build

  # イメージの脆弱性スキャン
  scan-image-by-trivy:
    needs: image-cache-or-build

最初に必ずDockerイメージのキャッシュリストア(キャッシュが無ければ新規ビルド→キャッシュ生成)を行い、後続のアプリテスト&イメージスキャンはこのキャッシュからリストアしたイメージを使って実行するようにします。アプリテストとイメージスキャンは並列実行でも構わないので並列にしています。

docker image save, docker image load, cache_from with BuildKit でイメージのキャッシュ・リストアとビルド

jobs:
  image-cache-or-build:
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

Dockerイメージのキャッシュリストアは、公式のキャッシュ処理用actionであるactions/cacheで行います。キャッシュのキーに ${{ hashFiles('Dockerfile') }} を含めているのは、Dockerfileに変更があった際にキャッシュHITさせないようにする事を意図したものです。

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache

# 略

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Docker tag and save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: mkdir -p ${IMAGE_CACHE_DIR}
        && docker image tag ${APP_IMAGE_TAG} ${APP_IMAGE_CACHE_TAG}
        && docker image save -o ${IMAGE_CACHE_DIR}/image.tar ${APP_IMAGE_CACHE_TAG}

上記step群の処理をまとめると、「ビルドされたイメージに rails6api-development-cache というタグを付与してtarに保存し、actions/cache のキャッシュ先ディレクトリに image.tar という名前で保存する」という処理を行っています。

キャッシュHITの有無で処理の流れは下記のように変わります。

  • キャッシュがHITしなかった場合
    • docker-build のstepで新規にイメージがビルドされます
    • actions/cacheでキャッシュ先として指定した${IMAGE_CACHE_DIR}をmkdirします
    • ビルド結果のイメージに別途「キャッシュ用のタグ」を付与します
      • = APP_IMAGE_CACHE_TAG = rails6api-development-cache
    • 「キャッシュ用のタグ」 = rails6api-development-cache を付与したイメージをdocker image saveで保存します。この保存先にactions/cacheでのキャッシュ先ディレクトリを指定します(ファイル名は image.tar)
  • キャッシュがHITした場合
    • docker-load のstepで、キャッシュからリストアされたimage.tarが docker image load によって展開されます
      • 展開されるイメージには「キャッシュ用のタグ」 = rails6api-development-cache が付与されています
    • docker-build のstepでイメージがビルドされますが、先のloadのstepで展開されたイメージによって cache_from rails6api-development-cache の指定が効き、このビルドはすぐに終わります
    • tagとsaveのstepは if: steps.cache-docker-image.outputs.cache-hit != 'true' の指定によりSKIPされます

キャッシュ・リストアしたイメージを使って高速CI

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache

# 略

jobs:

# 略

  test-app:
    needs: image-cache-or-build

    # 略

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker compose build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

先述のイメージのビルド&キャッシュjobが完了すると、アプリのテストを行うtest-appが起動します。docker-loadのstepでは「キャッシュ用のタグ」 = rails6api-development-cache が付与されているイメージが展開され、docker-buildのstepでこのイメージをcache_fromによって取り込んでbaseサービスのイメージ(= rails6api-development:0.1.0 タグが付与されたイメージ)をビルドします。

ufoscout/docker-compose-wait でMySQLコンテナの起動を待つ

    - name: Wait middleware services
      id: wait-middleware
      run: docker-compose run --rm wait-middleware

    - name: Confirm docker-compose logs
      id: confirm-docker-compose-logs
      run: docker-compose logs db

Dockerfileの最後でインストールしてあるufoscout/docker-compose-waitを使ってdbサービスの起動を待ちます。

Starting with configuration:
 - Hosts to be waiting for: [db:3306]
 - Timeout before failure: 30 seconds 
 - TCP connection timeout before retry: 5 seconds 
 - Sleeping time before checking for hosts availability: 0 seconds
 - Sleeping time once all hosts are available: 0 seconds
 - Sleeping time between retries: 1 seconds
--------------------------------------------------------
Checking availability of db:3306
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 is now available!
--------------------------------------------------------
docker-compose-wait - Everything's fine, the application can now start!
--------------------------------------------------------

以前に「GitHub ActionsのCI環境ではこの速さでは起動してくれず、wait-middlewareの効果が大きくなります」と書きましたが、上記ログがGitHub Actionsのrunnerインスタンス上での実行例で(Host db:3306 not yet available... でsleepを1秒挟んでいます)、ポート3306のLISTENまでに10秒以上掛かっています。このログ例のみならず、何度実行しても平均的に10秒超は掛かっていました。仮にこの所要時間でwaitするstepを挟まない(depends_onを指定するのみ)とすると、MySQL起動前に後続のRailsアプリに関するstepが走ってしまいエラーになるでしょう。

余談: MySQLコンテナの起動プロセスと処理時間

db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.20-1debian10 started.
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.20-1debian10 started.
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Initializing database files
db_1               | 2020-05-25T16:36:40.990281Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:40.990349Z 0 [System] [MY-013169] [Server] /usr/sbin/mysqld (mysqld 8.0.20) initializing of server in progress as process 45
db_1               | 2020-05-25T16:36:40.996092Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:42.100882Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:43.312805Z 6 [Warning] [MY-010453] [Server] root@localhost is created with an empty password ! Please consider switching off the --initialize-insecure option.
db_1               | 2020-05-25 16:36:46+00:00 [Note] [Entrypoint]: Database files initialized
db_1               | 2020-05-25 16:36:46+00:00 [Note] [Entrypoint]: Starting temporary server
db_1               | 2020-05-25T16:36:46.494377Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:46.494485Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.20) starting as process 92
db_1               | 2020-05-25T16:36:46.507413Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:46.819578Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:46.915827Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Socket: '/var/run/mysqld/mysqlx.sock'
db_1               | 2020-05-25T16:36:47.015509Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
db_1               | 2020-05-25T16:36:47.017398Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
db_1               | 2020-05-25T16:36:47.034485Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.20'  socket: '/var/run/mysqld/mysqld.sock'  port: 0  MySQL Community Server - GPL.

db_1               | 2020-05-25 16:36:47+00:00 [Note] [Entrypoint]: Temporary server started.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/iso3166.tab' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/leap-seconds.list' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/zone.tab' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/zone1970.tab' as time zone. Skipping it.
db_1               | 
db_1               | 2020-05-25 16:36:49+00:00 [Note] [Entrypoint]: Stopping temporary server
db_1               | 2020-05-25T16:36:49.538277Z 10 [System] [MY-013172] [Server] Received SHUTDOWN from user root. Shutting down mysqld (Version: 8.0.20).
db_1               | 2020-05-25T16:36:51.341063Z 0 [System] [MY-010910] [Server] /usr/sbin/mysqld: Shutdown complete (mysqld 8.0.20)  MySQL Community Server - GPL.
db_1               | 2020-05-25 16:36:51+00:00 [Note] [Entrypoint]: Temporary server stopped
db_1               | 
db_1               | 2020-05-25 16:36:51+00:00 [Note] [Entrypoint]: MySQL init process done. Ready for start up.
db_1               | 
db_1               | 2020-05-25T16:36:51.806387Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:51.806498Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.20) starting as process 1
db_1               | 2020-05-25T16:36:51.816008Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:52.191532Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:52.286349Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Socket: '/var/run/mysqld/mysqlx.sock' bind-address: '::' port: 33060
db_1               | 2020-05-25T16:36:52.341936Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
db_1               | 2020-05-25T16:36:52.345030Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
db_1               | 2020-05-25T16:36:52.363785Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.20'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server - GPL.

上記はMySQLコンテナ(dbサービス)起動時のログを docker-compose logs dbで確認した際の例です。

  • Initializing database files から Database files initialized で6秒
  • Starting temporary server から Temporary server stopped で5秒

この2処理で所要時間をほぼ半分ずつ要しています。

依存gemのvolumeマウントはnamed volumeではなく書き込み可能なディレクトリを使う

    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      GEMS_CACHE_DIR: /tmp/cache/bundle
      GEMS_CACHE_KEY: cache-gems

    # 略

    - name: Cache bundle gems
      id: cache-bundle-gems
      uses: actions/cache@v1
      with:
        path: ${{ env.GEMS_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-

依存Gemのキャッシュリストアも、公式のキャッシュ処理用actionであるactions/cacheで行います。キャッシュのキーに ${{ hashFiles('Gemfile.lock') }} を含めているのは、Gemfile.lock(Gemfile)に変更があった際にキャッシュHITさせないようにする事を意図したものです。

./docker-compose.yml
  backend: &backend

    # 略

    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache

# 略

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

全てをDockerで行おうとしているので、bundle installもコンテナ内で行います。つまりインストールされたgemをキャッシュしたければ、volume mount先からインストール結果を取り出さなければなりません。

↑のdocker-compose.ymlを紹介した際に「環境変数GEMS_CACHE_DIRがセットされていればその内容で、セットされていなければbundle-cacheという名前のnamed volumeでマウント」と書いたのですが、このcache-bundle-gemsのstepがまさに「環境変数GEMS_CACHE_DIRがセットされていれば」なケースに該当します。これは「named volumeではなくマウント先のパスを環境変数で明示する」のが意図です。

公式のvolumesのSHORT SYNTAXによると、パスが指定されていればそのパスが、固定文字列が指定されていればその名前のnamed volumeが、それぞれマウントされます。開発作業時はGEMS_CACHE_DIRを明示せずデフォルトの固定文字列(= named volume =bundle-cache)を使用しても良いですが、actions/cache で内容をキャッシュしようとした場合、そのディレクトリとしてnamed volumeの実体(具体的には /var/lib/docker/volumes/xxx というパス)を指定するとpermission deniedエラーでキャッシュに失敗してしまいます。なのでこれを回避する為にCI時のみGEMS_CACHE_DIRとして /tmp/cache/bundle という(permission deniedにならない)ディレクトリを明示しています。これによりCI時のbundle install結果はこのGEMS_CACHE_DIRディレクトリに出力され、actions/cacheでディレクトリが丸ごとキャッシュされます。

この記述は正直分かりやすいとは言えないので、LONG SYNTAXを利用して分かりにくさを軽減したいところなのですが、今回は「環境変数の中身によってvolume typeが変えられる」「1つのdocker-compose.ymlを開発作業とCIで併用しやすい」というメリットを優先してSHORT SYNTAXを採用しました。

アプリのセットアップ&テストもDockerコンテナ内で実行

    - name: Setup and Run test
      id: setup-and-run-test
      run: docker-compose run --rm console bash -c "bundle install && rails db:prepare && rspec"

このstepは構築するアプリの仕様ややりたい事次第で変わると思いますが、一応今回の例を紹介しておくと bundle install(結果は先述の通りキャッシュされる) → db:prepareでDBセットアップ(参考) → テスト(今回使ったアプリではrspecを使用しています)、という順にテストまで実施しています。それぞれのコマンドをrunnerインスタンス上で直接実行するのではなく、docker-compose runconsoleサービスを立ち上げてその中で実行するようにしています。

アプリのテストと並列でDockerイメージの脆弱性スキャンも実行

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache
  IMAGE_CACHE_DIR: /tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image

# 略

  scan-image-by-trivy:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      TRIVY_CACHE_DIR: /tmp/cache/trivy

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Scan image
      id: scan-image
      run: docker container run
        --rm
        -v /var/run/docker.sock:/var/run/docker.sock
        -v ${TRIVY_CACHE_DIR}:/root/.cache/
        aquasec/trivy
        ${APP_IMAGE_CACHE_TAG}

開発〜CIをDockerで完結させようとしているので、折角なのでCI時にDockerイメージの脆弱性スキャンも行っておきたいです。今回は aquasecurity/trivy を使わせてもらいました。Docker完結を目指しているので、trivyによるスキャンも公式に提供されているDockerで行います。

needs: image-cache-or-build によってイメージビルド&キャッシュが完了済なので、スキャンもそのキャッシュをload・展開したイメージに対して実施して高速化します(スキャン対象として ${APP_IMAGE_CACHE_TAG} = rails6api-development-cache を指定)。
毎回 aquasec/trivy をpullする事でスキャンそのものの仕様を常に最新化しているので、Dockerfileに変更がなくてもスキャンを実行するようにしています。

開発作業のユースケース例

新規参画エンジニアの環境構築手順は?

# ビルド
docker-compose build base

# セットアップ
docker-compose run --rm console bash -c "bundle install && rails db:prepare && rails db:seed"

# 起動
docker-compose up -d server

railsを起動したい時は?

docker-compose up -d server

起動中のRailsログをコンソールに流しておきたい時は?

# 下記コマンドでattachすれば、server コンテナの標準出力をtail風に確認可能
docker attach `docker-compose ps -q server`

# attach状態を終了したければ Ctrl+P => Ctrl+Q する

テスト(rspec)を実行したい時は?

起動中のserverサービスで

docker-compose exec server rspec [SPEC_FILES]

consoleサービスで

docker-compose run --rm console rspec [SPEC_FILES]

Rails consoleに接続したい時は?

起動中のserverサービスで

docker-compose exec server rails c

マイグレーションを追加・修正・適用したい時は?

起動中のserverサービスで

# マイグレーション新規生成
docker-compose exec server rails g migration MIGRATION_NAME

# :
# マイグレーションファイルを適宜修正
# :

# マイグレーションの適用
docker-compose exec server rails db:migrate
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

開発もGitHub ActionsでのCIも全てDockerで行う (Rails 6 API編)

Rails on GitHub Actions(或いは {Django,Laravel} on GitHub Actions)のCI事例として、

  • ホストランナー上にRuby(Python, PHP)をセットアップ
  • MySQLやRedisはサービスコンテナで立ち上げ
  • 依存ライブラリのインストール(bundle install) や ユニットテスト(rspec) もホストランナー上で直接実行

という事例は多く見かけるのですが、開発をDockerベースで行っていて、GitHub ActionsのCI Pipelineも同じくDockerベースで構築したい...というケースの事例があまり見当たらなかったので、自分が関わったプロジェクト(Rails 6 API mode)での事例を紹介します。

Requirements

  • 開発環境は全てDocker(Dockerfile/docker-compose.yml)で構築&管理したい
    • bundle installrails srspec などのコマンドは全てdocker-compose {run,exec} を介してコンテナ内で実行する(したい)
  • Dockerイメージのサイズを小さくしたい
    • Dockerイメージ自体でファイルを抱え込むような処理はなるべく書かない。実行したいコマンドはコンテナ起動時に都度コマンドとして付与し、成果物を保存・永続化したければvolumesを活用する
    • bundle install, rails db:prepareを実行して環境をセットアップする為のコンテナ」「rails sする為のコンテナ」という具合に用途でコンテナを分ける "1イメージ : Nコンテナ" 想定で
  • GitHub ActionsでのCIも開発環境と同じ Dockerfile/docker-compose.yml をそのまま活用してセットアップしたい
    • CIでも bundle installrails srspec などのコマンドは全てdocker-compose {run,exec} を介してコンテナ内で実行する(したい)
    • 「CI用のDockerfile」とか「CI時だけoverrideする為のdocker-compose.yml」は極力作りたくない
  • GitHub ActionsではDockerイメージや依存ライブラリのキャッシュを有効活用してCIを高速化したい

Version

  • Ruby 2.7.1
    • gem 3.1.3
    • bundler 2.1.4
  • Rails 6.0.3 (API mode)
  • Docker (Docker for Mac)
    • Engine 19.03.8
    • Compose 1.25.5
  • MySQL 8.0.20

Contents

Dockerfile

./Dockerfile
# このDockerfileとdocker-compose.ymlの書き方については、
# TechRachoさんの記事 https://techracho.bpsinc.jp/hachi8833/2019_09_06/79035
# で紹介されていた手法をベースにしています。(感謝)

ARG ARG_RUBY_VERSION

FROM ruby:${ARG_RUBY_VERSION}-alpine3.11

# hadolint ignore=DL3008,DL3018
RUN apk update && \
  apk add --update --no-cache \
    build-base \
    bash \
    curl \
    git \
    less \
    tzdata \
    mysql-client \
    mysql-dev && \
  rm -rf /var/cache/apk/*

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

ARG ARG_TZ=Asia/Tokyo
RUN cp /usr/share/zoneinfo/${ARG_TZ} /etc/localtime

ARG ARG_GEM_HOME=/bundle
ENV GEM_HOME=${ARG_GEM_HOME}

ENV BUNDLE_JOBS=4 \
  BUNDLE_RETRY=3 \
  BUNDLE_PATH=${GEM_HOME} \
  BUNDLE_BIN=${GEM_HOME}/bin \
  BUNDLE_APP_CONFIG=${GEM_HOME} \
  LANG=C.UTF-8 \
  LC_ALL=C.UTF-8

ARG ARG_BUNDLER_VERSION="2.1.4"
RUN gem update --system && \
    gem install bundler:${ARG_BUNDLER_VERSION}

ENV APP_ROOT=/app
RUN mkdir ${APP_ROOT}
ENV PATH=${APP_ROOT}/bin:${BUNDLE_BIN}:${GEM_HOME}/gems/bin:${PATH}
WORKDIR ${APP_ROOT}

ENV RAILS_ENV=development

ARG ARG_COMPOSE_WAIT_VER=2.7.3
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${ARG_COMPOSE_WAIT_VER}/wait /wait
RUN chmod +x /wait

Railsアプリのファイルをイメージ(Dockerfile)に含めない

CMDENTRYPOINTによる処理は定義せず、Railsアプリが動く環境を整える事にのみ特化してイメージのサイズを小さくしています。bundle installrails s といった処理は docker-compose {run,exec}を介してコンテナ内で実行し、ライブラリやアプリのファイルはvolumesでマウントしてコンテナにコピーする(イメージには含めないようにする)事を意図しています。

DB起動を待つ処理用途で ufoscout/docker-compose-wait を導入

ARG ARG_COMPOSE_WAIT_VER=2.7.3
ADD https://github.com/ufoscout/docker-compose-wait/releases/download/${ARG_COMPOSE_WAIT_VER}/wait /wait
RUN chmod +x /wait

Dockerfileの末尾3行部分で ufoscout/docker-compose-wait をインストールしています。これはmysql等のミドルウェア・コンテナのポートがLISTEN状態になるのを待ってくれるRust製のツールで、名前の通りdocker-compose.ymlとの併用が意図されています。
依存ミドルウェア起動をどうやって待つか?については、netcatやdockerizeを使った例だったり、公式のPostgresの例ではシェルスクリプトを書いて頑張る例が紹介されていたりしますが(Badの反応が多いのが気になりますが...)、このツールはミドルウェアの追加・削除時もdocker-compose.ymlに少し記述を追加するだけで対応できますし動作も確実性が高くて使い勝手が良かったです。具体的な使い方は後述のdocker-compose.ymlやGitHub Actionsに関する節で解説します。

docker-compose.yml

./docker-compose.yml
version: "3.7"

services:
  db:
    image: mysql:8.0.20
    command: --default-authentication-plugin=mysql_native_password
    environment:
      - MYSQL_ROOT_PASSWORD
      - MYSQL_ALLOW_EMPTY_PASSWORD
    volumes:
      - mysql-data:/var/lib/mysql
    ports:
      - ${MYSQL_FORWARDED_PORT:-3306}:3306
      - ${MYSQL_FORWARDED_X_PORT:-33060}:33060

  base: &base
    build:
      context: .
      dockerfile: ./Dockerfile
      cache_from:
        - rails6api-development-cache
      args:
        ARG_RUBY_VERSION: ${ARG_RUBY_VERSION:-"2.7.1"}
    image: rails6api-development:0.1.0
    tmpfs:
      - /tmp

  wait-middleware: &wait-middleware
    <<: *base
    environment:
      WAIT_HOSTS: db:3306
    depends_on:
      - db
    command: /wait

  backend: &backend
    <<: *base
    stdin_open: true
    tty: true
    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache
    depends_on:
      - db

  console:
    <<: *backend
    ports:
      - 3333:3000
    command: /bin/bash

  server:
    <<: *backend
    ports:
      - 3333:3000
    command: bash -c "rm -f tmp/pids/server.pid && rails s -b 0.0.0.0"

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

1つのDockerfileでdocker-composeの複数サービスを定義する

  base: &base
    build:
      context: .
      dockerfile: ./Dockerfile
      cache_from:
        - rails6api-development-cache
      args:
        ARG_RUBY_VERSION: ${ARG_RUBY_VERSION:-"2.7.1"}
    image: rails6api-development:0.1.0

docker-compose.ymlのbaseサービス以降の記述が先程のDockerfileを利用するサービスの設定になります。Dockerfileの冒頭で入力を期待しているARG_RUBY_VERSION ARGについては、「環境変数で指定されていたらその内容を、未設定時のデフォルト値は2.7.1を」指定するようにargsにて定義しています。

baseサービスは、それ自体がcommandやentrypointによる処理を行ってはおらず、単にビルドする為だけのサービスとして定義しています。&base とエイリアスを定義している事からも分かるように、これを後続サービスでマージして利用しています(後述)。ビルドに関する設定はこのbaseサービスにのみ集約してあるので、buildセクションの設定はこれ以降のサービスには出てきません。

baseサービスのポイントはcache_fromrails6api-development-cacheを指定している事です。この設定は開発作業時ではなくCI時での利用を想定したものです。詳細は後述します。

  wait-middleware: &wait-middleware
    <<: *base
    environment:
      WAIT_HOSTS: db:3306
    depends_on:
      - db
    command: /wait

このサービス定義が、Dockerfileの最後でインストールしたufoscout/docker-compose-waitを使ってdbサービスの起動を待つ為のサービスです。ymlの定義方法は公式を参照ください。先に定義したbaseサービスをmergeし、docker-compose-waitで必要な設定とdbサービスとの関連を定義しています。単独で実行したい場合はdocker-compose runすればOKです。

$ docker-compose run --rm wait-middleware

Creating network "rails6api_default" with the default driver
Creating rails6api_db_1    ... done
--------------------------------------------------------
 docker-compose-wait 2.7.3
---------------------------
Starting with configuration:
 - Hosts to be waiting for: [db:3306]
 - Timeout before failure: 30 seconds
 - TCP connection timeout before retry: 5 seconds
 - Sleeping time before checking for hosts availability: 0 seconds
 - Sleeping time once all hosts are available: 0 seconds
 - Sleeping time between retries: 1 seconds
--------------------------------------------------------
Checking availability of db:3306
Host db:3306 not yet available...
Host db:3306 is now available!
--------------------------------------------------------
docker-compose-wait - Everything's fine, the application can now start!

上記実行例は筆者のMacでのもので、ほとんど待ちが発生せずdbが立ち上がります。この速さならdepends_on で起動順さえ意識しておけば「DBが立ち上がっていない状態でアプリが動きそうになってエラー」という状況はほぼ発生しないのですが、GitHub ActionsのCI環境ではこの速さでは起動してくれず、wait-middlewareの効果が大きくなります。これについても後述します。

  backend: &backend
    <<: *base
    stdin_open: true
    tty: true
    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache
    depends_on:
      - db

  console:
    <<: *backend
    ports:
      - 3333:3000
    command: /bin/bash

  server:
    <<: *backend
    ports:
      - 3333:3000
    command: bash -c "rm -f tmp/pids/server.pid && rails s -b 0.0.0.0"

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

bashログインしてのプロンプト作業や rails s する為のサービス定義と、volumeの定義部分です。TechRachoさんの記事 で紹介されていた書き方を流用させてもらっています。

consoleserverの両サービスがbackendというサービス定義をマージしているのですが、このbackendのvolumesで ${GEMS_CACHE_DIR:-bundle-cache}:/bundle と定義されているvolumeはbundle install先のディレクトリで、「環境変数GEMS_CACHE_DIRがセットされていればその内容で、セットされていなければbundle-cacheという名前のnamed volumeでマウント」する事を意図しており、CIの為にこのような設定を行っています。これも詳細は後述します。

.env

./.env
MYSQL_ROOT_PASSWORD=root
MYSQL_ALLOW_EMPTY_PASSWORD=1
DB_HOST=db

MYSQL_FORWARDED_PORT=3806
MYSQL_FORWARDED_X_PORT=38060

docker-compose.ymlのdbサービス内の環境変数.envの内容から展開 しています。

.github/workflows/ci.yml

./.github/workflows/ci.yml
on:
  push:
    branches:
      - master
    paths-ignore:
      - '**/*.md'
      - 'LICENSE'
  pull_request:
    paths-ignore:
      - '**/*.md'
      - 'LICENSE'

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache
  IMAGE_CACHE_DIR: /tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image

jobs:
  image-cache-or-build:
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Docker tag and save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: mkdir -p ${IMAGE_CACHE_DIR}
        && docker image tag ${APP_IMAGE_TAG} ${APP_IMAGE_CACHE_TAG}
        && docker image save -o ${IMAGE_CACHE_DIR}/image.tar ${APP_IMAGE_CACHE_TAG}


  test-app:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      GEMS_CACHE_DIR: /tmp/cache/bundle
      GEMS_CACHE_KEY: cache-gems

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker compose build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Wait middleware services
      id: wait-middleware
      run: docker-compose run --rm wait-middleware

    - name: Confirm docker-compose logs
      id: confirm-docker-compose-logs
      run: docker-compose logs db

    - name: Cache bundle gems
      id: cache-bundle-gems
      uses: actions/cache@v1
      with:
        path: ${{ env.GEMS_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Setup and Run test
      id: setup-and-run-test
      run: docker-compose run --rm console bash -c "bundle install && rails db:prepare && rspec"


  scan-image-by-trivy:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      TRIVY_CACHE_DIR: /tmp/cache/trivy

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Scan image
      id: scan-image
      run: docker container run
        --rm
        -v /var/run/docker.sock:/var/run/docker.sock
        -v ${TRIVY_CACHE_DIR}:/root/.cache/
        aquasec/trivy
        ${APP_IMAGE_CACHE_TAG}

BuildKitを有効化して(ちょっとだけ)ビルドを高速化

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1

グローバルな環境変数でdocker-compose向けにBuildKitを有効化しています。今回のDockerfileはmulti-stageでも無くBuildKitによる恩恵はそこまで大きくはないのですが、有効化した事でビルド時間が速くなった(12%程度削減)ので有効化しています。

Dockerイメージのキャッシュ・リストア, アプリのテスト, イメージの脆弱性スキャン, の3ジョブ構成

jobs:
  # Dockerイメージのキャッシュ・リストア
  image-cache-or-build:

  # アプリのテスト
  test-app:
    needs: image-cache-or-build

  # イメージの脆弱性スキャン
  scan-image-by-trivy:
    needs: image-cache-or-build

最初に必ずDockerイメージのキャッシュリストア(キャッシュが無ければ新規ビルド→キャッシュ生成)を行い、後続のアプリテスト&イメージスキャンはこのキャッシュからリストアしたイメージを使って実行するようにします。アプリテストとイメージスキャンは並列実行でも構わないので並列にしています。

docker image save + docker image load + cache_from with BuildKit を駆使したDockerイメージのキャッシュ・リストア

jobs:
  image-cache-or-build:
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Generate dotenv
      id: generate-dotenv
      run: cp .env.sample .env

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

Dockerイメージのキャッシュリストアは、公式のキャッシュ処理用actionであるactions/cacheで行います。キャッシュのキーに ${{ hashFiles('Dockerfile') }} を含めているのは、Dockerfileに変更があった際にキャッシュHITさせないようにする事を意図したものです。

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache

# 略

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

    - name: Docker tag and save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: mkdir -p ${IMAGE_CACHE_DIR}
        && docker image tag ${APP_IMAGE_TAG} ${APP_IMAGE_CACHE_TAG}
        && docker image save -o ${IMAGE_CACHE_DIR}/image.tar ${APP_IMAGE_CACHE_TAG}

上記step群の処理をまとめると、「ビルドされたイメージに rails6api-development-cache というタグを付与してtarに保存し、actions/cache のキャッシュ先ディレクトリに image.tar という名前で保存する」という処理を行っています。

キャッシュHITの有無で処理の流れは下記のように変わります。

  • キャッシュがHITしなかった場合
    • docker-build のstepで新規にイメージがビルドされます
    • actions/cacheでキャッシュ先として指定した${IMAGE_CACHE_DIR}をmkdirします
    • ビルド結果のイメージに別途「キャッシュ用のタグ」を付与します
      • = APP_IMAGE_CACHE_TAG = rails6api-development-cache
    • 「キャッシュ用のタグ」 = rails6api-development-cache を付与したイメージをdocker image saveで保存します。この保存先にactions/cacheでのキャッシュ先ディレクトリを指定します(ファイル名は image.tar)
  • キャッシュがHITした場合
    • docker-load のstepで、キャッシュからリストアされたimage.tarが docker image load によって展開されます
      • 展開されるイメージには「キャッシュ用のタグ」 = rails6api-development-cache が付与されています
    • docker-build のstepでイメージがビルドされますが、先のloadのstepで展開されたイメージによって cache_from rails6api-development-cache の指定が効き、このビルドはすぐに終わります
    • tagとsaveのstepは if: steps.cache-docker-image.outputs.cache-hit != 'true' の指定によりSKIPされます

キャッシュ済のイメージでアプリのCI(テスト)を実行する事で高速化を図る

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache

# 略

jobs:

# 略

  test-app:
    needs: image-cache-or-build

    # 略

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Docker compose build
      id: docker-build
      run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 base

先述のイメージのビルド&キャッシュjobが完了すると、アプリのテストを行うtest-appが起動します。docker-loadのstepでは「キャッシュ用のタグ」 = rails6api-development-cache が付与されているイメージが展開され、docker-buildのstepでこのイメージをcache_fromによって取り込んでbaseサービスのイメージ(= rails6api-development:0.1.0 タグが付与されたイメージ)をビルドします。

ufoscout/docker-compose-wait でMySQLコンテナの起動を待つ

    - name: Wait middleware services
      id: wait-middleware
      run: docker-compose run --rm wait-middleware

    - name: Confirm docker-compose logs
      id: confirm-docker-compose-logs
      run: docker-compose logs db

Dockerfileの最後でインストールしてあるufoscout/docker-compose-waitを使ってdbサービスの起動を待ちます。

Starting with configuration:
 - Hosts to be waiting for: [db:3306]
 - Timeout before failure: 30 seconds 
 - TCP connection timeout before retry: 5 seconds 
 - Sleeping time before checking for hosts availability: 0 seconds
 - Sleeping time once all hosts are available: 0 seconds
 - Sleeping time between retries: 1 seconds
--------------------------------------------------------
Checking availability of db:3306
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 not yet available...
Host db:3306 is now available!
--------------------------------------------------------
docker-compose-wait - Everything's fine, the application can now start!
--------------------------------------------------------

以前に「GitHub ActionsのCI環境ではこの速さでは起動してくれず、wait-middlewareの効果が大きくなります」と書きましたが、上記ログがGitHub Actionsのrunnerインスタンス上での実行例で(Host db:3306 not yet available... でsleepを1秒挟んでいます)、ポート3306のLISTENまでに10秒以上掛かっています。このログ例のみならず、何度実行しても平均的に10秒超は掛かっていました。仮にこの所要時間でwaitするstepを挟まない(depends_onを指定するのみ)とすると、MySQL起動前に後続のRailsアプリに関するstepが走ってしまいエラーになるでしょう。

余談: MySQLコンテナ起動プロセスのどの処理が遅いのか?
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.20-1debian10 started.
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.20-1debian10 started.
db_1               | 2020-05-25 16:36:40+00:00 [Note] [Entrypoint]: Initializing database files
db_1               | 2020-05-25T16:36:40.990281Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:40.990349Z 0 [System] [MY-013169] [Server] /usr/sbin/mysqld (mysqld 8.0.20) initializing of server in progress as process 45
db_1               | 2020-05-25T16:36:40.996092Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:42.100882Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:43.312805Z 6 [Warning] [MY-010453] [Server] root@localhost is created with an empty password ! Please consider switching off the --initialize-insecure option.
db_1               | 2020-05-25 16:36:46+00:00 [Note] [Entrypoint]: Database files initialized
db_1               | 2020-05-25 16:36:46+00:00 [Note] [Entrypoint]: Starting temporary server
db_1               | 2020-05-25T16:36:46.494377Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:46.494485Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.20) starting as process 92
db_1               | 2020-05-25T16:36:46.507413Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:46.819578Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:46.915827Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Socket: '/var/run/mysqld/mysqlx.sock'
db_1               | 2020-05-25T16:36:47.015509Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
db_1               | 2020-05-25T16:36:47.017398Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
db_1               | 2020-05-25T16:36:47.034485Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.20'  socket: '/var/run/mysqld/mysqld.sock'  port: 0  MySQL Community Server - GPL.

db_1               | 2020-05-25 16:36:47+00:00 [Note] [Entrypoint]: Temporary server started.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/iso3166.tab' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/leap-seconds.list' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/zone.tab' as time zone. Skipping it.
db_1               | Warning: Unable to load '/usr/share/zoneinfo/zone1970.tab' as time zone. Skipping it.
db_1               | 
db_1               | 2020-05-25 16:36:49+00:00 [Note] [Entrypoint]: Stopping temporary server
db_1               | 2020-05-25T16:36:49.538277Z 10 [System] [MY-013172] [Server] Received SHUTDOWN from user root. Shutting down mysqld (Version: 8.0.20).
db_1               | 2020-05-25T16:36:51.341063Z 0 [System] [MY-010910] [Server] /usr/sbin/mysqld: Shutdown complete (mysqld 8.0.20)  MySQL Community Server - GPL.
db_1               | 2020-05-25 16:36:51+00:00 [Note] [Entrypoint]: Temporary server stopped
db_1               | 
db_1               | 2020-05-25 16:36:51+00:00 [Note] [Entrypoint]: MySQL init process done. Ready for start up.
db_1               | 
db_1               | 2020-05-25T16:36:51.806387Z 0 [Warning] [MY-011070] [Server] 'Disabling symbolic links using --skip-symbolic-links (or equivalent) is the default. Consider not using this option as it' is deprecated and will be removed in a future release.
db_1               | 2020-05-25T16:36:51.806498Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.20) starting as process 1
db_1               | 2020-05-25T16:36:51.816008Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1               | 2020-05-25T16:36:52.191532Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1               | 2020-05-25T16:36:52.286349Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Socket: '/var/run/mysqld/mysqlx.sock' bind-address: '::' port: 33060
db_1               | 2020-05-25T16:36:52.341936Z 0 [Warning] [MY-010068] [Server] CA certificate ca.pem is self signed.
db_1               | 2020-05-25T16:36:52.345030Z 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
db_1               | 2020-05-25T16:36:52.363785Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.20'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server - GPL.

上記はMySQLコンテナ(dbサービス)起動時のログを docker-compose logs dbで確認した際の例です。

  • Initializing database files から Database files initialized で6秒
  • Starting temporary server から Temporary server stopped で5秒

この2処理で所要時間をほぼ半分ずつ要しています。

依存gemのキャッシュ・リストアで named volume のディレクトリをそのまま使わない(使えない)

    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      GEMS_CACHE_DIR: /tmp/cache/bundle
      GEMS_CACHE_KEY: cache-gems

    # 略

    - name: Cache bundle gems
      id: cache-bundle-gems
      uses: actions/cache@v1
      with:
        path: ${{ env.GEMS_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Gemfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.GEMS_CACHE_KEY }}-${{ matrix.ruby }}-

依存Gemのキャッシュリストアも、公式のキャッシュ処理用actionであるactions/cacheで行います。キャッシュのキーに ${{ hashFiles('Gemfile.lock') }} を含めているのは、Gemfile.lock(Gemfile)に変更があった際にキャッシュHITさせないようにする事を意図したものです。

./docker-compose.yml
  backend: &backend

    # 略

    volumes:
      - ./:/app:cached
      - ${GEMS_CACHE_DIR:-bundle-cache}:/bundle
      - rails-cache:/app/tmp/cache

# 略

volumes:
  mysql-data:
  bundle-cache:
  rails-cache:

全てをDockerで行おうとしているので、bundle installもコンテナ内で行います。つまりインストールされたgemをキャッシュしたければ、volume mount先からインストール結果を取り出さなければなりません。

↑のdocker-compose.ymlを紹介した際に「環境変数GEMS_CACHE_DIRがセットされていればその内容で、セットされていなければbundle-cacheという名前のnamed volumeでマウント」と書いたのですが、このcache-bundle-gemsのstepがまさに「環境変数GEMS_CACHE_DIRがセットされていれば」なケースに該当します。これは「named volumeではなくマウント先のパスを環境変数で明示する」のが意図です。

公式のvolumesのSHORT SYNTAXによると、パスが指定されていればそのパスが、固定文字列が指定されていればその名前のnamed volumeが、それぞれマウントされます。開発作業時はGEMS_CACHE_DIRを明示せずデフォルトの固定文字列(= named volume =bundle-cache)を使用しても良いですが、actions/cache で内容をキャッシュしようとした場合、そのディレクトリとしてnamed volumeの実体(具体的には /var/lib/docker/volumes/xxx というパス)を指定するとpermission deniedエラーでキャッシュに失敗してしまいます。なのでこれを回避する為にCI時のみGEMS_CACHE_DIRとして /tmp/cache/bundle という(permission deniedにならない)ディレクトリを明示しています。これによりCI時のbundle install結果はこのGEMS_CACHE_DIRディレクトリに出力され、actions/cacheでディレクトリが丸ごとキャッシュされます。

この記述は正直分かりやすいとは言えないので、LONG SYNTAXを利用して分かりにくさを軽減したいところなのですが、今回は「環境変数の中身によってvolume typeが変えられる」「1つのdocker-compose.ymlを開発作業とCIで併用しやすい」というメリットを優先してSHORT SYNTAXを採用しました。

アプリのセットアップ&テストもDockerコンテナ内で実行

    - name: Setup and Run test
      id: setup-and-run-test
      run: docker-compose run --rm console bash -c "bundle install && rails db:prepare && rspec"

このstepは構築するアプリの仕様ややりたい事次第で変わると思いますが、一応今回の例を紹介しておくと bundle install(結果は先述の通りキャッシュされる) → db:prepareでDBセットアップ(参考) → テスト(今回使ったアプリではrspecを使用しています)、という順にテストまで実施しています。それぞれのコマンドをrunnerインスタンス上で直接実行するのではなく、docker-compose runconsoleサービスを立ち上げてその中で実行するようにしています。

アプリのテストと並行してDockerイメージの脆弱性スキャンも実行

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  APP_IMAGE_TAG: rails6api-development:0.1.0
  APP_IMAGE_CACHE_TAG: rails6api-development-cache
  IMAGE_CACHE_DIR: /tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image

# 略

  scan-image-by-trivy:
    needs: image-cache-or-build
    strategy:
      matrix:
        ruby: ["2.7.1"]
        os: [ubuntu-18.04]
    runs-on: ${{ matrix.os }}
    env:
      ARG_RUBY_VERSION: ${{ matrix.ruby }}
      TRIVY_CACHE_DIR: /tmp/cache/trivy

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker image
      id: cache-docker-image
      uses: actions/cache@v1
      with:
        path: ${{ env.IMAGE_CACHE_DIR }}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-${{ hashFiles('Dockerfile') }}
        restore-keys: |
          ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ matrix.ruby }}-

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: docker image load -i ${IMAGE_CACHE_DIR}/image.tar

    - name: Scan image
      id: scan-image
      run: docker container run
        --rm
        -v /var/run/docker.sock:/var/run/docker.sock
        -v ${TRIVY_CACHE_DIR}:/root/.cache/
        aquasec/trivy
        ${APP_IMAGE_CACHE_TAG}

開発〜CIをDockerで完結させようとしているので、折角なのでCI時にDockerイメージの脆弱性スキャンも行っておきたいです。今回は aquasecurity/trivy を使わせてもらいました。Docker完結を目指しているので、trivyによるスキャンも公式に提供されているDockerで行います。

needs: image-cache-or-build によってイメージビルド&キャッシュが完了済なので、スキャンもそのキャッシュをload・展開したイメージに対して実施して高速化します(スキャン対象として ${APP_IMAGE_CACHE_TAG} = rails6api-development-cache を指定)。
毎回 aquasec/trivy をpullする事でスキャンそのものの仕様を常に最新化しているので、Dockerfileに変更がなくてもスキャンを実行するようにしています。

開発作業のユースケース例

新規参画エンジニアの環境構築手順は?

# ビルド
docker-compose build base

# セットアップ
docker-compose run --rm console bash -c "bundle install && rails db:prepare && rails db:seed"

# 起動
docker-compose up -d server

railsを起動したい時は?

docker-compose up -d server

テスト(rspec)を実行したい時は?

起動中のserverサービスで

docker-compose exec server rspec [SPEC_FILES]

consoleサービスで

docker-compose run --rm console rspec [SPEC_FILES]

マイグレーションを追加・修正・適用したい時は?

起動中のserverサービスで

# マイグレーション新規生成
docker-compose exec server rails g migration MIGRATION_NAME

# :
# マイグレーションファイルを適宜修正
# :

# マイグレーションの適用
docker-compose exec server rails db:migrate
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【初心者向けハンズオン】WindowsにDockerでPHP/MySQL/Apache環境構築(第2回:PHP動かすところまで)

概要

 前回の記事はこちら:

 新人もベテランも本当にエンジニアによくある、「開発環境建てるのめんどくさい問題」を解決する糸口を模索していました。そこで行き当たったWindows環境でDockerを使って開発出来るようにする、という着地に対してアプローチしていくハンズオンです。
 1回1時間程度の作業時間でやるルールにして、記事は細切れに書いて行くスタイル。あと参考にさせていただいた記事はこちらです。
 ※https://qiita.com/hirosnow/items/cbe2a90ba1c6921fea1a

目標

  • PHP/MySQL/Apacheで古き良きWEBアプリケーションの開発環境を作る
  • つくった開発環境を他の人に配布できるようにする
  • ↑の「配布された人が簡単に開発環境を作れる手順をまとめる」ところまでがゴール

では行ってみましょう。

本日の作業内容

作業ディレクトリの作成

参考にした記事に従って、「C:\Users\あなたのPCのユーザー名\home」というディレクトリを作成しました。
※参考との相違点①:「\work」になっているところを「\home」い変えました(好み)

home/
 ├ html/
 │ └ index.php
 ├ mysql/
 ├ php/
 │ └ php.ini
 └ docker-compose.yml

設置したファイルの記述

ちなみにここに来て大変今更ですが「Dockerってなんや」「Dockerfile?docker-compose.yml?」あたりでそもそも理解に躓いたタイミングがあり、その解消になったyoutube動画を転載させていただきます。
https://www.youtube.com/watch?v=VIzLh4BgKck

記述したdocker-compose.yml

version: '3'

services:
  php:
    image: php:7.2-apache
    volumes:
      - ./php/php.ini:/usr/local/etc/php/php.ini
      - ./html:/var/www/html
    ports:
      - 32778:80
  mysql:
    image: mysql:5.7
    volumes:
      - ./mysql:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: test
      MYSQL_USER: test
      MYSQL_PASSWORD: test
    command: --innodb-use-native-aio=0
    ports:
      - "32780:3306"
  phpmyadmin:
    image: phpmyadmin/phpmyadmin
    environment:
      PMA_ARBITRARY: 1
      PMA_HOST: mysql
      PMA_USER: test
      PMA_PASSWORD: test
    restart: always
    links:
      - mysql:mysql
    ports:
      - '32779:80'

記述したphp.ini(ホントはxamppでもともと使ってたやつを入れたんですが、恥ずかしいのでこちらの記述で)

[Date]
date.timezone = "Asia/Tokyo"
[mbstring]
mbstring.internal_encoding = "UTF-8"
mbstring.language = "Japanese"

記述したindex.php(ここは別になんでもいいと思う)

<?php
    phpinfo();

いざ、起動

該当フォルダに移動

cd (docekr-compose.ymlのあるディレクトリ)

いざ、起動

docker-compose up -d

接続してみる

http://127.0.0.1:32778/」

おお、すごい。来てる。簡単。
image.png

終わり。

コマンド一発は超絶楽ですね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【初心者向けハンズオン】WindowsにDockerでPHP/MySQL/Apache環境構築(第1回:DockerDesktopを入れるまで)

概要

 新人もベテランも本当にエンジニアによくある、「開発環境建てるのめんどくさい問題」を解決する糸口を模索していました。何しろ、自分と共通の開発環境つくってもらうのがめんどくさくて仕方がない。
 で、そういった課題への解決策としては当然のように「Docker」が提唱されることになるわけです。
「でも自分の開発環境Windowsなんだよなあ」
 とはいえ行けるかどうか半信半疑で調べてみたら、結構参考記事が上がっていたので「よしじゃあこれも備忘つけながらやってみるか」と思い立った次第です。

 同じく例によって、1回1時間程度の作業時間でやるルールにして、記事は細切れに書いて行くスタイル。長くなると読む側もダレるかなあと思ったというのもあります。

目標

  • PHP/MySQL/Apacheで古き良きWEBアプリケーションの開発環境を作る
  • つくった開発環境を他の人に配布できるようにする
  • ↑の「配布された人が簡単に開発環境を作れる手順をまとめる」ところまでがゴール

では行ってみましょう。

作業内容

dockerインストール

こちらからインストール → https://docs.docker.com/docker-for-windows/install/

その後ダウンロードしたexeファイルを使ってインストール。

image.png

↓↓↓

image.png

↓↓↓

無事完了
image.png

まさかのエラー(でなければスルー)

意気揚々とインストールしたdockerを始めようとしたらいきなりエラーで出鼻をくじかれる。なぜ。。。
image.png

調べたらこんな記事が出てきました。
https://qiita.com/toro_ponz/items/d75706a3039f00ba1205

手順通りやったらできました。良かった。PCの管理者、ユーザーの利用者で躓くかもしれない。
image.png

初期セットアップ

とりあえず指示通り青いボタンを押しながら「Start」「Next Step」
image.png

(色々出る)
image.png

Docker Hubのアカウントが必要でした。
僕はたまたまもってましたが、ここで取得しましょう。
https://hub.docker.com/
image.png

で、Sign inしておしまいです。チュートリアルとか色々出るけど最後はこの画面でした。
image.png

終わり。

なんだかんだ読みながらやるとインストールだけで1時間とか立ちますよね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Dockerの基本をまとめてみた

基本事項

  • Linuxでのみ作動
  • CLI
  • カーネルをホストと共有
  • インフラ(OS)とアプリケーションを同梱

メリット

  • 起動時間が短い
    = インフラ(=コンテナ)を毎回作りなおすImmutableInfrastructureと相性がいい

ライフサイクル

  • 実行中

  • 停止

    • 異常終了時
    • 正常終了時

    停止状態では再実行が可能
    かつ、停止直前の状態を保持している

  • 破棄

    明示的に破棄しないとディスクに残る

Dockerfile/Docker image/Docker containerの関係

  • Dockerfile ≒ クラス定義書
  • Docker image ≒ クラス定義書から自動生成されたクラス
  • Docker container ≒ 上記のクラスのインスタンス

dockefileToContainer.png

参考
Docker/Kubernetes 実践コンテナ開発入門

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

転職したらKubernetesだった件

TL;DR

Kubernetes がどのように、人間の作業を自動化しているのかを、実際に Kubernetes がやっている作業を手作業で行なう ことで学びましょう。

このQiita の内容は、CloudNative Days Tokyo 2019 における発表の、「転職したらKubernetesだった件」を書き下ろし、実際にデモが行えるように修正を加えたものになります。

転職したら Kubernetes だった件


この物語はフィクションであり、登場する団体名・会社名・人名等は架空のもので、実在する団体・会社・人物等とは、一切、関係がありません。

これまでのあらすじ

ある日、某Z社に転職した稲津さんに与えられた仕事は、“Kubernetes の一員”になることだった!?

某Z社は、親会社からの依頼で Kubernetes クラスタを運用しなければならなくなりましたが、 「Kubernetes のアーキテクチャが難しすぎ、運用できない!」 と判断しました。しかし、世の中はクラウドネイティブです。とある団体曰く、 クラウドネイティブイコールKubernetesです。 世の中の流れ的に全てを拒否できないだろうと会社の上層部は判断しました。

「Kubernetes で一番重要なコンポーネントはなんだ?」
「kube-apiserverです。」

と、こんな会話があったとかなかったとか。

こうして、某Z社では、kube-apiserverのみが導入され、その他を手作業とする業務フローが生まれたのでした。

それでは、あなたは某Z社に転職した inajob(稲津さん) となり、Z社の社員としてZ社の業務を実際に体験してみましょう!

事前準備

以下のレポジトリを clone して、環境をセットアップしてください。実際に業務体験をするためには Vagrant と VirtualBox が必要です (Mac OS Xで動作確認しています、その他の環境で動かなかった場合の対応は PR お待ちしております :grin:)

$ git clone https://github.com/zlabjp/k8s-the-heartful-way
$ cd k8s-the-heartful-way/
$ vagrant up

また、以下のコマンドで、master ノードと、inajob のワーカーノードにログインできることを確認しておいてください。

$ vagrant ssh master01
$ vagrant ssh inajob

第1話 「稲津さん、"Kubernetesネイティブ"企業に転職する」

今日は稲津さんの初出社日です。
稲津「この会社でKubernetesのスキルを身につけて、CKA,CKADを取得すればこの先しばらく引く手数多だ」と、この時はまだワクワクしていました。

image.png

オリエンテーションを終えた稲津さん、そこにやってきた上司の須田さんから今後業務に使用するラップトップを受け取りました。

須田「稲津さん、あなたの仕事はそのラップトップを使って Kubernetesクラスタの一員になる ことです。そのラップトップでワーカノードとして必要なセットアップを実施してください。」

稲津さんは、何を言われたのかよくわかりませんでしたが、先輩社員に言われた通りにラップトップをセットアップすることにしました。

image.png

ネットワークのセットアップ

まず、稲津さんがワーカノードとして働くためには、ノード上で起動するPod同士が疎通するためのネットワークをセットアップしなければなりません。某Z社のPodネットワークはトラディショナルなhost-gwモデルを採用しており、/16 で切られたPodネットワークをさらに /24 でノードごとに割り当て、それぞれのPod間のアドレス解決はノード上のルーティングテーブルで行うというものでした。

image.png

整理しましょう、

  • Pod ネットワーク: 10.244.0.0/16
  • ノードのネットワーク: 192.168.43.0/24
  • inajob さんのノード
    • IP アドレス: 192.168.43.111
    • ノードに割り当てられたPod Networkのサブネット: 10.244.1.0/24
  • yuanying さんのノード
    • IP アドレス: 192.168.43.112
    • ノードに割り当てられたPod Networkのサブネット: 10.244.2.0/24

こんな環境で、inajob さんのノード上に起動したPod(10.244.1.0/24)から、他のノードのPodに(例えばyuanyingノードに起動したPodは10.244.2.0/24)にネットワーク到達可能でなければなりません。

須田「ということで、とりあえず inajob ノードの Pod ネットワークと yuanying ノードの Pod ネットワークを繋いでみましょう。まずはノードにログインしてください!」

$ vagrant ssh inajob

須田「うまくできましたね!」

須田「ノードにログインすると、yuanying ノードの Pod ネットワークはどのレンジが割り当たっているかについては、以下のコマンドで知ることができますよ。」

inajob
$ kubectl get nodes  -o custom-columns="Name:.metadata.name, PodCIDR:.spec.podCIDR, Address:.status.addresses[?(@.type=='InternalIP')].address" | grep --color -E  "^|yuanying.+$"

須田「192.168.43.112 のマシンに 10.244.2.0/24 という Pod ネットワークが割り当てられていることがわかりますね。それではこのネットワークに対してルーティングを追加しましょう。」

inajob
$ sudo ip route add 10.244.2.0/24 via 192.168.43.112

須田「ちゃんと登録されたか確認してください。」

inajob
$ ip route | grep --color -E "^|^10\.244\.2\.0.+$"

須田「大丈夫そうですね!引き続き他のメンバーのルーティングも追加する必要がありますが、とりあえず今日のところは、これで大丈夫です。」

ノードの登録

須田「Z社の社員の一覧は、kubectl で確認することができます。色々な業務をこのコマンドで行いますので覚えていきましょう。」

inajob
$ kubectl get node

稲津「あら、まだ僕は社員一覧にいないんですね。」
須田「ということで自分の情報を YAML で書いて登録してください。社員一覧の登録も kubectl を使います。」
稲津「新人ラベルっていうのがあるんですね。」
須田「最近じゃ node-role.kubernetes.io 系のラベルは非推奨だけどね。」

inajob
cat <<EOF | kubectl apply -f -
---
apiVersion: v1
kind: Node
metadata:
  name: inajob
  labels:
    node-role.kubernetes.io/newbie: ""
spec:
  podCIDR: 10.244.1.0/24
EOF

須田「毎回、ちゃんと登録できたか確認してください。」
稲津「えーっと、きゅーべしーてぃーえる げっと のーど、っと。」

inajob
$ kubectl get nodes -o wide | grep --color -E "^|inajob.+$"

稲津「ちゃんと登録されていました!なんか STATUS NotReady が目立ちますね。」
須田「うちは結構時間に融通きくんだよ。」
稲津「へー、いいですね。」
須田「CONTAINER-RUNTIMEやKERNEL-VERSIONなどの情報も一緒に登録よろしく。これらは現状のステータスを表すのでステータスフィールドに書いてください。」
稲津「これは kubectl じゃなくて curl を使うんですね。」
須田「そうだね、変な仕様だよまったく。」

:pencil: STATUS フィールドはユーザによる変更を想定していないため、kubectl では修正することができません。:grin:
通常は、kubelet や kube-controller-manager などのコントローラがこのフィールドを変更します。
今回の場合は、稲津さんが kubelet そのものなので、自身で変更する必要があるわけですね。

須田「まず、ステータスを JSON で書きます。」

inajob
STATUS=$(cat <<EOF
{
  "status": {
    "nodeInfo": {
      "kubeletVersion": "v1.15.0",
      "osImage": "Human 1.0.new",
      "kernelVersion": "4.15.2019-brain",
      "containerRuntimeVersion": "docker://18.6.3"
    },
    "addresses": [
      {
        "type": "InternalIP",
        "address": "192.168.43.111"
      }
    ]
  }
}
EOF
)

稲津「書きました」
須田「ノードのステータスはノードリソースのサブリソースなので、サブリソース用のエンドポイントに対してパッチリクエストを投げて更新してください。
稲津「こうですね、えいやっ!」

inajob
$ curl -k -X PATCH -H "Content-Type: application/strategic-merge-patch+json" \
    --key ~vagrant/secrets/user.key \
    --cert ~vagrant/secrets/user.crt \
    --data-binary "${STATUS}" "https://192.168.43.101:6443/api/v1/nodes/inajob/status"

須田「user.keyは認証用の鍵なので他の人に教えないでくださいね。それでは、、」
稲津「はいはい、確認ですね。ポチッと。(はー、kubectl楽だわー)」

inajob
$ kubectl get nodes -o wide | grep --color -E "^|inajob.+$"

須田「よしよし、できたね。今日はこれでいいかな。」
稲津「これで晴れて私も会社の一員ですかね?」
須田「お疲れ様でした。」

第2話「稲津さん、ワーカノードになる」

今日は稲津さんはじめての業務の日です。稲津さんが会社に出勤してきました。

どんな会社でも最初にやることは出勤登録することです。
 “Kubernetes ネイティブ”である某Z社における出勤登録は
担当するノードのステータスを更新することです。

image.png

稲津「おはようございます、今日もよろしくお願いします!」
須田「おはよう、それでは早速ですが勤怠登録お願いします。」
稲津「はい、わかりました。勤怠はどこのシステムにログインすれば。。。」
須田「うちは、勤怠もKubernetesで行なっているんだよ。」
稲津(何を言ってるんだこの人は。)
須田「はいはい、早速ノードにログインして。フォーマットはこんな感じのJSONで。」

inajob
$ STATUS=$(cat <<EOF
{
  "status": {
    "conditions": [
      {
        "lastHeartbeatTime": "$(date --utc +"%Y-%m-%dT%H:%M:%SZ")",
        "message": "今日からよろしくお願いします。",
        "reason": "稲津出社",
        "status": "True",
        "type": "Ready"
      }
    ]
  }
}
EOF
)

稲津「これでいいですか。」
須田「それじゃあ、昨日と同じようにステータスサブリソースにパッチを投げてください。」

inajob
$ curl -k -X PATCH -H "Content-Type: application/strategic-merge-patch+json" \
    --key ~vagrant/secrets/user.key \
    --cert ~vagrant/secrets/user.crt \
    --data-binary "${STATUS}" "https://192.168.43.101:6443/api/v1/nodes/inajob/status"

稲津「できました、こんな感じですか?」
須田「それj」
稲津「確認ですね。」

inajob
$ kubectl get node | grep --color -E "^|inajob.+$"

稲津「お、ステータスがREADYになりました。」
須田「上司の俺は、これで出勤を確認するから。これからじゃんじゃん仕事を振るからよろしくね。」
稲津「ウゲー(はい、よろしくお願いします!)」

image.png
:pencil: Kubernetes ノードはコンテナを実行できる準備ができている、ということをクラスタに対してそれを定期的に伝えています。稲津さんも出社して準備ができたので、それをクラスタに伝えます。Kubernetes はその報告をもとに仕事を各ノードに振り分けます。
:pencil: また、ノードに障害が発生して、ノードからの定期的な連絡がなくなると、Kubernetes はそのノードで問題が発生したと判断して、そのノード上で実行しているコンテナを他のノード上で代わりに実行するようにします。このようにすることで、Kubernetes 上にデプロイされるサービスは高い可用性を得ることができます。
:pencil: ちなみに、最近の Kubernetes では Lease オブジェクトを利用して Node のハートビートを行っているため、微妙にこの説明は古くなっています。

第3話「稲津さん、Pod を実行する」

スケジューラ担当 須田

須田「さて、俺もそろそろ自分の仕事をするかな。」
稲津「須田さんは何の仕事をしているんですか?」
須田「俺か?俺は色々な仕事を任せられているが、今日はスケジューラーだ。」
稲津「す、スケジューラー?」
須田「要は調整係だな、この会社で実行しなくちゃいけないプログラム、俺たちはPodと呼んでいるが、それを誰に実行してもらうか決める役割だ。」
稲津「へー、(簡単そうですね。)」
須田「そうだ、お前、俺の仕事を横から見ていろ。(俺もいつまでこの会社にいるかわからないからな。)後任を育てる義務がある。」
稲津「あっ。」

須田「まずは管理者ノードに入るぞ。これ間違えたらできないからな。」

$ vagrant ssh master01

須田「そして、まだノードに割り当てられていないプログラム(Pod)があるかどうかを確認する。」

master01
$ kubectl get pods -o custom-columns=Name:.metadata.name,Node:.spec.nodeName

稲津「お、nginxという名前のPodがありますね。」
須田「そうだ、ここのNodeが <none> になっているだろう、これはまだ、この nginx がどのノードにも割り当てられていないってことだ。」
須田「次に、出勤している社員を調べる。」

master01
$ kubectl get node

須田「この中でReadyになっていて実行できそうな社員にこのPodの実行を任せることにするんだ。」

master01
$ kubectl describe node inajob | head -n 14

須田「適当にノードを選んで、describe などを駆使して大丈夫かどうかを調べたりする。非常に高度な仕事だ。」

master01
$ cat <<EOL | tee nginx-binding.yaml
apiVersion: v1
kind: Binding
metadata:
  name: nginx
target:
  apiVersion: v1
  kind: Node
  name: inajob
EOL

須田「どのPodをどのノード(社員)に割り当てるのかを決める Binding リソースをYAMLで作成する。」

master01
$ curl -k -X POST -H "Content-Type: application/yaml" \
  --data-binary @nginx-binding.yaml \
  --key /vagrant/kubernetes/secrets/admin.key \
  --cert /vagrant/kubernetes/secrets/admin.crt \
"https://192.168.43.101:6443/api/v1/namespaces/default/pods/nginx/binding"

須田「そしてこのYAMLをapiserverにポスト、、、これで先ほどのBindingリソースが、nginx Pod の binding サブリソースとして登録された。」
稲津(わけわからん、これは私に割り当てられたのか…?)

:pencil: ここでは、須田さんは Kubernetes でいう kube-scheduler の役割を演じていました。kube-scheduler は Node に割り当てられていない Pod を逐一監視し、ノード一覧からそのPodを実行するのに最も適したノードを選択し、Podをノードに割り当てます。
:pencil: 割り当て自体は Pod の binding サブリソースとして表現されいます。

稲津さん、仕事を割り当てられていることを知る

稲津「えーっと、それでは私の日々のルーチンは何をすればいいのでしょうか。」
須田「基本的に稲津くんの仕事はPodを実行することなので、自分にPodが割り当てられているかを確認してください。kubectl で。とりあえず自分のPCにログインしてください。」

$ vagrant ssh inajob

稲津(マニュアルによるとこのコマンドか…。)

inajob
$ kubectl get pod \
  --field-selector 'status.phase=Pending,spec.nodeName=inajob' -A

稲津「あれ、さっきの nginx Pod が表示されましたよ。」
須田「そのコマンドは自分のノードに割り当てられていて、まだ実行されていないPodが表示されるんだ。」
稲津「それでどうすれば、、」
須田「マニュアル嫁」

Podのネットワークを設定しよう

pause コンテナを起動せよ

稲津「pauseコンテナを起動しろ、とありますね。pauseコンテナって何ですか?」
須田「ああ、そもそもPodとはね、」
稲津(そもそも論が始まった、これは長そうだ。)
稲津「あ、やっぱいいです。」
須田「まあ、簡単にいうとコンテナ同士のネームスペースを共有するため用のコンテナだ。まあ、今回は nginx コンテナ一つを起動するだけなので必要ないんだけどね。」
稲津(必要ないならやらなければいいのに…。)

:pencil: Pod はコンテナの集合です。某Z社ではコンテナランタイムにDockerを使っているわけなのですが、Dockerには「コンテナの集合」という概念がないため、その機能差を埋めるために 「pause コンテナ」というグルーを利用しています。
image.png
The Almighty Pause Container - Ian Lewis

inajob
$ docker run -d \
    --network none \
    --name k8s_POD_default-nginx \
    k8s.gcr.io/pause:3.1

稲津「起動しました!」

Pod に IP アドレスを割り当てよう

須田「それじゃあ、その pause コンテナに Pod のアドレスを割り当てようか。この作業は root で行う必要がある。」
稲津「すーどぅーですね。」

inajob
$ sudo su

須田「そしてさっき作った pause コンテナのネットワークネームスペースをメモっといてくれ。」

inajob@root
$ PID=$(docker inspect -f '{{ .State.Pid }}' k8s_POD_default-nginx)
$ NETNS=/proc/${PID}/ns/net

稲津「メモりました。」
須田「そしてCNIコマンドを実行する。CNIコマンドは環境変数と設定ファイルを標準入力を実行時のパラメータとして取るんだ。」
稲津「えーっと、環境変数を設定、っと。。何かおまじない感ありますね。」

inajob@root
export CNI_PATH=/opt/cni/bin
export CNI_COMMAND=ADD
export CNI_CONTAINERID=k8s_POD_default-nginx
export CNI_NETNS=${NETNS}
export CNI_IFNAME=eth0
export PATH=$CNI_PATH:$PATH
export POD_SUBNET=$(kubectl get node inajob -o jsonpath="{.spec.podCIDR}")

須田「環境変数を設定したら CNI の bridge コマンドを実行してくれ。」

inajob@root
/opt/cni/bin/bridge <<EOF
{
    "cniVersion": "0.3.1",
    "name": "bridge",
    "type": "bridge",
    "bridge": "cni0",
    "isGateway": true,
    "ipMasq": true,
    "ipam": {
        "type": "host-local",
        "ranges": [
          [{"subnet": "${POD_SUBNET}"}]
        ],
        "routes": [{"dst": "0.0.0.0/0"}]
    }
}
EOF

稲津「な、何かずらずらと出てきました!」
須田「.ips[0].address の値が割り当てられたPodのIPだ、忘れると大変だからこれもメモしておいてくれ。あ、以降の作業はまた一般ユーザで行うから exit してからな。」

inajob
$ exit # root 作業終了
$ POD_IP=10.244.1.2

稲津「メモりました!」

:pencil: POD_IP の値は環境ごとに違うので、実際の値をメモっておいてください。

須田「ちゃんと設定されたか確認しておこう。」
稲津「えーっと、この場合の確認ってどうすればいいんですかね。」
須田「ping で良いんじゃね。」

inajob
$ ping ${POD_IP}

稲津「応答返ってきました!」

nginx コンテナを起動しよう

須田「それでは実際の nginx コンテナを起動しようか。」
稲津「えーっと、どんな spec かをちゃんと確認してっと。。。」

inajob
$ kubectl get pod nginx -o json | jq '.spec.containers'

稲津「nginx:1.14 のイメージを起動すれば良いんですね。そういえば他のフィールドは…。」
須田「。。。」
稲津(時間と尺の都合で割愛かな。)
須田「ネットワークにさっきのコンテナを指定すれば、ネットワークを共有することができるんだ。」
稲津(あ、無視された。)

inajob
$ docker run -d \
    --network container:k8s_POD_default-nginx \
    --name k8s_nginx_nginx_default \
    nginx:1.14

稲津「起動しました!」
須田「実際に nginx が起動したか確認してみようか。」

inajob
$ curl http://${POD_IP}

稲津「nginx のデフォルトページが返ってきました!」

Pod のステータスを更新しよう

須田「それじゃあ、ちゃんとPodが実行できたことを俺に報告してくれ。」
稲津「なになに、Podのステータスを更新する、とありますね。この会社の報告は大体ステータスの変更で行うんですね。」
須田「Kubernetesネイティブ企業だからな。」

inajob
STATUS=$(cat <<EOF
{
  "status": {
    "conditions": [
      {
        "lastProbeTime": null,
        "lastTransitionTime": "$(date --utc +"%Y-%m-%dT%H:%M:%SZ")",
        "status": "True",
        "type": "Initialized"
      },
      {
        "lastProbeTime": null,
        "lastTransitionTime": "$(date --utc +"%Y-%m-%dT%H:%M:%SZ")",
        "status": "True",
        "type": "Ready"
      },
      {
        "lastProbeTime": null,
        "lastTransitionTime": "$(date --utc +"%Y-%m-%dT%H:%M:%SZ")",
        "status": "True",
        "type": "ContainersReady"
      }
    ],
    "containerStatuses": [
      {
        "containerID": "human://nginx-0001",
        "image": "nginx:1.14",
        "imageID": "docker-pullable://nginx@sha256:96fb261b66270b900ea5a2c17a26abbfabe95506e73c3a3c65869a6dbe83223a",
        "lastState": {},
        "name": "nginx",
        "ready": true,
        "restartCount": 0,
        "state": {
          "running": {
            "startedAt": "$(date --utc +"%Y-%m-%dT%H:%M:%SZ")"
          }
        }
      }
    ],
    "hostIP": "192.168.43.111",
    "phase": "Running",
    "podIP": "${POD_IP}",
    "startTime": "$(date --utc +"%Y-%m-%dT%H:%M:%SZ")"
  }
}
EOF
)

稲津「ステータスのJSON書きました。何か妙に長いですね。」
須田「本当はもっと細かく状態が変更されるたびに報告してもらうんだが、新人だから簡略化しました。」
稲津(えらくマイクロマネジメントだな。)
須田「それでは apiserver に登録してください。」

inajob
$ curl -k -X PATCH -H "Content-Type: application/strategic-merge-patch+json" \
    --key ~vagrant/secrets/user.key \
    --cert ~vagrant/secrets/user.crt \
    --data-binary "${STATUS}" "https://192.168.43.101:6443/api/v1/namespaces/default/pods/nginx/status"

稲津「登録しました。」

inajob
$ kubectl get pods -o wide | grep --color -E "^|Running"

須田「PendingからRunningになりましたね、ちゃんと登録されているようです。」

第4話「吉田さん、ReplicaSet を処理する」

image.png

ここで須田さん、稲津さんの上司である吉田さんが初登場します。吉田さんは、コントロールプレーンとしてコントローラマネージャという管理業務を行なっています。

管理業務の1つは Pod の冗長化です。一般にアプリケーションは、複数のインスタンスで構成して可用性を担保します。Kubernetes ではこれを ReplicaSet で実現します。

今日は吉田さんが ReplicaSet コントローラとして活躍するお話です。

吉田さん、処理されていないReplicaSetを見つける

吉田(ゴソゴソ)

$ vagrant ssh master01

稲津(おや、あれは確か上司の吉田さんだ。)
稲津「吉田さん、何をしているんですか。」
吉田「何って、仕事だよ仕事。色々監視してるんだ。」
稲津「何かこの会社って、基本監視で仕事が回ってますよね。」
吉田「そうだな、自分の仕事内容については自分でapierverを監視して自主的に処理するんだ。」

:pencil: リコンシレーションループと呼ばれる、対象オブジェクトの監視と処理は、Kubernetesの第一原則となっています。

吉田「親会社の人間は、俺たちに何かを処理させたくなると大体、ReplicaSet か Deployment と呼ばれる書式で指示して来ることが多いんだ。」

master01
$ kubectl get replicasets -o wide -A | grep --color -E "^|DESIRED|CURRENT"

吉田「ほら、web という名前のアプリを二つ処理してくれって来たぞ。」
稲津「DESIREDが2つ、になっていますね。CURRENTがゼロですが。」
吉田「そりゃまだ俺たちが処理していないからな。」
吉田「まあ、一応、本当に処理していないか調べる。このReplicaSetの指示書によると、app=web というラベルがついたPodが二ついないとダメらしい。」

master01
$ kubectl get pod -l app=web

稲津「ありませんね。」
吉田「よし、それじゃあ Pod を二つ作るか。どんなPodを作るかは ReplicaSet の指示書に書いてある。」

master01
$ kubectl get rs web -o json | jq '.spec.template'

稲津「この spec フィールドのテンプレートのところですね。」
吉田「そうだ、これを使ってチョチョイと kubectl create っと…。」

master01
$ kubectl get rs web -o json | \
    jq -r '.spec.template | .+{"apiVersion": "v1", "kind": "Pod"} | .metadata |= .+ {"name": "web-001"}' | \
    kubectl create -f -
$ kubectl get rs web -o json | \
    jq -r '.spec.template | .+{"apiVersion": "v1", "kind": "Pod"} | .metadata |= .+ {"name": "web-002"}' | \
    kubectl create -f -

稲津「jq がキモいですね。」
吉田「これで Pod が二つ作られたはずだ。」

master01
$ kubectl get pod -l app=web

稲津「どっちもPendingのままですね、担当者に割り当てもされていないみたいです。」
吉田「おう、俺の仕事はReplicaSetからPodの指示書を作るだけだからな、あとはスケジューラの須田とkubelet担当の誰かがやってくれるさ。」
稲津(この人の業務楽そうだな、、早く偉くなりたい。)
吉田「まだ終わりじゃないぞ、一応親会社の誰かさんに処理が進んだことを報告しないといけないからな。」
稲津「ステータスを更新するんですね。」
吉田「そうだ、よくわかっているじゃないか。須田もちゃんと教育しているようだな。」

master01
$ STATUS=$(cat <<EOF
{
  "status": {
    "availableReplicas": 0,
    "fullyLabeledReplicas": 0,
    "observedGeneration": 1,
    "readyReplicas": 0,
    "replicas": 2
  }
}
EOF
)

吉田「とりあえず、まだ何も動かないPodを二つ作ったので replicas に 2 を指定してっと。」

master01
$ curl -k -X PATCH -H "Content-Type: application/strategic-merge-patch+json" \
    --key /vagrant/kubernetes/secrets/admin.key \
    --cert /vagrant/kubernetes/secrets/admin.crt \
    --data-binary "${STATUS}" "https://192.168.43.101:6443/apis/apps/v1/namespaces/default/replicasets/web/status"

吉田「ReplicaSetのステータスサブリソースを更新しておく。」

master01
$ kubectl get replicasets -o wide | grep --color -E "^|DESIRED|CURRENT"

稲津「READYがまだゼロだけど、CURRENTが2になりました。」
吉田「うむ、これで俺の仕事は終わり。基本は自分の担当オブジェクトを kubectl watch してればいい。」

須田さんによる、いつものスケジューリング業務

$ vagrant ssh master01

須田「む、Podが更新されたな、俺の仕事の出番か。」
稲津「お疲れ様です、どうしたんですか?」

master01
$ kubectl get pods -o custom-columns=Name:.metadata.name,Node:.spec.nodeName

須田「見ろ、ノードが割り当てられてないPodが二つ増えた。スケジューラとしての俺の出番だ。」
稲津「誰かに仕事を割り振るんですね。(自分に割り当てませんように)」
須田「そうだな、最初の1個目はリモート勤務の大塚(yuanying)に任せるか。」
稲津「この会社はリモート勤務もOKなんですね。」
須田「最近の流行りだな。いつも通りに指示書を作るぞ。」

master01
$ cat <<EOL | tee web-yuanying-binding.yaml
apiVersion: v1
kind: Binding
metadata:
  name: web-001
target:
  apiVersion: v1
  kind: Node
  name: yuanying
EOL

須田「そしてapiserverに登録する。」

master01
$ curl -k -X POST -H "Content-Type: application/yaml" \
  --data-binary @web-yuanying-binding.yaml \
  --key /vagrant/kubernetes/secrets/admin.key \
  --cert /vagrant/kubernetes/secrets/admin.crt \
  "https://192.168.43.101:6443/api/v1/namespaces/default/pods/web-001/binding"

須田「リモートでもちゃんと仕事してるか確認するぞ。」

master01
$ kubectl get pod -o wide

稲津「わ、もう Running になってますよ!仕事早いですねー。」
須田「大塚はワーカーノード歴が長いからな、このくらいやってもらわないと困る。」
須田「まあ、念の為ちゃんと起動してるか確認するか。」

master01
$ curl http://$(kubectl get pod web-001 -o jsonpath='{.status.podIP}'):8080

稲津「ちゃんと動いてますね。」
須田「それじゃあもう一個は稲津、お前に振るぞ。」

master01
$ cat <<EOL | tee web-inajob-binding.yaml
apiVersion: v1
kind: Binding
metadata:
  name: web-002
target:
  apiVersion: v1
  kind: Node
  name: inajob
EOL
$ curl -k -X POST -H "Content-Type: application/yaml" \
  --data-binary @web-inajob-binding.yaml \
  --key /vagrant/kubernetes/secrets/admin.key \
  --cert /vagrant/kubernetes/secrets/admin.crt \
  "https://192.168.43.101:6443/api/v1/namespaces/default/pods/web-002/binding"

稲津(他の人に振ってくれてもいいのに)

稲津さん、またまたPodを起動する

稲津「えーっと、ノードにログインしてっと。」

$ vagrant ssh inajob

稲津「さて、何の仕事が私に割り当たっていたんだっけかな。確認しよう。」

inajob
$ kubectl get pod \
  --field-selector 'status.phase=Pending,spec.nodeName=inajob' -A

稲津「あったあった。人使い荒いなあ、この会社は。」
稲津「といっても優秀な稲津くんなので、すでにこの作業はスクリプトにしているのです。」
須田(何をブツブツ言ってるんだ。)

inajob
$ sudo bash /vagrant/scripts/create-pod.sh web-002

稲津(できたできた、もう少し仕事してるフリしてから報告しよう。)

master01
$ kubectl get pod -A

須田「お、稲津くん、キミも仕事が早いじゃないか。」
稲津(しまった、ステータスの更新もスクリプト化してしまっていた。)

吉田さん、ReplicaSet 業務の報告をする

吉田「さてと、そろそろ俺が指示した Pod の作成も終わっている頃かな。親会社への作業完了の報告はコントローラマネージャとしての大事な仕事だからな、ちゃんと監視を怠らないようにしないと。」

$ vagrant ssh master01

吉田「コントロールプレーンノードにログインしてっと、Podを確認するか。」

master01
$ kubectl get pod -l app=web -o wide | \
  grep --color -E "^|Running"

吉田「お、ちゃんと二つのPodがReadyになっているな。それではReplicaSetのステータスを更新するか。」

master01
$ STATUS=$(cat <<EOF
{
  "status": {
    "availableReplicas": 2,
    "fullyLabeledReplicas": 2,
    "observedGeneration": 1,
    "readyReplicas": 2,
    "replicas": 2
  }
}
EOF
)

吉田「readyReplicas を2に更新してっと。」

master01
$ curl -k -X PATCH -H "Content-Type: application/strategic-merge-patch+json" \
    --key /vagrant/kubernetes/secrets/admin.key \
    --cert /vagrant/kubernetes/secrets/admin.crt \
    --data-binary "${STATUS}" "https://192.168.43.101:6443/apis/apps/v1/namespaces/default/replicasets/web/status"

吉田「apiserver に登録っと。」

master01
$ kubectl get rs | grep --color -E "^|DESIRED|CURRENT|READY"

吉田「Desired/Current/Ready が一致した。完璧だ。」
稲津(この会社、独り言してる人が多いな。)

:pencil: Kubernetes コントローラは、API オブジェクトに定義された「望ましい状態」とクラスタの「現在の状態」を一致させるように「継続的に」動作します。これを調整ループ(reconciliation Loop)と呼びます。
image.png

第5話「稲津さん、Service を処理する」

ReplicaSet を使って複数の Pod レプリカを作成できましたが、
Pod にアクセスしたいときにそれぞれの IP に直接アクセスするのは不便です。


代表的なアドレスがあっていい感じに
ロードバランスして欲しいですよね。

image.png
:pencil: Kubernetes では通常、そのような場合 Service を使います。サービスは ClusterIP と呼ばれる仮想 IP とポートを使って、そこへのアクセスをバックエンドの Pod に分散します。バックエンドの Pod はラベルセレクタで選択されます。"Service" という名前はサービスの入り口となる機能となることから来ています。
この Service のロードバランサとしての機能の実現に責任を持つコンポーネントが kube-proxy です。

激務な(コントローラー)マネージャー吉田さん、Endpointを処理する

吉田「さてと、そろそろまた新しい指示が来てそうな時間だな、確認するか。」

$ vagrant ssh master01

吉田「うわ、やっぱり。さっきReplicaSetを処理したから来てると思ったんだ。」
稲津「どうしたんですか、吉田さん。」
吉田「お、稲津か。これを見てみろ。」
稲津「どれどれ?」

master01
$ kubectl get service -o wide

稲津「web-service っていう名前の Service がありますね。何ですかこれ?」
吉田「CLUSTER-IP10.254.10.128SELECTORapp=web とあるだろ、要するにこれはさっき二つ作ったPodをいい感じにロードバランスしてくれる VIP 10.254.10.128 が欲しい、っていう指示書だ。」
稲津「へ、じゃあ吉田さんはこれからその、ロードバランサの設定をするんですか?
吉田「いや、俺はこの指示をもう少し細かくブレークダウンするだけで、実際の作業は kube-proxy 担当だ。」
稲津「へー、そうなんですね。」
吉田「ひとごとのようだが、kube-proxy はノード担当の仕事だぞ。」
稲津「!?(やっぱりこの人の仕事は楽なんじゃない?!)」

master01
$ kubectl get pod -l app=web -o custom-columns=NAME:.metadata.name,IP:.status.podIP

吉田「まず、やることは、さっきのラベルを持っているPodのIPアドレス一覧を取得する。」

master01
$ cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Endpoints
metadata:
  name: web-service
subsets:
- addresses:
  - ip: $(kubectl get pod web-001 -o jsonpath='{.status.podIP}')
  - ip: $(kubectl get pod web-002 -o jsonpath='{.status.podIP}')
  ports:
  - port: 8080
    protocol: TCP
EOF

吉田「そしてそのIPアドレスからEndpointsオブジェクトを作ってやる。あとはこのEndpointsオブジェクトを見てノード担当が実際の設定をするんだ。」

稲津さん、kube-proxy の初期設定をする

須田「そういえば稲津、お前のノードにロードバランサの初期設定をするのを忘れていた。」
稲津「なんかめんどくさそうですね。」
須田「そうだな、俺もいまだによくわからん。とりあえず一個ずつ設定していくぞ。ノードにログインして!」

$ vagrant ssh inajob

稲津「はい、ログインしました。」
須田「ついでに sudo が面倒なので root になっておいて。」

inajob
$ sudo su

稲津「はい、root になりました!」

image.png
:pencil: さて、稲津くんはこれから kube-proxy の設定を行なっていきます。一つ目は Dummy interface の作成。二つ目は、外部からサービスのリクエストを受けたときのためのiptables のマスカレードの設定及びカーネルパラメータの設定です。これらがないと IPVS のリクエストを外部から受けたとき、パケットがどこかに吸い込まれていってしまいます。

須田「うちは kube-proxy の処理を行うのに、IPVS を使っている。なのでまずは VIP を割り当てる dummy interface を作成する。」
稲津「こうですね!」

inajob@root
$ ip link add kube-ipvs0 type dummy

須田「次に、iptablesの設定をしていくぞ。」
稲津「iptables...」
須田「iptables を触りたくないから IPVS にしたのだが、結局 iptables は必要なのだそうだ。」

inajob@root
$ ipset create KUBE-CLUSTER-IP hash:ip,port

稲津「まずは ipset というのを作るんですね?」
須田「そうだ。これを作ることで iptables のルールを線形探索しなくてすむようになる。」

須田「そして、その ipset を使う iptablesのルールを作っていくぞ。」

inajob@root
iptables -t nat -N KUBE-MARK-MASQ
iptables -t nat -A KUBE-MARK-MASQ -j MARK --set-xmark 0x4000/0x4000

iptables -t nat -N KUBE-POSTROUTING
# kubernetes service traffic requiring SNAT
iptables -t nat -A KUBE-POSTROUTING -m comment --comment "kubernetes service traffic requiring SNAT" -m mark --mark 0x4000/0x4000 -j MASQUERADE

iptables -t nat -N KUBE-SERVICES
# Kubernetes service cluster ip + port for masquerade purpose
iptables -t nat -A KUBE-SERVICES ! -s 10.244.0.0/16 -m comment --comment "Kubernetes service cluster ip + port for masquerade purpose" -m set --match-set KUBE-CLUSTER-IP dst,dst -j KUBE-MARK-MASQ
iptables -t nat -A KUBE-SERVICES -m set --match-set KUBE-CLUSTER-IP dst,dst -j ACCEPT

# kubernetes service portals
iptables -t nat -I PREROUTING -m comment --comment "kubernetes service portals" -j KUBE-SERVICES
iptables -t nat -I OUTPUT -m comment --comment "kubernetes service portals" -j KUBE-SERVICES

# kubernetes postrouting rules
iptables -t nat -I POSTROUTING -m comment --comment "kubernetes postrouting rules" -j KUBE-POSTROUTING

iptables -t filter -N KUBE-FORWARD
# kubernetes forwarding rules
iptables -t filter -A KUBE-FORWARD -m comment --comment "kubernetes forwarding rules" -m mark --mark 0x4000/0x4000 -j ACCEPT
# kubernetes forwarding conntrack pod source rule
iptables -t filter -A KUBE-FORWARD -s 10.244.0.0/16 -m comment --comment "kubernetes forwarding conntrack pod source rule" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
# kubernetes forwarding conntrack pod destination rule
iptables -t filter -A KUBE-FORWARD -d 10.244.0.0/16 -m comment --comment "kubernetes forwarding conntrack pod destination rule" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
# kubernetes forwarding rules
iptables -t filter -I FORWARD -m comment --comment "kubernetes forwarding rules" -j KUBE-FORWARD

稲津「何だかたくさんありますね…。」
須田「これでも昔よりは楽になったんだ。少なくともこれ以上ルールが増えることがないからな。」

:pencil: Kubernetes の kube-proxy/ipvs パッケージ に、どのような場合に IPVS モードの kube-proxy が iptables にフォールバックするのかが書いてあります。
:pencil: ちなみに、今回設定した iptables のルールは、ClusterIP に関するルールのみで、type: NodePorttype: Loadbalancer のルールは省略されています。

須田「最後に、カーネルパラメータの設定だ。」
須田「まずは、カーネルモジュールをロードして。」

inajob@root
modprobe -- ip_vs
modprobe -- ip_vs_rr
modprobe -- ip_vs_wrr
modprobe -- ip_vs_sh
modprobe -- nf_conntrack_ipv4

須田「そしてカーネルパラメータを設定する。」

inajob@root
$ echo 1 | sudo tee /proc/sys/net/ipv4/vs/conntrack

稲津「これは一体何をしてるんですか…。」
須田「よく聞く気になったな、えらいえらい。」
須田「詳しくは 「IPVS (LVS/NAT) とiptables DNAT targetの共存について調べた に書いてあるから読むといいぞ。」
稲津「なるほど、IPVS により conntrack の情報が消されてしまってマスカレードができなくなってしまう問題を解決するための設定なんですね!」
須田(なんだ、いきなり賢くなったぞ。)

稲津さん、IPVS の設定をする

須田「それじゃあ、まずは設定するVIPの値を調べよう、kubectl get svc して。」

inajob
$ kubectl get svc

稲津「はい、しました。どうやらVIPのアドレスは 10.254.10.128 のようですね。」
須田「そのアドレスを dummy interface に設定する。」

inajob
$ sudo ip addr add 10.254.10.128 dev kube-ipvs0

須田「さらに、さっき作った ipset にも登録する。」

inajob
$ sudo ipset add KUBE-CLUSTER-IP 10.254.10.128,tcp:80

須田「そして、さらにさらに、ipvs の Virtual Server そのものを作成する。」

inajob
$ sudo ipvsadm -A -t 10.254.10.128:80 -s rr

稲津「何か、、いろんなところに同じ情報が散らばっていて気持ち悪いですね、、自動化したい気分…。」

:pencil: kube-proxy はこれらの作業を自動化しています。

須田「何か言ったか?それじゃあ ipvs の Real Server を設定するぞ、endpoint を取得して!」

inajob
$ kubectl get ep web-service -o yaml

稲津「.subsets[0].addresses の値ですね。」
須田「その値で Real Server を二つ作る。」

inajob
$ sudo ipvsadm -a -t 10.254.10.128:80 -r $(kubectl get ep web-service -o jsonpath='{.subsets[0].addresses[0].ip}'):8080 -m
$ sudo ipvsadm -a -t 10.254.10.128:80 -r $(kubectl get ep web-service -o jsonpath='{.subsets[0].addresses[1].ip}'):8080 -m

稲津「設定しました!」
須田「それじゃあ確認して!」
稲津(何か投げやりになってきたな、面倒になってきたのか?)

inajob
$ sudo ipvsadm -Ln

稲津「設定できているみたいです。」
須田「実際に curl してみようか。」

inajob
$ curl http://10.254.10.128
$ curl http://10.254.10.128

稲津「ちゃんとラウンドロビンされているようです!」
須田「よくやった、、もうお前は立派なくべれっとだ。」
稲津「何かスタッフロールが流れてきました。」

その後、須田さんは別会社へと旅立ち、稲津さんがスケジューラに昇進したとかしないとか。しかし、その後も某Z社はKubernetesネイティブ企業として長く繁栄したとのことです。


まとめ

Kubernetes はコントロールプレーンと呼ばれるマスタコンポーネントとノードコンポーネントで構成されています。今回いくつかの Kubernetes のコンポーネントの代わりに手作業で一通りの Kubernetes での作業を実施することで、そのコンポーネントの責任と何をやっているのかをあきらかにしました。

Kubernetes はブラックボックスでよくわからないものではなく、もちろん人間が実施できる作業です。Kubernetes がなにをやっているかを把握しておくことはなにか問題があったときの解決に役立つでしょう。

それでは、良い Kubernetes ライフを!

                      キャスト

                kubectl    kubectl
         kube-apiserver    kube-apiserver
                   etcd    etcd
         kube-scheduler    須田一輝
kube-controller-manager    吉田龍馬
               kubelet1    稲津和磨(あなた)
               kubelet2    kubelet
            kube-proxy1    稲津和磨(あなた)
            kube-proxy2    kube-proxy
      container-runtime    Docker

                      スライド

     https://speakerdeck.com/superbrothers/

                        動画

     https://www.youtube.com/watch?v=_Bve-nkBr2E

                       GitHub

     https://github.com/zlabjp/k8s-the-heartful-way

おわり

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

dockerの覚書

Dockerとは

dockerとは自分のパソコンのカーネルを使って、あたかも自分のパソコンにもう一つ別のパソコンがあるようにするものと認識しています。
もう一つ別のパソコンがあるようにしている技術のことをコンテナと呼ぶと認識しています。
これの何が良いかというと、共同開発していく上で、バージョンの違いや、各々のPCの環境に左右されないことです。

Dockerをインストールする

公式ページからインストールします
https://www.docker.com/
ダウンロードするとdocker-composeも一緒インストールされます。

docker-composeとは

dockerを使って複数のコンテナを作ることが可能です。この複数のコンテナをいちいち、設定を書いたりするのは面倒なので、一括で管理できるものがdocker-composeです
参考
https://knowledge.sakura.ad.jp/16862/

composeをインストールするときに使うコマンド

# curl -L https://github.com/docker/compose/releases/download/1.21.2/docker-compose-$(uname -s)-$(uname -m) -o /usr/local/bin/docker-compose
# chmod +x /usr/local/bin/docker-compose
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ほぼゼロから始めたdockerのお勉強の旅メモ

前段

dockerって超便利で今どき知らないとか自称ITエンジニアとしてもどうなの?という世界線で、dockerをよくわからずなんとなく使っていたが、ECSとかFargateとかついにDockerから逃れられなくなった青年が、広大なネットを一日さまよって得たDockerの知識をここに記す。

※つまり、ハチャメチャなことを書いている可能性があるということだ。気をつけてね。

とりあえずこれをやればいいと思った件

https://jdlm.info/articles/2019/09/06/lessons-building-node-app-docker.html

これすごく良かったような気がします。
いろんなサイト巡ってこれにたどり着いた。
Google翻訳でもたぶん読めるから英語だけど頑張れ!

その上で、今日の気ずきをまとめていく:point_up_2:

COPYとVolumeについて

今日の朝はVolumeでソースコードマウントして作ったイメージをFargateでデプロイしたら503エラーがでた〜とか騒いでいたんですがこれは当たりまえだのクラッカーなお話でした。

基本的にDocker開発をするときはソースコードを何回も何回も何回も何回も変更するので

docker run -v /host/path:/container/path hoge

みたいな感じでホストのディレクトリをコンテナのディレクトリにマウントして開発をします。
こうしないと、コンテナの中でプログラムを書くか、ホストでソースコードを書き換えるたびにdocker buildしなくてはいけなくなるからです。

だかしかし、つまりこれはコンテナのなかにソースコードは入っていないということなのです。
当然イメージにソースコードは含まれていません。

そんなイメージをFargateで展開したってなんも出てこないのは当たり前です。

ということでどこかしらで公開するということになったら、これまでVolumeでマウントしていたファイルたちをイメージの中に内包することが必要になってきます。

その時に使うのがCOPYです。

COPY . /app/src/

みたいにDockerfileに書いて上げればイメージにソースコードが内包されます。

開発環境はVolumeでマウントすればいいのですが、デプロイするってなったらCOPYしなくちゃいけないよ。

とここで、僕は開発と本番でDockerfile2つ書かなくちゃいけないのかよめんどくさーって思いました。
次に繋がります。

検証と本番でかき分けるマルチステージドサービス

検証と本番のDockerfile分けるのめんどくせーって思っていたし、どうやら2016年とかは分けるしかなかったみたいなんだけど、今は「マルチステージドサービス」というものがあって、分けなくていいんだとさ。

FROM node:10.16.3 AS developmentFROM node:10.16.3-slim AS production

このFROM ・ AS hogeってのがポイント。
このhogeってところを指定することで開発用とか本番用とか分けられるんだって。

指定はtargetでやればいいらしいよ。

docker-compose.yaml
version: '3.7'

services:
  chat:
    build:
      context: .
      target: development
    command: npx nodemon index.js
    ports:
      - '3000:3000'
    volumes:
      - .:/srv/chat
      - chat_node_modules:/srv/chat/node_modules

上にtargetって書いてありますが、そこをASの後ろのワードにすればOK。

で多分これの本当の良さはここではないんだが、ぶっちゃけまだ良くわかってないのでなんとなく書くんだけれど

COPY --from=development --chown=root:root /srv/chat/node_modules ./node_modules

こういうことができるのですよ。

つまり、別のステージのビルド結果をコピーできるってことですね。

Dockerガリガリつかってないからなんとも言えないので詳しくはこのあたりを読むといいと思う。
https://qiita.com/minamijoyo/items/711704e85b45ff5d6405

以上

まとめるとたいしたことないな。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Raspberry Piで、自分のBlynkサーバーを立ててWidgetをタダで使おう

Blynkはサーバーも要らなくていいね。

Blynkは、インターネット上にあるBlynk Serverを介して、デバイスと情報のやりとりをします(下の図)。スマホとIOTデバイスだけで簡単にリモート操作ができるので大変すばらいい。
architecture.png

ローカルサーバーを持つ利点は?

今回、紹介するのは、そのBlynk serverをお家のRaspberry Piに立てることです。うん?サーバーなしですぐできるのが長所なのになぜだ?と思うかも知りません。

  • 応答が速い (お家の無線LANだけでやりとりするので)
  • BlynkのWidgetが使い放題(タダです)

下記の図のようにBlynk mobileアプリにジョイスティックやボタンなどのWidgetをつけるのにお金がかかります。(Energyというポイントが必要で、最初に1000pointsまで無料です)自分のサーバーなら、それを自由に設定できるので、開発の際に自由度がぐーんと増します。そして、お家のサーバーなので、ネットを介すよりタイムラグがすくなく、例えば、リモコンでミニカーを操作する場合、操作性が断然違います。

IMG_3766.PNG

今回使ったもの

  • Raspberry Pi 3 Model B+

手順

  1. Raspberry piにDockerをインストール
  2. Blynk ServerカスタマイズしてImage作成
  3. 自動起動設定と起動
  4. Mobile AppのBlynkでユーザー作成

1. Raspberry piにDockerをインストール

下記の先人の知恵を借りました。

ただ、素早くやってみたいだけの方はこちらのコードでどうぞ

// 現在の Raspberry pi内の環境
$ uname -a
Linux raspberrypi 4.14.98-v7+ #1200 SMP Tue Feb 12 20:27:48 GMT 2019 armv7l GNU/Linux

$ lsb_release -a
No LSB modules are available.
Distributor ID: Raspbian
Description:    Raspbian GNU/Linux 9.8 (stretch)
Release:    9.8
Codename:   stretch

Raspberry Piに Docker インストール

# Install
$ curl -fsSL https://get.docker.com -o get-docker.sh
$ sudo sh get-docker.sh
$ sudo systemctl enable docker
$ docker --version
# Docker version 19.03.9, build 9d98839

# Raspbianの標準ユーザのpiにDockerの実行権限を付与します。
$ sudo usermod -aG docker pi

インストールと起動確認
インストールしたら、常にdockerが動くようになっています。

# 確認
$ systemctl status docker.service
● docker.service - Docker Application Container Engine
   Loaded: loaded (/lib/systemd/system/docker.service; enabled; vendor preset: enabled)
   Active: active (running) since Sat 2020-05-23 21:56:03 JST; 1 day 14h ago

$ ps aux | grep docker
root       416  0.1  6.5 967836 58464 ?        Ssl  May23   2:48 /usr/bin/dockerd -H fd:// -- ....
root       857  0.0  0.3 866456  3152 ?        Sl   May23   0:34 /usr/bin/docker-proxy -proto tcp -host-ip ....
root       870  0.0  0.3 867224  3396 ?        Sl   May23   0:00 /usr/bin/docker-proxy -proto tcp -host-ip ....
root       879  0.0  0.6 799176  5672 ?        Sl   May23   0:25 containerd-shim -namespace moby -workdir ....
pi       12761  0.0  0.0   4340   564 pts/0    S+   12:49   0:00 grep --color=auto docker

2. Blynk ServerカスタマイズしてImage作成

blynkの公式Github repositoryにdockerを使った起動方法がありますが、そこの説明のままに実行したら毎回データーが初期化されるなど不便なところがあります。そこで、そのイメージを少しカスタマイズしたものを用意したので、このrepositoryからクローンして、Raspberry piで、実行してください。
ここでは、まず、自分のローカル

# イメージビルドソースをクローン
# ビルドしたイメージの中でblynk
$ git clone https://github.com/yusonkim/localblynkserver.git
$ cd localblynkserver

# サーバー設定ファイルを開く(各自環境に合わせてeditorを使ってください)
$ vim server.properties

initial.energyは、新しいアカウントを作成した時に使えるWidget購入pointです。そのままでも十分ですが、ガンガン使うならもっと高くしても良いでしょう。
admin.emailadmin.passは、ウェブからの管理画面にアクセスする時に使うものです。この管理画面のURLは、https://<Rasberry piのhostname>.local:9443/adminで、お家のネットワーク内で、Macなど他のPCから接続できます。暗証番号等を新たに変更してください。

server.properties
...中略
#initial amount of energy
initial.energy=100000
...中略
admin.email=admin@blynk.cc
admin.pass=admin

そのあとは、Raspberry pi内でカスタムしたイメージを作成して、Rapberry piが再起動しても常に立ち上がるように設定するだけです。

# 設定ファイルを取り込んだカスタムBlynk Server Imageを作成
$ ./build.sh

3. 自動起動設定と起動

下記を参照しました。

systemctlを使ってRaspberry Piが起動すると自動的にdocker runコマンドで起動するように設定しています。その時に、永続化するボリュームを指定してユーザーデーターがなくならないようにしています。実際には、下記のスクリプトを実行するだけでよいです。

# Blynk Serverが自動的に起動するように設定
$ ./deploy.sh

# 再起動
$ sudo reboot

# 再起動後確認
# 起動までに約1分ほどかかります。
$ docker ps

CONTAINER ID        IMAGE                       COMMAND             CREATED              STATUS              PORTS                                                      NAMES
646b4dbdd298        rasbian-blynk:customlocal   "entrypoint.sh"     About a minute ago   Up About a minute   0.0.0.0:8080->8080/tcp, 0.0.0.0:9443->9443/tcp, 8440/tcp   blynk

4. Mobile AppのBlynkでユーザー作成

アプリがなければ、AppStoreなどからダウンロードしてください。
もしログインしていれば、一度ログアウトして、新しくアカウントを作ります。
ローカルサーバーへの接続は、新しいアカウントを作る時に指定します。
Create New Account を選択して、EmailやPasswordを入力します。
そのあと、下にあるサーバー選択アイコンを選択します。
IMG_3765.PNG
BlynkからCustomに設定を変更してから、住所にRaspberry piのIP、もしくは .localをいれ、ポート番号を9443に指定します。
IMG_3764.PNG
プロジェクトを作って見ると、Energyが増えていることが確認できます。

その他

管理画面から、token取得

IOTデバイスで動くプログラムを組むときは、PCだと思いますが、その時に、各プロジェクトごとにデバイスのtokenをいれなければなりません。結構長いので、モバイルのBlynkアプリからコピーするのは大変だと思いますが、先ほど出ていた管理画面に接続すれば、簡単に参照できます。

https://<RaspberryPiのIP>:9443/adminに接続します。

するとこんな画面が、でます。今回ssl証明書の設定をしてないので、怒られています。Advencedを押して、Proceed to ...を押して管理画面に入ることができます。sslの設定方法はblynkの公式Github repositoryにありますので、ソース上のserver.propertiesファイルも変更すれば、ご自身で設定できると思います。
Screen Shot 2020-05-26 at 0.10.19.png
Screen Shot 2020-05-26 at 0.10.24.png

設定したadmin情報でログインして見ると、
User欄からモバイルで登録したアカウントが確認できます。
Screen Shot 2020-05-26 at 0.17.01.png
そして、そのアカウントをクリックすれば、プロジェクトやデバイスなどの情報と、デバイスの情報欄にtokenが表示されていることがわかりますので、ここからコピーすれば良いでしょう。
Screen Shot 2020-05-26 at 0.19.33.png

サーバー設置方法検討

Blynk社でローカルサーバー用のBlynk Server公開しているので、それを使えば簡単にローカルサーバーが立てられます。
が、今回はDockerを使ってサーバーを立てます。その理由は、

  • どんな環境(ハードウェアに関係なし)でも失敗せずに立ち上げられます。
  • パッケージやプログラム言語をインストールする必要がない
  • 起動・停止・削除が簡単で、綺麗に行える

Dockerを使うときは、ボリュームを適切にマウントして、ユーザーデーターを保持することに注意が必要です。そうしなければ、サーバーを起動する度に初期化状態になります。簡単にできますので、心配は要りません。

docker-composeやkubernetes(k8s)を使うのもいいですが、今回は必要性をあまり感じませんでした。postgress dbを使って、データー(IOTデバイスからの生データー)を保存するとなるとdocker-composeやk8sもいいオプションですが、今回は、小さな容量で動くRaspberryPiなので、dbは必要ありません。ユーザーデーターもファイルで保存する方法でやっていますので、Blynk server単体で十分です。そして、サーバーの台数を増やしたりするAutoscalingも必要ありませんので、k8sもスキップしました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Deno+MySQL+DockerでAPIサーバーをさくっと作る

Deno

TypeScriptを標準サポートしているDenoのv1.0がリリースされたということで興味があったので触ってみました。
あと恐竜がかわいい。

作るもの

deno + MySQL + docker-composeでローカル環境にシンプルなAPIサーバーです。
適当にどう森の村民を登録して編集するようなやつとします。

全体がみたい方はこちら

https://github.com/rydein/deno_first_api

インストールとか下準備

公式に書いてある通りですが以下のコマンドを流すだけです

$ brew install deno

エディタはVSCodeを使いますがdenoのimport記法に対応させるためこちらのプラグインを追加しましょう。

Screen Shot 2020-05-25 at 23.49.31.png

Docker + docker-compose

deno用のDockerfileとdocker-compose.ymlはこちらを使います

Dockerfile
FROM hayd/ubuntu-deno:1.0.0

EXPOSE 3000

WORKDIR /app

USER deno

ADD . .

RUN deno cache index.ts

CMD ["run", "--allow-net", "index.ts"]
docker-compose.yml
version: '3'

services:
  deno:
    build: .
    ports:
      - "3000:3000"

  db:
    image: mysql:8
    ports:
        - "3306:3306"    
    environment:
      - MYSQL_ROOT_PASSWORD=Passw0rd
      - MYSQL_DATABASE=deno-dev
    volumes:
      - db-volume:/var/lib/mysql
      - ./mysql/conf:/etc/mysql/conf.d

volumes:
  db-volume:

ローカル環境立ち上げ

とりあえずMySQLをたちあげておきたいので動く環境だけ用意します

index.ts
import { Application, Router } from "https://deno.land/x/denotrain@v0.5.0/mod.ts";

const app = new Application();
const router = new Router();

app.get("/", (ctx) => {
  return {"hello": "world"};
});
await app.run();

立ち上げます

$ docker-compose build
$ docker-compose up -d

DB migration

まずはDBのテーブル定義を作成していきます。
migrationにはこちらのツールを使います。

https://deno.land/x/nessie

init

# nessie.config.ts を作成してdb接続情報を追記する
$ deno run --allow-net --allow-read --allow-write https://deno.land/x/nessie/cli.ts init

MySQL以外のデフォルトコードが生成されますが今回は不要なので消してしまいましょう。
configMySqlにDB接続情報を追記します。

nessie.config.ts
const configMySql = {
  migrationFolder: `./migrations`,
  connection: {
    hostname: "localhost", // hostからDockerのMySQLコンテナに繋ぐ
    port: 3306,
    username: "root",
    password: "Passw0rd",
    db: "deno-dev",
  },
  dialect: "mysql",
};

export default configMySql;

接続情報が作成できたのでmigrationファイルを作成します。
create_villagersの部分は作成するテーブルによって適宜読み替えてください。

# 村民のmigration
$ deno run --allow-net --allow-read --allow-write https://deno.land/x/nessie/cli.ts make create_villagers

migration/ディレクトリにmigrationファイルが生成されるので必要な情報を追記していきます。
村民には名前、性別、性格、誕生日があるので追加します。

migrations/1590336600981-create_villagers.ts
import { Schema } from "https://deno.land/x/nessie/mod.ts";

// migration ファイルにテーブル情報を追加する
export const up = (schema: Schema): void => {
    schema.create("villagers", (table) => {
        table.id();
        table.string("name", 100).nullable();        // 名前
        table.string("gender", 100).nullable();      // 性別
        table.string("personality", 100).nullable(); // 性格
        table.string("birthday", 100).nullable();    // 誕生日
    });
};

export const down = (schema: Schema): void => {
    schema.drop("villagers");
};

migrationを実行しましょう。

#migration の実行
$ deno run --allow-net --allow-read https://deno.land/x/nessie/cli.ts migrate

Query Builder

DBからデータを参照したり登録したりするにはこちらのクエリビルダーを使います。

https://deno.land/x/dex

場所はどこでも良いですがmodels/villagers.tsにDBに登録、取得する処理を作成します。

models/villagers.ts
import Dex from "https://deno.land/x/dex/mod.ts";
import client from "./config.ts";

let dex = Dex({client: "mysql"});
let table = "villagers";

interface Villager {
    id?: number,
    name: string,
    gender: string,
    personality: string,
    birthday: string
}

///
/// 新しい村民を追加して追加したデータを返す
///
function addVillager(villager: Villager) {
    const insertQuery = dex.queryBuilder().insert([villager]).into(table).toString();
    return client.execute(insertQuery).then((result: any) => {
        const getQuery = dex.queryBuilder().select().from(table).where({id: result.lastInsertId}).toString();
        return client.execute(getQuery).then((result: any) => result.rows ? result.rows[0] : {});
    })
}

///
/// 全ての村民を返す
///
function getAllVillagers() {
    const getQuery = dex.queryBuilder().select("*").from(table).toString();
    return client.execute(getQuery);
}

///
/// 村民の更新を行う、更新されたデータを返す
///
function editVillager(id: number, villager: Villager) {
    const editQuery = dex.queryBuilder().from(table).update(villager).where({id}).toString();
    return client.execute(editQuery).then(() => {
        const getQuery = dex.queryBuilder.select().from(table).where({id}).toString();
        return client.execute(getQuery).then((result: any) => result.rows ? result.rows[0] : {});
    });
}

///
/// 村民の削除
///
function deleteVillager(id: number) {
    const deleteQuery = dex.queryBuilder().from(table).delete().where({id}).toString();
    return client.execute(deleteQuery)
}

export {
    addVillager,
    getAllVillagers,
    editVillager,
    deleteVillager
}

Http Server

https://deno.land/x/denotrain

controllers/villagers.ts にコントローラーを作成し、APIのルーティングを作成してリクエストを処理していきます。

controllers/villagers.ts
import { Router } from "https://deno.land/x/denotrain@v0.4.4/mod.ts";
import { addVillager, getAllVillagers, editVillager, deleteVillager } from "../models/villagers.ts";

const api = new Router();

api.get("/", (ctx) => {
    return getAllVillagers().then((result: any) => {
        return result.rows;
    })
})

api.post("/", (ctx) => {
    const body = {
        name: ctx.req.body.make,
        gender: ctx.req.body.gender,
        personality: ctx.req.body.personality,
        birthday: ctx.req.body.birthday,
    }

    return addVillager(body).then((villager: any) => {
        ctx.res.setStatus(201);
        return villager;
    })
})

api.patch("/:id", (ctx) => {
    const body = {
        name: ctx.req.body.make,
        gender: ctx.req.body.gender,
        personality: ctx.req.body.personality,
        birthday: ctx.req.body.birthday,
    }

    return editVillager(ctx.req.params.id as number, body).then((result: any) => {
        return result;
    })
});

api.delete("/:id", ctx => {
    return deleteVillager(ctx.req.params.id as number).then(() => {
        ctx.res.setStatus(204);
        return true;
    })
});

export default api;

index.tsにサーバーのエントリーポイントを用意し、作成したRouterとURLをセットします。
/api/villagersに作成したRouterをセットすることで、このURL配下に上記で作成したルーティングが用意されます。

import { Application, Router } from "https://deno.land/x/denotrain@v0.4.4/mod.ts";
import api from "./controllers/villagers.ts";

const app = new Application({});
app.use("/api/villagers", api);

app.run();

起動

サーバーの用意ができたので起動しましょう、以下のURLでAPIが起動しているはずです。
http://localhost:3000/

$ docker-compose build
$ docker-compose up -d

まずは最初のAPIを作ってみましたが、ライブラリを探すのも公式ページから検索できてドキュメントも読みやすくそろっているのでとくにハマることなく進みました。

Dockerのイメージも40MBぐらいで小さいので良さそうですね、次はクラウド環境にデプロイしてみたいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む