「[24時間365日]サーバー/インフラを支える技術」を読んだ
読んだのでメモ。
1章 冗長化・負荷分散の基本
コールドスタンバイ -> 予備機を使わずに、現用機に障害が発生したら切り変えること(e.g. ルーター) ホットスタンバイ -> 予備機も稼働させておくことで現用機と同じ状態を保つこと(e.g. Webサーバー) フェイルオーバー -> 現用機に障害が発生した時に自動的に処理を予備機に引き継ぐこと
Webサーバーのフェイルオーバーは2段階。
- ヘルスチェック
- 仮想IPの引き継ぎ
1. ヘルスチェック
IP引き継ぎの機会(障害の発生)を検出する。
- ICMP監視(レイヤ3)
- ping
- マシンに電源が入っていることをチェックするだけなので、webサーバーが落ちているかはわからない。
- ポート監視(レイヤ4)
- TCPで接続
- Webサーバーが稼働しているかチェックするだけなので、サービスのエラーはわからない。
- サービス監視(レイヤ7)
- HTTPリクエスト
- サービスのエラーはわかるが、他と比べると負荷が高い。
2. 仮想IPの引き継ぎ
- ただIPを別のサーバーに割り当てても通信はルーターから届かない
4章 性能向上、チューニング。Linux単一ホストの負荷の見極め
負荷 -> 複数のタスク(プロセス)がリソースを奪い合う結果生じる待ち時間
ボトルネック見極めの基本的な流れ
多くの場合Webアプリケーションにおいては 負荷分散 = ディスクI/Oの軽減
- キャッシュを効かせることが鍵
AppサーバーはCPUバウンドであることが多い
- 基本的にDBサーバーから受け取ったデータを加工してクライアントサイドに送るだけだから
DBサーバーはI/Oバウンドであることが多い
- 多くのデータをディスクからフェッチするから
topなどで出る3つのロードアベレージの数字はそれぞれ直近1分、5分、15分の間で待ち状態にあったタスクの単位時間あたりの数
- プロセススケジューラがプロセスディスクリプタの情報を元にタスクの優先順位を決める
- ディスクリプタの正体は
task_struct
という構造体 https://github.com/torvalds/linux/blob/61a09258f2e5b48ad0605131cae9a33ce4d01a9d/include/linux/sched.h#L629 - ディスクリプタに記録されるプロセスの状態:
- TAS_RUNNING ... 実行可能 or 実行中。CPUリソースが余っていなくて待たされている
- TASK_INTERRUPTIBLE... 割り込み可能。ユーザーの入力待ちなどの予測のできない復帰
- TASK_UNINTERRUPTIBLE ... 割り込み不可能。ディスクの入出待ちなどの短時間の復帰
- TASK_SLEEP
- TASK_ZOMBIE ... 子プロセスがexitして親プロセスにkillされるのを待っている状態
- ディスクリプタの正体は
- プロセススケジューラがプロセスディスクリプタの情報を元にタスクの優先順位を決める
ロードアベレージのカーネルコードを見る
- run queueに存在するTASK_RUNNINGとTASK_UNINTERRUPTIBLEの2つをカウントしている
- ロードアベレージにカウントされるのはCPU or I/O待ちで実行されていないタスク
long calc_load_fold_active(struct rq *this_rq, long adjust) { long nr_active, delta = 0; nr_active = this_rq->nr_running - adjust; nr_active += (long)this_rq->nr_uninterruptible; if (nr_active != this_rq->calc_load_active) { delta = nr_active - this_rq->calc_load_active; this_rq->calc_load_active = nr_active; } return delta; }
CPU使用率とI/O待ち率
- マルチCPU(シングルコアを仮想的に複数のcpuに見せることが可能)
- cpu負荷は分散できるがI/O負荷は分散できない
- e.g) 2cpuだとして、全体のI/O負荷20%、cpu1のI/O負荷0%、cpu2のI/O負荷40%がありえる
- CPU使用率、I/O待ち率はCPUごとに見ていく必要がある
- e.g) 2cpuだとして、全体のI/O負荷20%、cpu1のI/O負荷0%、cpu2のI/O負荷40%がありえる
- cpu負荷は分散できるがI/O負荷は分散できない
CPU使用時間などを記録する構造体。sarが表示する項目が全部入っている:
enum cpu_usage_stat {
CPUTIME_USER,
CPUTIME_NICE,
CPUTIME_SYSTEM,
CPUTIME_SOFTIRQ,
CPUTIME_IRQ,
CPUTIME_IDLE,
CPUTIME_IOWAIT,
CPUTIME_STEAL,
CPUTIME_GUEST,
CPUTIME_GUEST_NICE,
NR_STATS,
};
cpu使用率のカーネルコードを見る
タイマ割り込みの度にaccount_process_tickを呼ぶ
void update_process_times(int user_tick) { struct task_struct *p = current; /* Note: this timer irq context must be accounted for as well. */ account_process_tick(p, user_tick); run_local_timers(); rcu_sched_clock_irq(user_tick); ...
user_tickが正であればaccount_user_timeを、そうでなければaccount_system_timeもしくはaccount_idle_timeを呼ぶ
void account_process_tick(struct task_struct *p, int user_tick) { u64 cputime, steal; if (vtime_accounting_enabled_this_cpu()) return; if (sched_clock_irqtime) { irqtime_account_process_tick(p, user_tick, 1); return; } cputime = TICK_NSEC; steal = steal_account_process_time(ULONG_MAX); if (steal >= cputime) return; cputime -= steal; if (user_tick) account_user_time(p, cputime); else if ((p != this_rq()->idle) || (irq_count() != HARDIRQ_OFFSET)) account_system_time(p, HARDIRQ_OFFSET, cputime); else account_idle_time(cputime); }
各関数はtask_struct構造体の対応するメンバに時間を足して記録する
void account_user_time(struct task_struct *p, u64 cputime) { int index; /* Add user time to process. */ p->utime += cputime; /* <- ここ account_group_user_time(p, cputime); index = (task_nice(p) > 0) ? CPUTIME_NICE : CPUTIME_USER; /* Add user time to cpustat. */ task_group_account_field(p, index, cputime); /* Account for user time used */ acct_account_cputime(p); }
void account_system_time(struct task_struct *p, int hardirq_offset, u64 cputime) { int index; if ((p->flags & PF_VCPU) && (irq_count() - hardirq_offset == 0)) { account_guest_time(p, cputime); return; } if (hardirq_count() - hardirq_offset) index = CPUTIME_IRQ; else if (in_serving_softirq()) index = CPUTIME_SOFTIRQ; else index = CPUTIME_SYSTEM; account_system_index_time(p, cputime, index); } void account_system_index_time(struct task_struct *p, u64 cputime, enum cpu_usage_stat index) { /* Add system time to process. */ p->stime += cputime; /* <- ここ account_group_system_time(p, cputime); /* Add system time to cpustat. */ task_group_account_field(p, index, cputime); /* Account for system time used */ acct_account_cputime(p); }
void account_idle_time(u64 cputime) { u64 *cpustat = kcpustat_this_cpu->cpustat; struct rq *rq = this_rq(); if (atomic_read(&rq->nr_iowait) > 0) cpustat[CPUTIME_IOWAIT] += cputime; else cpustat[CPUTIME_IDLE] += cputime; }
psとスレッド
- 別名LWP(Light Weight Process)
- カーネル内では"タスク"としてプロセスと同じ扱いを受ける
- ps -Lで見れる
- ps aux -L | wc -l と数字が一致するので、sar -qのplist-sz(システムに存在するプロセス数)にはスレッドの数も含まれている雰囲気
ps, sar, vmstatの使い方
- ps
- TIMEはCPU使用時間のことであり、プロセスが起動してからの時間ではない
- sar
- sadcというバックグラウンドで動く、カーネルからレポートを収集して保存するプログラムがある
- 時間毎のOSの状態が見れるので、プログラムなどを入れ替えた直後に使ったりする
/var/log/sa
に過去の情報がある
- vmstat
その他
- Linuxは可能な限り空いているメモリをキャッシュに回そうとする
- DBサーバー再起動->flushでキャッシュが空になる->データ全体に読み込みをかけることでキャッシュ構築->本番環境に戻す
「まんがでわかるLinux シス管系女子3」を読んだ
読んだので軽くメモ。
https://www.amazon.co.jp/dp/B07CM2YNVD/ref=cm_sw_r_tw_dp_U_x_8b9nEb3ZHTFF5
特定のポートでのみ通信を許可する
ファイアウォール・・・許可された通信以外を遮断する仕組み。今回のこれはパケットフィルター。
$ sudo ufw default deny # まず全て遮断する $ sudo ufw allow 22 # 通信を許可したいポートだけ個別で許可 $ sudo ufw allow 80 $ sudo ufw enable # 有効果 $ sudo ufw status # チェック
踏み台の向こうのサーバーに直接ファイルをコピーする
ポートフォワーディングすれば手元のマシンのポートと踏み台の向こうのサーバーのポートの間で直接通信が行われる。(漫画はコピーしたいファイルが大きすぎて踏み台のディスク容量が不足してたところをポートフォワーディングで直接通信させて解決、という流れ)
# ポートフォワーディングする(手元 - コピー先) $ ssh -L take@踏み台ip 50022(適当):コピー先ip:22 # 手元のマシンから直接コピー(トンネルの入り口があるlocalhostをコピー先に指定) $ scp -p 50022 fuga/hoge.txt take@localhost:/tmp
誰がそのポート番号を使っているのか知りたい
lsof
... ofはOpen Files。プロセスが開いているファイルや、ファイルを開いているプロセスを調べるのに使われる。
$ lsof -i :3000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME ruby 16873 masakazutakewaka 12u IPv4 0x150e6b95224251b3 0t0 TCP localhost:hbci (LISTEN) ruby 16873 masakazutakewaka 13u IPv6 0x150e6b951d1cfa73 0t0 TCP localhost:hbci (LISTEN)
逆にプロセスが使っているポート番号を調べる。
$ lsof -p 16873 | grep LISTEN ruby 16873 masakazutakewaka 12u IPv4 0x150e6b95224251b3 0t0 TCP localhost:hbci (LISTEN) ruby 16873 masakazutakewaka 13u IPv6 0x150e6b951d1cfa73 0t0 TCP localhost:hbci (LISTEN)
誰がネットワークの帯域を使っているのか調べたい
- どのプロセスがどれくらい帯域を使っているのかが知りたければ
nethogs
- 誰との通信で帯域が使われているのかが知りたければ
iftop
管理者権限で行われた捜査の履歴を辿りたい
auth.logを見る。 Ubuntuだと/var/log/auth.log
。
管理者権限の操作に必ずsudo
が付くように、rootアカウントを無効化する。
# passwdにlockオプションをつけるとそのアカウントにログイン出来なくなる $ sudo passwd --lock root
sshの総当たり攻撃を締め出したい
漫画ではfailregex
に認証失敗を締め出す正規表現を追加していたが、僕の環境(Ubuntu18.04, fail2ban0.10.2)では/etc/fail2ban/jail.conf
に以下の記述があった。
# To use more aggressive sshd modes set filter parameter "mode" in jail.local: # normal (default), ddos, extra or aggressive (combines all). # See "tests/files/logs/sshd" or "filter.d/sshd.conf" for usage example and details. #mode = normal
いう通りに/etc/fail2ban/jail.local
を作成。 実験したいだけなので締め出し期間は1分。
[sshd] mode = aggressive bantime = 1m
デーモンを再起動して、sshで何回か意図的に認証失敗してみたら、きちんと締め出された。
# 検証用に用意した仮想マシンの中 $ sudo systemctl restart fail2ban # ホストマシンでsshの認証失敗を数回 # 検証用に用意した仮想マシンの中 $ sudo fail2ban-client status sshd Status for the jail: sshd |- Filter | |- Currently failed: 0 | |- Total failed: 6 | `- File list: /var/log/auth.log `- Actions |- Currently banned: 0 |- Total banned: 1 `- Banned IP list:
なぜデフォルトで締め出しの設定がないのかというと、fail2banが本来弱いパスワード認証に対する締め出しに使われるもので、ssh認証の諦めメッセージConnection closed by...
はログを溢れさす以上のことはしないかららしい。(?)
管理者権限が必要な操作を一部許可する
/etc/sudoers
を編集する。開くとThis file MUST be edited with the 'visudo' command as root.
とあるので、visudo
で編集する必要がある。visudo
はバリデーションが効く。
シンタックスはユーザー名 この設定が有効なIP=(なり変われるユーザー:なり変われるグループ) 実行を許可するコマンド
。 ()内は(ALL)
の様にグループを省略可能で、「なれるグループなし」という意味になるが、sudo -g
はあまり使われないので問題なしとのこと。
自分に対して管理者権限でのpasswd
とsu
を禁止してみる。
# /etc/sudoersに以下の様に書かれているので新たにファイルを作成する # Please consider adding local content in /etc/sudoers.d/ instead of # directly modifying this file. $ sudo visudo -f /etc/sudoers.d/take # /etc/sudoers.d/takeの中 take ALL=(ALL:ALL) ALL, !/bin/su, !/usr/bin/passwd # 1番下の設定の優先順位が1番高い $ sudo -l Matching Defaults entries for take on take-VirtualBox: env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin User take may run the following commands on take-VirtualBox: (ALL : ALL) ALL (ALL) ALL, !/bin/su, !/usr/bin/passwd, !/bin/ls # suとpasswdがsudoで実行できなくなっていることを確認 $ sudo su Sorry, user take is not allowed to execute '/bin/su' as root on take-VirtualBox. $ sudo passwd Sorry, user take is not allowed to execute '/usr/bin/passwd' as root on take-VirtualBox.
その他
VirtualBoxのマシン(Ubuntu)に原始的なやり方で固定IPを割り当てる
"漫画でわかるLinux シス管系女子3" の1章で原始的(?)な固定IPの割り当て方法が紹介されていた。具体的にはUbuntuの/etc/network/interfaces
を編集するやり方。
ネットワークの基本的な知識が確かめられるので、経験しておくのもいいと思いVirtualBoxで試してみた。(漫画に出てくる色んな技術を試すのに、コンテナより仮想マシンの方がいいのかなという割とふわっとした考え)
Ubuntuのバージョンは18.04。
/etc/network/interfacesを開いてみる
まだループバックアドレスしか定義されていない。このファイルに固定IPアドレスの情報を追加していく。
# interfaces(5) file used by ifup(8) and ifdown(8) auto lo iface lo inet loopback
インターフェースのIPアドレスを調べる
eth0
がなくて、代わりにenp0s3
が存在していることに気づく。
調べたところenp0s3
というのはsystemdが導入したもので、ハードウェアのコネクター(?)の名前らしい。従来のネーミングだと不都合があるっぽい。
https://askubuntu.com/a/704121
$ ip a #... 2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:15:b1:d2 brd ff:ff:ff:ff:ff:ff inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic noprefixroute enp0s3 valid_lft 86276sec preferred_lft 86276sec inet6 fe80::893:8d2b:c542:f28b/64 scope link noprefixroute valid_lft forever preferred_lft forever
動的IPアドレスで、値は10.0.2.15
。サブネットマスクは255.255.255.0
。
ちなみになぜ192.168.1.15
のような感じではなく10.0.2.15
なのかを調べてみたら、VirtualBoxが10.0.2.x
のプライベートネットワークを作るからだった。(これは昔仕事でVirtualBoxを使っていた時に目にした記憶がある)
virtualbox - Trying to SSH to local VM Ubuntu with Putty - Unix & Linux Stack Exchange
ゲートウェイのIPアドレスを調べる
$ ip r # default via 10.0.2.2 dev enp0s3 onlink # 10.0.2.0/24 dev enp0s3 proto kernel scope link src 10.0.2.20 # 169.254.0.0/16 dev enp0s3 scope link metric 1000
/etc/network/interfacesを編集する
こんな感じで書く。
# interfaces(5) file used by ifup(8) and ifdown(8) auto lo iface lo inet loopback auto enp0s3 iface enp0s3 inet static address 10.0.2.20 netmask 255.255.255.0 gateway 10.0.2.2
設定したインターフェースを起動する
$ sudo ifup enp0s3
意図通りに値が更新されている。10.0.2.15
→ 10.0.2.20
$ ip a #... 2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 08:00:27:15:b1:d2 brd ff:ff:ff:ff:ff:ff inet 10.0.2.20/24 brd 10.0.2.255 scope global enp0s3 valid_lft forever preferred_lft forever inet6 fe80::a00:27ff:fe15:b1d2/64 scope link valid_lft forever preferred_lft forever
ホストからsshできるか検証
VirtualBoxがつくるプライベートネットワークにホストからアクセスできないのでまずポートフォワーディングする。
host:~$ ssh -p 3022 take@127.0.0.1 take@take-VirtualBox:~$ # VirtualBox machine
sshすることができたので、きちんと設定できてた模様。
「理論から学ぶデータベース入門」を読んだ
読んだのでメモ。
1章 リレーショナルモデルとSQL
リレーショナルモデルとは、リレーショナルモデルとSQLにおける演算の比較
- リレーショナルモデルはデータモデルの1つで集合論がベース
- 集合 -> 要素が重複しない、要素が集合に含まれるか確実に判断可能
- つまり集合にNULLはない
- 集合 -> 要素が重複しない、要素が集合に含まれるか確実に判断可能
- リレーショナルモデルのリレーションはSQLでいうテーブル
- 次のシンブルなSELECT文は3つのリレーションの演算 SELECT (射影) FROM (直積) WHERE (制限);
- SQLにあってリレーショナルモデルにないもの
- 要素の重複
- 値の更新
- トランザクション
- NULL
2章 述語論理とリレーショナルモデル
述語論理の簡単な導入、リレーショナルモデルの演算を述語論理で表現
- リレーショナルモデルは述語論理(論理学)もベースにしている
- 命題 -> ある物事についての文で、真偽値が問えるもの
- 命題論理 -> 既知の命題から他の命題の真偽値を導く
- 量化
- 集団を対象にした真偽値
- ある集団のすべての要素がある性質を満たすか
- ある集団にある性質を満たす要素があるか
- 集団を対象にした真偽値
- 命題論理 -> 量化で拡張 -> 述語論理
- 閉世界説
- 未知の事実は存在しない(全ての事実はリレーションに含まれる)という考え方
- 全ての問いがリレーションの演算で解決する
- 未知の事実は存在しない(全ての事実はリレーションに含まれる)という考え方
3章 正規化理論(関数従属性)
関数従属性、1NF - BCNFの解説
- 正規化理論はデータの不整合性の原因である「重複」を取り除く方法
- 候補キー
- タプルを一意に特定できる、無駄な要素が含まれない属性の集合(無駄が含まれるとスーパーキー)
- 1つのリレーションにそのような集合は複数存在しうるので「候補」
- 関数従属性
- AがわかればBもわかるとき、BはAに関数従属する
- 無損失分解
- リレーションをjoinで再構築可能なように複数のリレーションに分解する
- 1NF
- リレーションであるかどうか
- 行と列に順序がない
- 行が重複しない
- 値にNULLがない
- 値がこれ以上分解できない(意味的に)
- リレーションであるかどうか
- 2NF, 3NF
- 無損失分解で関数従属性を取り除いていく
- BCNF
- 自明なものを除いて関数従属性が存在しない
- 多くの場合ここまでくると5NFまで満たしている
4章 正規化理論(結合従属性)
結合従属性、4NF - 6NFの解説
- 結合従属性 -> リレーションRの見出しの部分集合をjoinしてRが再構築できれば、Rは結合従属性を持つ
- 無損失分解ができるということ
- 関数従属性は結合従属性の一部
- 4NF - 6NF
- 無損失分解で結合従属性を取り除いていく
- 5NF -> 暗黙的ではない全ての結合従属性が取り除かれた状態。最後の正規形
- 6NF -> 全ての結合従属性が取り除かれた状態。無駄な結合が増える
5章 リレーションの直交性
- 直交 -> 2つ以上のリレーションに同じタプルが含まれない
- 複数リレーション間の重複を解消するもの
- 異なるテーブルに意味は違うが値は同じレコードが存在することもあるので、直交化は必須ではない
6章 ドメインの設計戦略
- ドメイン -> 属性が取り得る値の集合
- 優れたDB設計がしたいならまず優れたアプリケーション設計スキルを身につけよう
- アプリケーションへの理解が曖昧であったり、設計に欠陥があると、DB設計も悲惨になる
- DBは本質的なデータを扱う
- 表示上の問題をDBに持ち込まない
- ナチュラルキーの設計をする際は、用いる値がIDとして機能するかきちんと吟味する
- ISBNは長年運用されているが、書籍の旧版と新版に同一の値を割り振る出版社のせいで、実はIDとしては機能していない
- パーツに意味があるIDはその値に依存した処理で問題が発生する
- 冗長で非効率なクエリになりがち
- 1NFでないから
- 冗長で非効率なクエリになりがち
7章 NULLとの戦い
- NULL -> unknownな状態
- 1NFを満たさない
- 閉世界説を破綻させる -> リレーショナルモデルも破綻
- オプティマイザへの弊害
- オプティマイザはクエリの結果が等価であることを数学的に証明できる組み合わせから選択する
- NULLがあると数学的に証明できる組み合わせが激減する
- オプティマイザはクエリの結果が等価であることを数学的に証明できる組み合わせから選択する
- クエリのコスト見積もりに影響
- インデックス上でNULLはそのインデックスの先頭か最後尾にまとめられる
- IS NULLでその部分を全スキャンする必要が生じる
- インデックス上でNULLはそのインデックスの先頭か最後尾にまとめられる
9章 履歴データとの戦い
- 履歴データとRDBは相性が良くない(リレーショナルモデルと噛み合わない)
- リレーションでないので1NFを満たさない
- 時間によってクエリの実行結果が変わる
- リレーションとはある時点における事実のなので、これはリレーションではない <- ??
- 初期値NULLのカラムが登場しやすい
- 暗黙の意味を持つ特定のレコードの存在
- e.g.) ある商品の最新の価格
- リレーションとは「ある述語に当てはめると真になる要素の集合」なのでこれはリレーションではない
- e.g.) ある商品の最新の価格
- 時間によってクエリの実行結果が変わる
- リレーションでないので1NFを満たさない
11章 インデックスの設計戦略
B+ Tree
- 文字列は前方一致である必要あり
- 文字列の左端からソートされてるから
- マルチカラムインデックスの場合、左から順にカラムを指定
- インデックスの更新 -> 削除と挿入 -> 高コスト
パーティショニング
- 刈り込みによって検索が速くなることがメリット
- 刈り込みが効かないとそれぞれのパーティションに検索をかけることになりむしろ高コスト
- パーティショニングが有効なケース(多くのケースはインデックスで十分)
最適なインデックスを探す
- クエリが決まってから高速化のためにインデックスを設計する
- 先にインデックスを決め打ちしてそれに囚われてはいけない
where
select * from t1 where c1 > 100 and c2 = 'abc';
インデックスは(c2, c1)の順で貼る。('abc', 100) < (c2, c1) < ('abd', c1の最小値) という範囲がインデックスから検索される。
(c1, c2)だとc1 > 100が開いているため、B+ ツリーだとc2が解決されない。<- ??
join
select * from t1 join t2 on t1.c1= t2.c2 where t1.c3 =100 and t2.c4 = 'abc';
joinでは内部表(結合される方のテーブル)にインデックスが効く。
t2.c2がユニークではない場合(オプティマイザが駆動表と内部表を入れ替えることがある)、(c2, c4)のマルチカラムインデックスがあるとクエリが速くなる可能性がある。
t2をフェッチする時点でt1.c1 = t2.c2
とt2.c4 = 'abc'
の両方が適用されて、テーブルをフェッチした後にwhere句で制限する手間が省かれる。
14章 トランザクションの本質
トランザクションとは
データを正しく保つための手法。DBに限った概念ではない
トランザクションの機能
スケジューラー
- 異常の種類
- 分離レベル
- 基本的にはserializableを選ぶ(PostgreSQLのデフォルトはserializable)
- 性能が欲しい場合はread-commitedかrepeatable-readを選択して必要な時だけ明示的にロックを取ることもできる
- read-uncommitedはロールバックが機能しないので使われない
クラッシュリカバリ
Goでマイクロサービスやってみた
序文
マイクロサービスという設計手法が世に広まって数年、 今では様々なプロジェクトでマイクロサービスが採用されているのを目にします。
興味はあるけどまだよくわかってない、でも実際に手を動かして理解を深めたい、といったモヤモヤを抱えている人もいるのではないでしょうか?
私もその一人でしたが、この度マイクロサービスでサンプルAPIを実装してみたのでその実装方法、実際に手を動かしてみることで得た所感を共有させていただきたいと思います。
簡単なマイクロサービスの実装
今回私はマイクロサービスアーキテクチャを用いたAPIのサンプルをGoで実装しました。
https://github.com/masakazutakewaka/grpc-proto
下図が今回実装したサンプルAPIの概観になります。
APIはユーザーとアイテム、コーディネートという3つのサービスで構成されていて、
それぞれのサービスは別々のコンテナ上に配置されています。
そしてそれらのサービスにhttpでアクセスするためのリバースプロキシを用意するところまでやりました。
ディレクトリ構造はこのようになっていています。
grpc-proto/ ├── Gopkg.lock ├── Gopkg.toml ├── README.md ├── coordinate ├── docker-compose.yaml ├── gateway ├── item ├── user └── vendor
docker-composeはこのようになっています。
version: "3.6" services: item: build: context: "." dockerfile: "./item/app.dockerfile" depends_on: - "item_db" environment: DATABASE_URL: "postgres://takewaka:takewaka@item_db/grpcproto?sslmode=disable" item_db: build: context: "./item" dockerfile: "./db.dockerfile" environment: POSTGRES_DB: "grpcproto" POSTGRES_USER: "takewaka" POSTGRES_PASSWORD: "takewaka" restart: "unless-stopped" user: build: context: "." dockerfile: "./user/app.dockerfile" depends_on: - "user_db" environment: DATABASE_URL: "postgres://takewaka:takewaka@user_db/grpcproto?sslmode=disable" user_db: build: context: "./user" dockerfile: "./db.dockerfile" environment: POSTGRES_DB: "grpcproto" POSTGRES_USER: "takewaka" POSTGRES_PASSWORD: "takewaka" restart: "unless-stopped" coordinate: build: context: "." dockerfile: "./coordinate/app.dockerfile" depends_on: - "coordinate_db" - "item" - "user" environment: DATABASE_URL: "postgres://takewaka:takewaka@coordinate_db/grpcproto?sslmode=disable" ITEM_URL: "item:8080" USER_URL: "user:8080" coordinate_db: build: context: "./coordinate" dockerfile: "./db.dockerfile" environment: POSTGRES_DB: "grpcproto" POSTGRES_USER: "takewaka" POSTGRES_PASSWORD: "takewaka" restart: "unless-stopped" gateway: build: context: "." dockerfile: "./gateway/gateway.dockerfile" ports: - "8000:8080" depends_on: - "item" - "user" - "coordinate" environment: ITEM_URL: "item:8080" USER_URL: "user:8080" COORDINATE_URL: "coordinate:8080"
一つのサービスに対して一つのDBを用意しています。
他のサービスのデータを要求する手段を、サービスのインターフェース経由に限定することで各サービスは疎結合になっています。
それでは早速各サービスの中身を見ていきましょう。
Item
ディレクトリ構成
item/ ├── app.dockerfile ├── client.go ├── create_table.sql ├── db.dockerfile ├── main ├── pb ├── repository.go └── server.go
DBとの連携を担うrepository層、gprcサーバーを定義するserver層、grpcサーバーと通信するクライアント側の機能を担うclient層から成り立ちます。
今回は簡単なサンプル実装ということでドメインロジックは用意していません。(かなり不自然ですが、そういう理由でドメインロジックを格納する層が存在しないです。)
まずはItemサービスのプロトコル定義から見ていきましょう。
プロトコル定義
syntax = "proto3"; package pb; import "google/api/annotations.proto"; service ItemService { rpc getItem (getItemRequest) returns (getItemResponse) { option (google.api.http) = { get: "/item/{id}" }; } rpc getItems (getItemsRequest) returns (getItemsResponse) {} rpc postItem (postItemRequest) returns (postItemResponse) { option (google.api.http) = { post: "/item" body: "*" }; } } message Item { int32 id = 1; string name = 2; int32 price = 3; } . . .
ここではサービスのインターフェースとオブジェクトの型を定義しています。
また、google/api/annotations.proto
を使って、httpで通信するリバースプロキシのエンドポイントの定義もここでしています。
protocol bufferのコンパイラであるprotoc
を使ってこのファイルをコンパイルすると
item.pb.go
・・・Itemオブジェクトの型、gprcのAPIが定義されている。item.pb.gw.go
・・・リバースプロキシのAPIが定義されている。
が生成されます。
各層を実装していく上で、これらのファイルを適宜インポートしていきます。
※ protocコマンドをそのまま使うとオプションが大変なことになるので protoeasy を使いましょう。
client層
client層には、grpcサーバーと通信するクライアント側の機能を実装します。
. . . type Client struct { conn *grpc.ClientConn service pb.ItemServiceClient } func NewClient(url string) (*Client, error) { conn, err := grpc.Dial(url, grpc.WithInsecure()) if err != nil { return nil, err } client := pb.NewItemServiceClient(conn) return &Client{conn, client}, nil } func (client *Client) Close() { client.conn.Close() } func (client *Client) GetItem(ctx context.Context, id int32) (*pb.Item, error) { res, err := client.service.GetItem(ctx, &pb.GetItemRequest{Id: id}) if err != nil { return nil, err } return &pb.Item{ Id: res.Item.Id, Name: res.Item.Name, Price: res.Item.Price, }, nil } . . .
Client型の2つのフィールドにはそれぞれ
- conn : gprcサーバーへのコネクション
- service : クライアント側が持つメソッドを集約したもの
が格納されています。
ItemServiceClientの定義
type ItemServiceClient interface { GetItem(ctx context.Context, in *GetItemRequest, opts ...grpc.CallOption) (*GetItemResponse, error) GetItems(ctx context.Context, in *GetItemsRequest, opts ...grpc.CallOption) (*GetItemsResponse, error) PostItem(ctx context.Context, in *PostItemRequest, opts ...grpc.CallOption) (*PostItemResponse, error) }
Itemサービスと通信したい場合はNewClient
関数を呼び出すことで、gprcサーバーとのコネクションを貼ることができます。
server層
server層にはgprcサーバー周りの機能を実装していきます。
. . . type itemServer struct { r Repository } func ListenGRPC(r Repository, port int) error { listen, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { return err } server := grpc.NewServer() pb.RegisterItemServiceServer(server, &itemServer{r}) reflection.Register(server) return server.Serve(listen) } func (s *itemServer) GetItem(ctx context.Context, r *pb.GetItemRequest) (*pb.GetItemResponse, error) { item, err := s.r.GetItemByID(ctx, r.Id) if err != nil { return nil, err } return &pb.GetItemResponse{Item: item}, nil } . . .
肝となるのは
RegisterItemServiceServer(server, &itemServer{r})
の部分で、中で grpc#Server.RegisterService を使ってサービスに関わる実装をgprcサーバーに登録しています。
ここでいうサービスに関わる実装というのはitemServer
型のフィールドであるRepository
のことです。
Repository
の実装は次のrepository層の説明で詳しく見ていきましょう。
repository層
repository層にはDBと直接やり取りする部分を実装していきます。
. . . type Repository interface { Close() GetItemByID(ctx context.Context, id int32) (*pb.Item, error) GetItemsByIds(ctx context.Context, ids []int32) ([]*pb.Item, error) InsertItem(ctx context.Context, name string, price int32) error } type postgresRepository struct { db *sql.DB } func NewPostgresRepository(url string) (Repository, error) { db, err := sql.Open("postgres", url) if err != nil { return nil, err } err = db.Ping() if err != nil { return nil, err } return &postgresRepository{db}, nil } . . . func (r *postgresRepository) GetItemByID(ctx context.Context, id int32) (*pb.Item, error) { row := r.db.QueryRowContext(ctx, "SELECT id, name, price FROM items WHERE id = $1", id) item := &pb.Item{} if err := row.Scan(&item.Id, &item.Name, &item.Price); err != nil { return nil, err } return item, nil } . . .
Repository
interfaceにはserver層で定義されているインターフェースの中で使われる関数が集約されています。
それぞれの関数にはDBから取得したデータをGoの型にマッピングする機能を持たせています。
main.go
ポート8080でgrpcサーバーを立ち上げます。
. . . func main() { dbURL := os.Getenv("DATABASE_URL") repo, err := item.NewPostgresRepository(dbURL) if err != nil { log.Fatal(err) } log.Println("listen to port 8080 ...") log.Fatal(item.ListenGRPC(repo, 8080)) }
文字通りプログラムのエントリーポイントであり、コンテナ内でこのファイルをコンパイルし実行しています。
FROM golang:1.10.3-alpine3.8 AS builder WORKDIR /go/src/github.com/masakazutakewaka/grpc-proto/item COPY vendor ../vendor COPY item ./ RUN go build -o /go/bin/app main/main.go FROM alpine:3.8 WORKDIR /usr/bin COPY --from=builder /go/bin . EXPOSE 8080 CMD ["app"]
User
ディレクトリ構成
user/ ├── app.dockerfile ├── client.go ├── create_table.sql ├── db.dockerfile ├── main ├── pb ├── repository.go └── server.go
プロトコル定義
. . . service UserService { rpc getUser (getUserRequest) returns (getUserResponse) { option (google.api.http) = { get: "/user/{id}" }; } rpc getUsers (getUsersRequest) returns (getUsersResponse) {} rpc postUser (postUserRequest) returns (postUserResponse) { option (google.api.http) = { post: "/user" body: "*" }; } } message User { int32 id = 1; string name = 2; } message getUserRequest { int32 id = 1; } message getUserResponse { User user = 1; } . . .
client層
Itemサービスのclient層とほとんど同じです。
. . . type Client struct { conn *grpc.ClientConn service pb.UserServiceClient } func NewClient(url string) (*Client, error) { conn, err := grpc.Dial(url, grpc.WithInsecure()) if err != nil { return nil, err } client := pb.NewUserServiceClient(conn) return &Client{conn, client}, nil } func (client *Client) Close() { client.conn.Close() } func (client *Client) GetUser(ctx context.Context, id int32) (*pb.User, error) { res, err := client.service.GetUser(ctx, &pb.GetUserRequest{Id: id}) if err != nil { return nil, err } return &pb.User{ Id: res.User.Id, Name: res.User.Name, }, nil } . . .
server層
Itemサービスのserver層とほとんど同じです。
. . . type userServer struct { r Repository } func ListenGRPC(r Repository, port int) error { listen, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { return err } server := grpc.NewServer() pb.RegisterUserServiceServer(server, &userServer{r}) reflection.Register(server) return server.Serve(listen) } func (s *userServer) GetUser(ctx context.Context, r *pb.GetUserRequest) (*pb.GetUserResponse, error) { user, err := s.r.GetUserByID(ctx, r.Id) if err != nil { return nil, err } return &pb.GetUserResponse{User: user}, nil } . . .
repository層
Itemサービスのrepository層とほとんど同じです。
. . . type Repository interface { Close() GetUserByID(ctx context.Context, id int32) (*pb.User, error) ListUsers(ctx context.Context, skip int32, take int32) ([]*pb.User, error) InsertUser(ctx context.Context, name string) error } type postgresRepository struct { db *sql.DB } func NewPostgresRepository(url string) (Repository, error) { db, err := sql.Open("postgres", url) if err != nil { return nil, err } err = db.Ping() if err != nil { return nil, err } return &postgresRepository{db}, nil } func (r *postgresRepository) Close() { r.db.Close() } func (r *postgresRepository) Ping() error { return r.db.Ping() } func (r *postgresRepository) GetUserByID(ctx context.Context, id int32) (*pb.User, error) { row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id) user := &pb.User{} if err := row.Scan(&user.Id, &user.Name); err != nil { return nil, err } return user, nil } . . .
main.go
Itemサービスのmain.go
とほとんど同じです。
. . . func main() { dbURL := os.Getenv("DATABASE_URL") repo, err := user.NewPostgresRepository(dbURL) if err != nil { log.Fatal(err) } log.Println("listen to port 8080 ...") log.Fatal(user.ListenGRPC(repo, 8080)) } . . .
Coordinate
ディレクトリ構成
coordinate/ ├── app.dockerfile ├── client.go ├── create_table.sql ├── db.dockerfile ├── main ├── pb ├── repository.go └── server.go
プロトコル定義
coordinate/pb/coordinate.proto
. . . service CoordinateService { rpc getCoordinatesByUser (getCoordinatesByUserRequest) returns (getCoordinatesByUserResponse) { option (google.api.http) = { get: "/user/{userId}/coordinates" }; } rpc postCoordinate (postCoordinateRequest) returns (postCoordinateResponse) { option (google.api.http) = { post: "/coordinate" body: "*" }; } } message Coordinate { int32 id = 1; int32 userId = 2; repeated int32 itemIds = 3; } message getCoordinatesByUserRequest { int32 userId = 1; } message getCoordinatesByUserResponse { repeated Coordinate coordinates = 1; } . . .
Coordinate型にはフィールドにユーザーのIDとアイテムのIDを持たせています。
client層
. . . type Client struct { conn *grpc.ClientConn service pb.CoordinateServiceClient } func NewClient(url string) (*Client, error) { conn, err := grpc.Dial(url, grpc.WithInsecure()) if err != nil { return nil, err } client := pb.NewCoordinateServiceClient(conn) return &Client{conn, client}, nil } func (client *Client) Close() { client.conn.Close() } func (client *Client) GetCoordinatesByUser(ctx context.Context, userId int32) ([]*pb.Coordinate, error) { res, err := client.service.GetCoordinatesByUser(ctx, &pb.GetCoordinatesByUserRequest{UserId: userId}) if err != nil { return nil, err } return res.Coordinates, nil }
GetCoordinatesByUser
はユーザーのIDからコーディネートを取ってきます。
server層
. . . type coordinateServer struct { r Repository itemClient *item.Client userClient *user.Client } func ListenGRPC(r Repository, itemURL string, userURL string, port int) error { itemClient, err := item.NewClient(itemURL) if err != nil { return err } userClient, err := user.NewClient(userURL) if err != nil { return err } listen, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { itemClient.Close() userClient.Close() return err } server := grpc.NewServer() pb.RegisterCoordinateServiceServer(server, &coordinateServer{r, itemClient, userClient}) reflection.Register(server) return server.Serve(listen) } func (s *coordinateServer) GetCoordinatesByUser(ctx context.Context, r *pb.GetCoordinatesByUserRequest) (*pb.GetCoordinatesByUserResponse, error) { _, err := s.userClient.GetUser(ctx, r.UserId) if err != nil { return nil, err } coordinates, err := s.r.GetCoordinatesByUserId(ctx, r.UserId) if err != nil { return nil, err } return &pb.GetCoordinatesByUserResponse{Coordinates: coordinates}, nil } . . .
CoordinateサービスではItemサービス、Userサービスとの連携があるので、coordinateServer
のフィールドにitem.Client
、user.Client
を持たせて、Itemサービス、Userサービスのgrpcサーバーと通信できるようにしています。
GetCoordinatesByUser
ではコーディネートのデータを取得する前に、特定のIDのユーザーが存在するか確かめています。
repository層
. . . type Repository interface { Close() GetCoordinatesByUserId(ctx context.Context, userId int32) ([]*pb.Coordinate, error) InsertCoordinate(ctx context.Context, userId int32, itemIds []int32) error } type postgresRepository struct { db *sql.DB } func NewPostgresRepository(url string) (Repository, error) { db, err := sql.Open("postgres", url) if err != nil { return nil, err } err = db.Ping() if err != nil { return nil, err } return &postgresRepository{db}, nil } func (r *postgresRepository) Close() { r.db.Close() } func (r *postgresRepository) Ping() error { return r.db.Ping() } func (r *postgresRepository) GetCoordinatesByUserId(ctx context.Context, userId int32) ([]*pb.Coordinate, error) { rows, err := r.db.QueryContext(ctx, "SELECT id, item_ids FROM coordinates WHERE user_id = $1", userId) coordinates := []*pb.Coordinate{} if err != nil { return nil, err } var itemIds string for rows.Next() { coordinate := &pb.Coordinate{} if err := rows.Scan(&coordinate.Id, &itemIds); err != nil { return nil, err } coordinate.UserId = userId coordinate.ItemIds, err = SliceItemIds(itemIds) if err != nil { return nil, err } coordinates = append(coordinates, coordinate) } if err := rows.Err(); err != nil { return nil, err } return coordinates, nil } . . .
main.go
. . . func main() { dbURL := os.Getenv("DATABASE_URL") itemURL := os.Getenv("ITEM_URL") userURL := os.Getenv("USER_URL") repo, err := coordinate.NewPostgresRepository(dbURL) if err != nil { log.Fatal(err) } log.Println("listen to port 8080 ...") log.Fatal(coordinate.ListenGRPC(repo, itemURL, userURL, 8080)) } . . .
リバースプロキシ
ディレクトリ構成
gateway/ ├── gateway.dockerfile └── main
main.go
import ( . . . "github.com/golang/glog" "github.com/grpc-ecosystem/grpc-gateway/runtime" "google.golang.org/grpc" coordinatePb "github.com/masakazutakewaka/grpc-proto/coordinate/pb" itemPb "github.com/masakazutakewaka/grpc-proto/item/pb" userPb "github.com/masakazutakewaka/grpc-proto/user/pb" ) func run(itemURL string, userURL string, coordinateURL string) error { ctx := context.Background() ctx, cancel := context.WithCancel(ctx) defer cancel() mux := runtime.NewServeMux() opts := []grpc.DialOption{grpc.WithInsecure()} err := itemPb.RegisterItemServiceHandlerFromEndpoint(ctx, mux, itemURL, opts) if err != nil { return err } err = userPb.RegisterUserServiceHandlerFromEndpoint(ctx, mux, userURL, opts) if err != nil { return err } err = coordinatePb.RegisterCoordinateServiceHandlerFromEndpoint(ctx, mux, coordinateURL, opts) if err != nil { return err } return http.ListenAndServe(":8080", mux) } func main() { itemURL := os.Getenv("ITEM_URL") userURL := os.Getenv("USER_URL") coordinateURL := os.Getenv("COORDINATE_URL") defer glog.Flush() if err := run(itemURL, userURL, coordinateURL); err != nil { glog.Fatal(err) } }
作ってみて感じたこと
時間がかかる
今回作ったサンプルAPIはRailsでモノリシックに作ればほんの一瞬できるものですが、マイクロサービスにしたことで結構時間がかかった感じがします。
よく言われることですが、マイクロサービスにすることで発生する初期のオーバーヘッドはかなり大きいと感じました。
中規模以上のアプリケーションをマイクロサービスで作ってこの初期のオーバーヘッドを回収していくんだなぁと実感できました。
各機能が疎結合になる
疎結合になることで、シンプルに考えられたり、実装できたりするのは純粋に利点だなぁと実感しました。
ただ疎結合に関する難しさは、既存のサービスの機能を切り分けることそのものにあるのだろうとも思いました。
デバッグが増える
サービス間の通信の部分でのエラーなど、モノリスだと考えなくてよかったものが出てきて、運用する際に悩みのタネになったりしそうだなぁと思いました。
まとめ
今回、マイクロサービスで簡単なサンプルAPIを実装してみたので
- 実装方法
- 実装してみた所感
を書きました。
やはりアプリケーションにあった設計方法というものがあって、その判断を適切にするということが重要なんだと実感することができました。
"銀の弾丸"などないんだなーと。
この記事がどなたかの参考に少しでもなれば幸いです。