Cloud Native Goのまとめ
Cloud Nativeという概念について
誤解
- 特定の技術や言語に依存するもの?
- No
- Cloudで動かしたらCloud Native?
- No。例えばオンプレで動いていた管理しにくくデプロイしづらいアプリケーションをKubernetesに入れて動かしても、 ずさんなアプリケーションはずさんなまま。
背景
- インターネットを継続的に利用する人の割合は1997年に2%だったが2017年には48%になり、現在も増え続けている
- 人々がこうした状況に慣れ、サービスに期待するレベルが上がっている(より洗練された機能や常にサービスが使えることを求めるようになった)
- こうした要求に応えるため、より一層スケールし、複雑で(複雑な要求に応えられ)、組み合わせ可能なシステムが求められるようになった
- AWS等のパブリッククラウドの登場により単純にサーバの数を増やしたりすることはできるようになった
- しかし、インフラだけ増やしてもこうした新しい要求に旧来の方法では答えられない(100や1000のサーバをどうやって管理するか。動作がおかしい時どうやってデバッグするか。そもそもシステムに問題がないかどうやって知るか)
- Cloud Nativeの概念はこうした状況を解決するための(クラウドを利用したアプリケーション構築の)ベストプラクティスとして提唱された
Cloud Nativeとは
Cloud Native Computing Foundationによる定義は以下。
Cloud native technologies empower organizations to build and run scalable applications in modern, dynamic environments such as public, private, and hybrid clouds….
These techniques enable loosely coupled systems that are resilient, manageable, and observable. Combined with robust automation, they allow engineers to make high-impact changes frequently and predictably with minimal toil6.
-Cloud Native Computing Foundation, CNCF Cloud Native Definition v1.0
ここに記載されている通り、Cloudの力を活用し、ユーザの要求事項に答えるためにはシステムとして以下の特性を持つ必要がある。
- Scalable
- Loosely Coupled
- Resilient
- Maganeable
- Observable
また、上記にある堅牢な自動化の仕組みと組み合わせることで、インパクトの大きい変更であっても頻繁にリリース可能にするというのもポイント。(自動化という点でCI/CDの活用やDevOpsの考えも重要になる)
Scalability
急激なアクセス数の変化が発生しても、システムとして期待したとおりに動作し続ける力のこと。
あるシステムが急激なアクセス数の増加に見舞われても特に手を加える必要なしに動作し続けられたらそのシステムはScalabilityを備えていると言える。
Scalabilityの実現方法には以下2つの方法がある。
- Vertical Scaling
- ハードウェアリソースを増強することでアクセスの増加に対応することを指す
- メリット
- 比較的簡単
- デメリット
- 対応できる範囲に制約がある(ハードウェアの制約)
- Horizontal Scaling
- サービスインスタンス(サーバー等)の数を増やすことでアクセスの増加に対応することを指す
- メリット
- 上限に制約がない
- 冗長性を担保できる
- デメリット
- 管理するインスタンスの数が増えるので設計や管理が複雑になる
- すべてのサービスで実現できるわけではない
Loose Coupling
Loose Couplingとはシステムのコンポーネントが他のコンポーネントについて最小限の知識しかもたないように設計をすること。
一つのコンポーネントに対して加えた変更が他のコンポーネントに影響を及ぼさない時に、その2つのシステムは疎結合だと言える。
Reailence
- 一部のコンポーネントが動作しなくなっても、システム全体では動作し続けるシステムを作る
- システムのコンポーネントはfailするという想定をする
- 一部のコンポーネントが動作しなくなった時の影響範囲を限定する
Manageability
Observability
システムが今どういう状態かを外部の状態からどれぐらい把握できるかという尺度。
古いアーキテクチャであればログの設定がしてあり、メトリクスが見れるダッシュボードがあり、アラートの設定があれば十分だったかもしれないが、現在の分散し、複雑化したシステムではこれまでの手法では通用せず、新しい手法が必要。
これがObservabilityという性質。
GolangとCloud Nativeの関係性
- Goは2007年にGoogleによって開発された
- Goが開発された当時に広く使われていた言語は現在では当たり前となっているマルチコアプロセッサーやインターネットへのアクセスが世界的に提供される以前の時代に開発されたものであり、こうした現代では当たり前となった(そしてCloud上でのアプリケーション構築で必須となる)ものを効率的に使うことができない
- こうした状況やエンジニアリング上の問題を踏まえて、Cloud Nativeな世界で必要とされる特性を備えた言語としてGolangを開発した
「Go言語らしさ」とは何か? Simplicityの哲学を理解し、Go Wayに沿った開発を進めることの良さ - エンジニアHub|Webエンジニアのキャリアを考える!
Composition
- ソフトウェアに複雑さをもたらす可能性の高いInheritance(is-a関係)ではなくComposition(has-a関係)の使用が推奨される
- 例えば以下の例では
Shape
interfaceでArea
メソッドを定義することで、Area
メソッドをもったものはShape
interfaceを暗黙的に満たす。
type Shape interface { // Any Shape must have an Area
Area() float64
}
type Rectangle struct { // Rectangle doesn't explicitly
width, height float64 // declare itself to be a Shape
}
func (Rectangle r) Area() float64 { // Rectangle has an Area method; it
return r.width * r.height // satisfies the Shape interface
}
Go Language Foundations
Basic Data Types
-
Booleans that contain only one bit of information — true or false — representing some logical conclusion or state.
-
Numeric types that represent simple — variously-sized floating point and signed and unsigned integers — or complex numbers.
-
Strings that represent an immutable sequence of Unicode code points.
Booleans
and := true && false
fmt.Println(and) // false
or := true || false
fmt.Println(or) // true
not := !true
fmt.Println(not) // false
Numbers
Simple Numbers
- Signed Integer(符号付き整数)
- int8
- int16
- int32
- int64
- Unsigned Integer(符号なし整数)
- uint8
- uint16
- uint32
- uint64
- Floating-Point(浮動小数点)
- float32, float64
Complex Numbers
以下2種類のデータタイプが存在するが、あまり頻繁に使うことはないので、省略。
complex64
complex128
var x complex64 = 3.1415i
fmt.Println(x) // (0+3.1415i)
Strings
- GoにおけるStringはimmutableで内容を変更することは出来ない
GoにおけるStringの定義方法には以下の2つがある。
- ダブルクォート
- バッククォート
以下は同じ文字列を上記2つの方法で定義した場合の例。
// The interpreted form
"Hello\nworld!\n"
// The raw form
`Hello
world!`
Variables
// 定義時に値を設定
var foo int = 42
// 複数の変数を定義
var foo, bar int = 42, 1302
// 型の指定を省略
var foo = 42
// 型の指定を省略して、複数の変数を定義
var b, f, s = true, 2.3, "four"
// 変数の定義時に値を設定しない(Zero Valueが設定される)
var s string
Short Variable Declaration
以下のようにvarを省略して変数を定義することもできる。
この記法を使用した時には型の指定は出来ない
name := 'John'
Zero Values
変数の定義時に値を代入しない場合、型ごとに定められたゼロの値(Zero Values)が自動で代入される
- Integer
- 0
- Floats
- 0.0
- Booleans
- false
- Strings
- ""
Blank Identifier
Constants
Cloud Native Applicationとは
Cloud Nativeという単語が示すのは単にクラウドで動かすことではない(アプリケーションをコンテナに入れてKubernetesで動かすことではない)
Story
- 1950s
- メインフレーム
- 一つの巨大なコンピュータ上で全てのプログラムが動作
- メインフレーム
- 1980s
-
- 1990s
- Microservice
-
Cloud Native Patterns
L Peter Deutschがまとめた分散アプリケーションの初学者が持ちやすい誤解
- The network is reliable: switches fail, routers get misconfigured
- Latency is zero: it takes time to move data across a network
- Bandwidth is infinite: a network can only handle so much data at a time
- The network is secure: don’t share secrets in plain text; encrypt everything
- Topology doesn’t change: servers and services come and go
- There is one administrator: multiple admins lead to heterogeneous solutions
- Transport cost is zero: moving data around costs time and money
- The network is homogeneous: every network is (sometimes very) different
[the Fallacies of Distributed Computing] L Peter Deutsch
さらにこの書籍の著者が以下を追加で挙げている。
- Services are reliable: services that you depend on can fail at any time
上記のポイントについて実際にGolangでのアプリケーション構築を通じて学んでいく。
この本ではアプリケーションレベルにフォーカスするためBulkheadやGatekeeperについては扱わない。
インフラレベルでのパターンについては以下の書籍参考。
- Cloud Native Infrastructure by Justin Garrison and Kris Nova (O’Reilly)
- Designing Distributed Systems by Brendan Burns (O’Reilly).
Contextの活用
アプリケーションの構築に当たってGo1.7から導入されたcontext packageを活用する。
contextの仕組みを使うことでリクエストなどを受け取った際に状況をサブリクエスト(例えばDBへのリクエストなど)と共有することができる。
具体的に役立つ場面として、ユーザがリクエストを途中でキャンセルした場合にアプリケーションのプロセスやDBへのリクエストなど、ユーザからのリクエストに付随して行われる処理を途中でキャンセルすることが簡単にでき、無駄なリソースを使わずに済む。
また、実装に当たって重要な点として、Contextの値はスレッドセーフであり、gorutinesによる並列処理でも意図しない挙動が発生する恐れがない。
Contextの作成
- Background() Context
- キャンセルされることのない空の(no values, no deadline)Contextを返す
- 一般的には以下の用途で使われる
- main function
- 初期化(initialization)
- テスト
- リクエストのトップレベル
- Todo() Context
DeadlinesとTimeoutsの設定
- func WithDeadline(Context, time.Time)(Context CancelFunc)
- func WithTimeout(Context, time.Duration)(Context CancelFunc)
- func WithCancel(Context)(Context CancelFunc)
Request-Scoped Valueの定義
Stability Patterns
Circuit Breaker
Applicability
エラーやFailは常に発生する可能性があり、分散システムにおいてそれを拒否することはできなくて、(サービスは設定ミスが発生し、データベースはクラッシュし、ネットワークは繋がらなくなる)それを受け入れるしかない。
実装(Implementation)
Breakerは以下2つの状態(state)になりうる。
- closed
- 全てが問題なく動作している
- Breakerが受信したリクエストは全て内容を変えずにそのままCircuitに渡される
- open
- 何かに問題が発生した状態
- BreakerはCircuitにリクエストを送らず、エラーメッセージを返す
Circuit側でエラーを返した場合(あらかじめ定めた閾値を超えた場合)には、Breaker側でstateをopenに変更する。
Debounce
Debounce limits the frequency of a function invocation so that only the first or last in a cluster of calls is actually performed.
Retry
Throttle
Timeout
Concurrency Patterns
Fan-In
Fan-Out
Future(Promises or Delays)
Sharding
Building a Cloud Native Service
key-value storeを題材にCloud Native Serviceを作る
Requirements
- Key-valueペアを格納できる
- Key-valueペアをput, get, deleteするためのエンドポイントが提供されている
- データを永続的に格納できる
- 冪等性(idempotent)を持たせる
冪等性(idempotent)
プログラミングの世界では1回読んでも何回読んでも同じ結果(作用)が得られるとき冪等性(idempotent)があると呼ぶ。
-
x=1
-
Put Endpoint
-
x+=1
-
冪等性を持つ処理は安全
- あるサービスに対してリクエストを送って結果が返ってこなかったとき、おそらくほとんどの人はもう1度リクエストを送る
- このとき冪等性があれば何も影響を及ぼさない。しかし、もしそうでなければ何かしらの影響が発生する
-
冪等性を持つ処理はシンプル
- 受け取った値で更新するだけのPUTともし同じ値が存在すればエラーを返すCREATEを比較してみるとPUTのロジックが非常にシンプルなことがわかる
- CREATEを実装しようとすると、エラーハンドリングや分散システムでのロックなどが必要になってくる
-
冪等性を持つ処理は宣言的
- 結果
Generation 0: The Core Functionality
どのWebフレームワークを使うか考える前にコアな機能を構築する
- Key-valueペアを格納する
- put, get, deleteする
Simple API
Generation1: The Monolith
コア機能の構築が終わったので、サービスとして提供するための構築を行う。
選択肢として以下の3つが考えられるが、現在のアプリケーションはそれほど複雑なデータを扱うわけでもないので、もっともシンプルに実現できるRESTをまずは採用する。
- GraphQL
- RPC(gRPC)
- REST
net/httpを使ったHTTP Serverの構築
GoではPythonでいうDjangoやFlaskのように洗練されたWeb Frameworkはない?が、80%程度のユースケースをカバーしてくれるスタンダードライブラリが提供されている。
また、そうしたスタンダードライブラリは拡張可能なように設計されているため、多くのWeb Frameworkがスタンダードライブラリの機能を拡張して作られている。
HTTP Handlerの仕組みを構築するのに使用するスタンダードライブラリはnet/http
以下がもっとも基本的な実装。
func helloGoHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello net/http!\n"))
}
func main() {
http.HandleFunc("/", helloGoHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
# サーバの起動
$ go run main.go
# リクエストの送信
$ curl http://localhost:8080
Hello net/http!
前述のコードを紐解くと、http.HandleFunc
でリクエストと処理とのマッピングを定義でき、また、http.HandleFunc
は以下の定義がされているので、それに準じた実装を呼び出すFunction側で行う必要がある。
type HandlerFunc func(http.ResponseWriter, *http.Request)
Gorillaを使った改善
For many web services the net/http and DefaultServeMux will be perfectly sufficient. However, sometimes you’ll need the additional functionality provided by a third-party web toolkit. A popular choice is Gorilla, which, while being relatively new and less fully developed and resource-rich than something like Django or Flask, does build on Go’s standard net/http package to provide some excellent enhancements.
The gorilla/mux package—one of several packages provided as part of the Gorilla web toolkit—provides an HTTP request router and dispatcher that can fully replace DefaultServeMux, Go’s default service handler, to add several very useful enhancements to request routing and handling. We’re not going to make use of these features just yet, but they will come in handy going forward. If you’re curious and/or impatient, however, you can take a look at the gorilla/mux documentation for more information.
Gorilla/muxを使った実装はHandleFuncの定義の前にRouterの定義を追加するだけ。
import (
"errors"
"log"
"net/http"
"github.com/gorilla/mux"
)
func helloMuxHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello gorilla/mux!\n"))
}
func main() {
r := mux.NewRouter()
r.HandleFunc("/", helloMuxHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Gorillaの機能: Variables in URI Paths
Pathの指定に変数を使える。
r := mux.NewRouter()
r.HandleFunc("/products/{key}", ProductHandler)
r.HandleFunc("/articles/{category}/", ArticlesCategoryHandler)
r.HandleFunc("/articles/{category}/{id:[0-9]+}", ArticleHandler)
// mux.Varsでhandler Function側で変数のmapを取得できる
vars := mux.Vars(request)
category := vars["category"]
Gorillaの機能: Matchers
Host名やメソッドなど、より細かなマッチングの指定ができる。
r := mux.NewRouter()
r.HandleFunc("/products", ProductsHandler).
Host("www.example.com"). // Only match a specific domain
Methods("GET", "PUT"). // Only match GET+PUT methods
Schemes("http") // Only match the http scheme
RESTful Serviceの構築
Functionality | Method | Possible Statuses |
---|---|---|
Key-value PairをStoreに格納する | PUT | 201(Created) |
StoreからKey-value Pairを取得する | PUT | 200(OK), 404(Not Found) |
Key-value PairをStoreから削除する | DELETE | 200(OK) |
Create Functionの実装
- Status Codeは201か500で返す
- ステータスコードの設定自体は
http
パッケージで定義されたものを使える
- ステータスコードの設定自体は
func main() {
r := mux.NewRouter()
r.HandleFunc("/v1/{key}", keyValuePutHandler).Methods("PUT")
log.Fatal(http.ListenAndServe(":8080", r))
}
func keyValuePutHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
key := vars["key"]
// Request Bodyからキーの値を取得する
value, err := io.ReadAll(r.Body)
defer r.Body.Close()
// キーの値の取得に失敗したらInternal Server Errorを返す
if err != nil {
http.Error(w,
err.Error(),
http.StatusInternalServerError)
return
}
// Putに失敗したらInternal Server Errorを返す
err = Put(key, string(value))
if err != nil {
http.Error(w,
err.Error(),
http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
% curl -X PUT -d 'Hello, key-value store!' -v http://localhost:8080/v1/key-a
...
< HTTP/1.1 201 Created
% curl -X GET -d 'Hello, key-value store!' -v http://localhost:8080/v1/key-a
...
< HTTP/1.1 405 Method Not Allowed
Read Functionの実装
% curl -X PUT -d 'こんにちは' -v http://localhost:8080/v1/key-a
% curl -X GET -d 'こんにちは' -v http://localhost:8080/v1/key-a
...
< HTTP/1.1 200 OK
< Date: Fri, 14 May 2021 10:24:35 GMT
< Content-Length: 15
< Content-Type: text/plain; charset=utf-8
<
* Connection #0 to host localhost left intact
こんにちは* Closing connection 0
Deleteの作成
同じ要領でDelete Functionも作成する
func keyValueDeleteHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
key := vars["key"]
err := Delete(key)
if err != nil {
http.Error(w,
err.Error(),
http.StatusInternalServerError)
return
}
w.Write([]byte("Delete Compeleted"))
}
- GoにおけるMapsは並列処理ではsafeではない
- 一般的にgoroutine等のより並列処理でreadもwriteも行われる場合には、mutex(lock)を用いる
- 具体的には
sync
パッケージのRWMtex
がこうした機能を提供してくれる
mutexの基本的な仕組み
Writeの場合
// 定義
var myMap = struct{
sync.RWMutex
m map[string]string
}{m: make(map[string]string)}
// 実際に使う
myMap.Lock() // Take a write lock
myMap.m["some_key"] = "some_value"
myMap.Unlock()
readの場合
myMap.RLock() // Take a read lock
value := myMap.m["some_key"]
myMap.RUnlock() // Release the read lock
fmt.Println("some_key:", value)
muxの組み込み
storeの改善
Lockの仕組みを取り入れるためまずはstoreのデータ型をstructに変えて、sync.RWMutex
を持つようにする
// var store = make(map[string]string)
var store = struct {
sync.RWMutex
m map[string]string
}{m: make(map[string]string)}
Functionの修正
各Functionでstoreのアクセス前後にLockとリリースの処理を追加する。
storeの型が変わったのでアクセスの方法も変更する
func Put(key string, value string) error {
// store[key] = value
store.Lock()
store.m[key] = value
store.Unlock()
return nil
}
func Get(key string) (string, error) {
// value, ok := store[key]
store.Lock()
value, ok := store.m[key]
store.Unlock()
if !ok {
return "", ErrorNoSuchKey
}
return value, nil
}
後はcurl等で機能が変わっていないことを確認する
% curl -X PUT -d 'こんにちは' -v http://localhost:8080/v1/key-a # 201で返ってくる
% curl -X GET -d 'こんにちは' -v http://localhost:8080/v1/key-a # 200で返ってくる
% curl -X DELETE -v http://localhost:8080/v1/key-a #
% curl -X GET -d 'こんにちは' -v http://localhost:8080/v1/key-a # 404 no such keyと返ってくる
Generation 2: Persisting Resource State
分散されたCloud Native Applicationでの難しい問題はどうやってstate(状態)を保存するか。
複数のサービス間で状態を管理する方法にはいくつものテクニックがあるが、まずはMVPということで以下の2つの方法を使用する。
- ログファイルにStateを保存
- データベースにStateを保存
以下、まだ消化できていないが、stateについてなぜ、stateを持つことが問題なのか、また、stateといってもapplication stateとresource stateの2つがあり、よく混同されてしまうことに触れている
Transaction Log
データストアに加えられた変更を記録するもの。
もしサービスがクラッシュしたときに変更を再現し、元の状態に復旧できるよう作られるもの。
一般的にはDBMSで使われるものだが、今回の用途ではシンプルなものを作る。
Pros and Cons
-
Pros:
- No downstream dependency
- There’s no dependency on an external service that could fail or that we can lose access to.
- Technically straightforward
- The logic isn’t especially sophisticated. We can be up and running quickly.
- No downstream dependency
-
Cons:
- Harder to scale
- You’ll need some additional way to distribute your state between nodes when you want to scale.
- Uncontrolled growth
- These logs have to be stored on disk, so you can’t let them grow forever. You’ll need some way of compacting them.
- Harder to scale
要件
- 時刻順で上から下に流れていく(同じ状態が再現できるように)
- 追加しかできない(過去の記録を削除したりしない)
以下の項目を持つ
- sequence number
- Event type(PUT or DELETE)
- Key
- Value
Loggerのinterfaceを定義
type TransactionLogger interface {
WriteDelete(key string)
WritePut(key, value string)
}
設計(Prototype)
- シンプルさのため、プレインテキストで書き込む(binaryなどの選択肢もあるが、)
- 行ごとに書き込む
- 前述の4つの項目をタブ区切りで書き込む
先ほどinterfaceで定義したWritePut
とWriteDelete
を実装する
type FileTransactionLogger struct {
// something
}
func (l *FileTransactionLogger) WritePut(key, value string) {
// something
}
func (l *FileTransactionLogger) WriteDelete(key, value string) {
// something
}
ログに書き込む内容をEventとして定義する。
ここではiota
の機能を利用して利用可能なEventの種類(EventType)を管理する。
type EventType byte
const (
_ = iota
EventDelete EventType = iota
EventPut
)
type Event struct {
Sequence uint64
EventType EventType
Key string
Value string
}
ログの組み込み
最終的には以下のようにmain functionにログの処理を組み込む
func main() {
// Initializes the transaction log and loads existing data, if any.
// Blocks until all data is read.
err := initializeTransactionLog()
if err != nil {
panic(err)
}
// Create a new mux router
r := mux.NewRouter()
r.Use(loggingMiddleware)
r.HandleFunc("/v1/{key}", keyValueGetHandler).Methods("GET")
...
この状態でHTTPサーバを起動してリクエストを送ると、以下のようにログが出力される。
% go run .
2021/05/20 07:29:44 0 events replayed
2021/05/20 07:30:20 PUT /v1/key-a
2021/05/20 07:30:20 PUT key=key-a value=こんにちは
2021/05/20 07:30:40 GET /v1/key-a
2021/05/20 07:30:40 GET key=key-a
2021/05/20 07:30:51 DELETE /v1/key-a
2021/05/20 07:30:51 DELETE key=key-a
2021/05/20 07:31:27 GET /v1/key-a
現時点は最小限の実装であり、まだ以下の問題がある。
- There aren’t any tests.
- There’s no Close method to gracefully close the file.
- The service can close with events still in the write buffer: events can get lost.
- Keys and values aren’t encoded in the transaction log: multiple lines or whitespace will fail to parse correctly.
- The sizes of keys and values are unbound: huge keys or values can be added, filling the disk.
- The transaction log is written in plain text: it will take up more disk space than it probably needs to.
- The log retains records of deleted values forever: it will grow indefinitely.
外部のDBにStateを保存する
GoではコアライブラリにSQLとのやりとりを行うためのパッケージ(sql)が含まれている。
- Pros
- アプリケーションの状態を外部に保存できる
- 分散したアプリケーションそれぞれの状態を気にする必要がなくなり、クラウドネイティブに近づく
- スケールしやすい
- アプリケーションの状態を外部に保存できる
- Cons
- ボトルネックを導入してしまう
- upstream dependencyを導入してしまう
- 初期化が必要になる
- 複雑性が増す
GOにおけるDBとのやりとり
sql.DB
利用するDBに合わせてDBドライバーを選択する必要があり、Goでは40個以上のDBドライバーが提供されている
実装
-
sql.DB
を作成するため、PostgresTransactionLogger
のConstruction fucntionを作成する- ファイルの時と異なり、DBとのコネクションを確立し、失敗した時にはエラーを返す
func NewPostgresTransactionLogger(host, dbName, user, password string)
(TransactionLogger, error) { ... }