GoのURL decodeするときの注意
TL;DR
Goの net/url
のURL構造体が持つ String()
関数は自動でエスケープされるから気をつけよう
コードと意味
http://hoge.com/hoge.jpg%3Fversion%3Dv1
みたいなURLを http://hoge.com
と /hoge.jpg~~
のようにFQDNとパスで分けて
パスのエスケープをデコードしつつURLとして再結合したいみたいなときにちょっとつまったので書いとく
動くコードは以下に示す
package main import ( "fmt" "path" "net/url" ) func main() { var base *url.URL base, _ = url.Parse("http://foo.com/bar") str, _ := url.QueryUnescape("/hoge.jpg%3Fwidth%3D200") p := path.Join(base.Path, str) fmt.Println(p) base.Path = p fmt.Println(base.String()) // もう一度unescape decoded, _ := url.QueryUnescape(base.String()) fmt.Println(decoded) }
これを動かすと次のような出力になる
/bar/hoge.jpg?width=200 http://foo.com/bar/hoge.jpg%3Fwidth=200 http://foo.com/bar/hoge.jpg?width=200
一回目のprintlnは ?
と =
をアンエスケープできている
これを *url.URL.Path
に代入してフルのURLを String()
で表示した結果が2回目のprintlnである.
?
が再びエスケープされてしまっている.
ということで もう一度 String()
の結果を最初と同じように QueryUnescape
を使って完全にアンエスケープされたURLを取得した.
ちなみにこれ,次のようなコードで1回目のパスをアンエスケープしないで渡すコードにして出力を確認すると
package main import ( "fmt" "path" "net/url" ) func main() { var base *url.URL base, _ = url.Parse("http://foo.com/bar") str := "/hoge.jpg%3Fwidth%3D200" p := path.Join(base.Path, str) fmt.Println(p) base.Path = p fmt.Println(base.String()) }
以下のように2重にエスケープされてしまう
/bar/hoge.jpg%3Fwidth%3D200 http://foo.com/bar/hoge.jpg%253Fwidth%253D200
ただ,2回アンエスケープするの面倒だなと思って次のように,
結合する前のURLのパスを String()
で取得し,その後に path.Join
で結合するという手法を試してみた
package main import ( "fmt" "path" "net/url" ) func main() { var base *url.URL base, _ = url.Parse("http://foo.com/bar") str, _ := url.QueryUnescape("/hoge.jpg%3Fwidth%3D200") baseStr := base.String() fmt.Println(path.Join(baseStr, str)) }
だが,この結果は http:/foo.com/bar/hoge.jpg?width=200
という風に http://
の二つ目の /
が削除されてしまっていたので結局上記の形に落ち着いた
気をつけよう
gVisorのコード読んでみた
gVisorとOCIコンテナランタイムのアーキテクチャを探る
動機
これからGKE上でデプロイする時にもしかしたらランタイムを選択するような時が来るかもしれないので適切に取捨選択できるようにしたい.あと自作コンテナランタイムしたい
OCI って何
Open Container Initiativeの略. 各企業が参画してる団体でコンテナ技術の標準化をしてる.
OCI Runtime
コンテナランタイムと言っても色々意味がある. その広さはgccをコンパイラと一括りにしてしまうぐらい広い
例えばgccも内側では cc(compiler)
, gas(assembler)
, ld(linker)
という風にレイヤ分けされている.
コンテナランタイムも同じ.
dockerコマンドも内側では以下の図のように containerd(CRI)
, runc(OCI)
という風にレイヤ分けされている
最終的にコンテナイメージ(ファイルシステムの塊)を実際に読み込んでプロセスとして起動するのがOCI Runtimeの役割.実態としてはAPIサーバでもなんでもなく普通のCLIバイナリ.
OCI RuntimeはOCIが定めたコマンドラインのインタフェースを実装しているCLIツールのことを指している(多分). Runtimeの仕様は https://github.com/opencontainers/runtime-spec ここ参照. Go言語での実装方針についても書いてくれてる.優しい
最近のOCI RuntimeとgVisor
参考: https://qiita.com/mamomamo/items/ed5db2ab1555078f8a24
gVisorはGoogleがオープンソースとしてリリースしたOCI Runtime. まだプロダクション環境での利用はオススメされていない(AppEngineのnodejsアプリはこのgVisor上で動いてるとかなんとか)
既存のコンテナエンジンはセキュリティ的に不安なのでより安全にアプリを実行できるよというもの
何が安全じゃないのか
dockerはデフォルトにOCI Runtimeでrunc(https://github.com/opencontainers/runc) を使う. コンテナとは基本的にOSが管理するプロセスの一個として動くのでホストカーネルに対して直接システムコールを発行して動作する.
システムコールの脆弱性を付いて1プロセスがroot権限を奪取するなんてこともできる(https://ipsj.ixsq.nii.ac.jp/ej/?action=repository_action_common_download&item_id=175781&item_no=1&attribute_id=1&file_no=1) のでまぁ危ない.システムコール 脆弱性でググると事例はたくさん出てくる.
なのでgVisorのアプローチは既存のVMのようにコンテナを隔離しようとした. その結果が以下の図に現れている.
gVisorは主に2個目の図の紫で描かれたコンポーネントを提供する.
実際の処理の流れとしては,
- containerdなどのハイレベルコンテナランタイムがgVisor(runsc)を起動
- gVisorはPOSIXゲストカーネルをユーザ空間に展開.
- アプリケーションプロセスを実行し,システムコールをkvmかptraceでフックし,sentryにリダイレクトする.
- gVisorはユーザカーネル上でそのシステムコールを実行する
コードのアーキテクチャ
. ├── g3doc ├── kokoro ├── pkg │ ├── abi │ │ └── linux │ ├── amutex │ ├── atomicbitops │ ├── binary │ ├── bits │ ├── bpf │ ├── compressio │ ├── control │ │ ├── client │ │ └── server │ ├── cpuid │ ├── eventchannel │ ├── fd │ ├── fdnotifier │ ├── gate │ ├── ilist │ ├── linewriter │ ├── log │ ├── metric │ ├── p9 │ │ ├── local_server │ │ └── p9test │ ├── rand │ ├── refs │ ├── seccomp │ ├── secio │ ├── segment │ │ └── test │ ├── sentry │ │ ├── arch │ │ ├── context │ │ ├── control │ │ ├── device │ │ ├── fs │ │ ├── hostcpu │ │ ├── inet │ │ ├── kernel │ │ ├── limits │ │ ├── loader │ │ ├── memmap │ │ ├── memutil │ │ ├── mm │ │ ├── pgalloc │ │ ├── platform │ │ ├── safemem │ │ ├── sighandling │ │ ├── socket │ │ ├── state │ │ ├── strace │ │ ├── syscalls │ │ ├── time │ │ ├── unimpl │ │ ├── uniqueid │ │ ├── usage │ │ ├── usermem │ │ └── watchdog │ ├── sleep │ ├── state │ │ └── statefile │ ├── syserr │ ├── syserror │ ├── tcpip │ │ ├── adapters │ │ ├── buffer │ │ ├── checker │ │ ├── hash │ │ ├── header │ │ ├── link │ │ ├── network │ │ ├── ports │ │ ├── sample │ │ ├── seqnum │ │ ├── stack │ │ └── transport │ ├── tmutex │ ├── unet │ ├── urpc │ └── waiter ├── runsc │ ├── boot │ │ └── filter │ ├── cgroup │ ├── cmd │ ├── console │ ├── container │ │ └── test_app │ ├── debian │ ├── fsgofer │ │ └── filter │ ├── sandbox │ ├── specutils │ ├── test │ │ ├── image │ │ ├── integration │ │ ├── root │ │ └── testutil │ └── tools │ └── dockercfg ├── test │ ├── syscalls │ │ ├── gtest │ │ └── linux │ └── util ├── third_party │ └── gvsync │ ├── atomicptrtest │ └── seqatomictest ├── tools │ ├── go_generics │ │ ├── generics_tests │ │ ├── globals │ │ ├── go_merge │ │ └── rules_tests │ └── go_stateify └── vdso 119 directories
主に関係あるのが runsc
と pkg/sentry
以下.
runscはOCI RuntimeのCLIを実装しているのでruncと比較するとほぼ同じコードの構造なのがよくわかる.
一番重要なシステムコールのフック,実装を行なっているのが sentry
.
適当なシステムコールの実装を見てみる
pkg/sentry/syscalls/linux
以下に各システムコールの実装がある.
今回はglibcやlinux kernelで実際に調査したことのあるfork周りを追ってみる
forkシステムコールは pkg/sentry/syscalls/linux/sys_thread.go
の176行目に定義されていた
// Fork implements Linux syscall fork(2). func Fork(t *kernel.Task, args arch.SyscallArguments) (uintptr, *kernel.SyscallControl, error) { // "A call to fork() is equivalent to a call to clone(2) specifying flags // as just SIGCHLD." - fork(2) return clone(t, int(syscall.SIGCHLD), 0, 0, 0, 0) }
内部ではclone関数に変換されているのがわかる. glibcのソースをみるとわかるがこの実装はglibcと同じ(厳密にはここから更にインラインアセンブラに変換される)
というわけでclone関数に飛んでみる
func clone(t *kernel.Task, flags int, stack usermem.Addr, parentTID usermem.Addr, childTID usermem.Addr, tls usermem.Addr) (uintptr, *kernel.SyscallControl, error) { opts := kernel.CloneOptions{ SharingOptions: kernel.SharingOptions{ NewAddressSpace: flags&syscall.CLONE_VM == 0, NewSignalHandlers: flags&syscall.CLONE_SIGHAND == 0, NewThreadGroup: flags&syscall.CLONE_THREAD == 0, TerminationSignal: linux.Signal(flags & exitSignalMask), NewPIDNamespace: flags&syscall.CLONE_NEWPID == syscall.CLONE_NEWPID, NewUserNamespace: flags&syscall.CLONE_NEWUSER == syscall.CLONE_NEWUSER, NewNetworkNamespace: flags&syscall.CLONE_NEWNET == syscall.CLONE_NEWNET, NewFiles: flags&syscall.CLONE_FILES == 0, NewFSContext: flags&syscall.CLONE_FS == 0, NewUTSNamespace: flags&syscall.CLONE_NEWUTS == syscall.CLONE_NEWUTS, NewIPCNamespace: flags&syscall.CLONE_NEWIPC == syscall.CLONE_NEWIPC, }, Stack: stack, SetTLS: flags&syscall.CLONE_SETTLS == syscall.CLONE_SETTLS, TLS: tls, ChildClearTID: flags&syscall.CLONE_CHILD_CLEARTID == syscall.CLONE_CHILD_CLEARTID, ChildSetTID: flags&syscall.CLONE_CHILD_SETTID == syscall.CLONE_CHILD_SETTID, ChildTID: childTID, ParentSetTID: flags&syscall.CLONE_PARENT_SETTID == syscall.CLONE_PARENT_SETTID, ParentTID: parentTID, Vfork: flags&syscall.CLONE_VFORK == syscall.CLONE_VFORK, Untraced: flags&syscall.CLONE_UNTRACED == syscall.CLONE_UNTRACED, InheritTracer: flags&syscall.CLONE_PTRACE == syscall.CLONE_PTRACE, } ntid, ctrl, err := t.Clone(&opts) return uintptr(ntid), ctrl, err }
各種フラグの設定をしたあと kernel.Task
の関数 t.Clone()
を読んでいるので今度
はこちらにjump
そうすると今度はファイルが変わり pkg/sentry/kernel/task_clone.go
に飛ぶ.
恐らくこの task_*.go
がlinux kernel側のシステムコール実装に対応していると思う
中身は150行ぐらいあるので省略するが,ユーザネームスペースのコピー,メモリ空間の共有,プロセスファイルの複製などlinux kernelでの処理とほぼ一致していたので間違いないはず.
さて,このシステムコールがどうやってptraceからフックされ,呼び出されるかをもう少し深く探ってみる
ユーザ空間からユーザカーネルへの移動
ユーザーのアプリはdockerの場合containerdから runsc --hoge
のように呼び出されるわけだがその時のエントリーポイントは runsc/cmd/boot.go
にある
こちらもすごい長い関数なので一部抜粋すると
// Create the loader. bootArgs := boot.Args{ ID: f.Arg(0), Spec: spec, Conf: conf, ControllerFD: b.controllerFD, DeviceFD: b.deviceFD, GoferFDs: b.ioFDs.GetArray(), StdioFDs: b.stdioFDs.GetArray(), Console: b.console, NumCPU: b.cpuNum, TotalMem: b.totalMem, UserLogFD: b.userLogFD, } l, err := boot.New(bootArgs) if err != nil { Fatalf("creating loader: %v", err) } // Fatalf exits the process and doesn't run defers. // 'l' must be destroyed explicitly after this point! // Notify the parent process the sandbox has booted (and that the controller // is up). startSyncFile := os.NewFile(uintptr(b.startSyncFD), "start-sync file") buf := make([]byte, 1) if w, err := startSyncFile.Write(buf); err != nil || w != 1 { l.Destroy() Fatalf("unable to write into the start-sync descriptor: %v", err) } // Closes startSyncFile because 'l.Run()' only returns when the sandbox exits. startSyncFile.Close() // Wait for the start signal from runsc. l.WaitForStartSignal() // Run the application and wait for it to finish. if err := l.Run(); err != nil { l.Destroy() Fatalf("running sandbox: %v", err) } ws := l.WaitExit() log.Infof("application exiting with %+v", ws) *waitStatus = syscall.WaitStatus(ws.Status()) l.Destroy() return subcommands.ExitSuccess
l.Run()
という処理がアプリの実行部分である(これは実際にデバッグしてログを確かめたので間違いなし).
l
という変数はloaderというオブジェクトで runsc/boot/loader.go
に実体が定義されてる.
Run()
は run()
をラップしているので run()
の一部を覗くと
log.Infof("Process should have started...") l.watchdog.Start() return l.k.Start()
という処理が見える.l.k
は以下のようにgVisorが実装したユーザスペースカーネルの実体.
type Loader struct { // k is the kernel. k *kernel.Kernel
ここまで辿った後のフローはご親切に公式ドキュメントに次のフロー図が書いてある
kernel.Startによってアプリケーションのコードがgoroutine上で起動する.またptrace,kvmなどのアプリケーションからのシステムコールをフックするものもここらへんで起動してアプリケーションにアタッチする.
kernel.Start関数は内部でtask.Start関数を呼び出す.
task構造体はアプリケーション,システムコール実行その他諸々の責任を担うgVisorにおける処理実行の本体だと思えばいい.
そして上記の図の通りアプリケーションからくるシステムコールの実行部分は pkg/sentry/kernel/task_run.go
に定義されている次の部分
func (*runApp) execute(t *Task) taskRunState { ... switch err { case nil: // Handle application system call. return t.doSyscall() ... }
doSyscallは同じ階層の pkg/sentry/kernel/task_syscall.go
で定義されている
// doSyscall is the entry point for an invocation of a system call specified by // the current state of t's registers. // // The syscall path is very hot; avoid defer. func (t *Task) doSyscall() taskRunState { sysno := t.Arch().SyscallNo() args := t.Arch().SyscallArgs() // Tracers expect to see this between when the task traps into the kernel // to perform a syscall and when the syscall is actually invoked. // This useless-looking temporary is needed because Go. tmp := uintptr(syscall.ENOSYS) t.Arch().SetReturn(-tmp) // Check seccomp filters. The nil check is for performance (as seccomp use // is rare), not needed for correctness. if t.syscallFilters.Load() != nil { switch r := t.checkSeccompSyscall(int32(sysno), args, usermem.Addr(t.Arch().IP())); r { case linux.SECCOMP_RET_ERRNO, linux.SECCOMP_RET_TRAP: t.Debugf("Syscall %d: denied by seccomp", sysno) return (*runSyscallExit)(nil) case linux.SECCOMP_RET_ALLOW: // ok case linux.SECCOMP_RET_KILL_THREAD: t.Debugf("Syscall %d: killed by seccomp", sysno) t.PrepareExit(ExitStatus{Signo: int(linux.SIGSYS)}) return (*runExit)(nil) case linux.SECCOMP_RET_TRACE: t.Debugf("Syscall %d: stopping for PTRACE_EVENT_SECCOMP", sysno) return (*runSyscallAfterPtraceEventSeccomp)(nil) default: panic(fmt.Sprintf("Unknown seccomp result %d", r)) } } return t.doSyscallEnter(sysno, args) }
処理としてはseccompパッケージで定義されているフィルターでチェックしてから実行しているのがわかる.
doSyscallEnterから少し省略するがいくつかのラッパーを経由した後 executeSyscall関数を呼び出す
func (t *Task) executeSyscall(sysno uintptr, args arch.SyscallArguments) (rval uintptr, ctrl *SyscallControl, err error) { s := t.SyscallTable() fe := s.FeatureEnable.Word(sysno) var straceContext interface{} if bits.IsAnyOn32(fe, StraceEnableBits) { straceContext = s.Stracer.SyscallEnter(t, sysno, args, fe) } if bits.IsOn32(fe, ExternalBeforeEnable) && (s.ExternalFilterBefore == nil || s.ExternalFilterBefore(t, sysno, args)) { t.invokeExternal() // Ensure we check for stops, then invoke the syscall again. ctrl = ctrlStopAndReinvokeSyscall } else { fn := s.Lookup(sysno) if fn != nil { // Call our syscall implementation. rval, ctrl, err = fn(t, args) } else { // Use the missing function if not found. rval, err = t.SyscallTable().Missing(t, sysno, args) } } if bits.IsOn32(fe, ExternalAfterEnable) && (s.ExternalFilterAfter == nil || s.ExternalFilterAfter(t, sysno, args)) { t.invokeExternal() // Don't reinvoke the syscall. } if bits.IsAnyOn32(fe, StraceEnableBits) { s.Stracer.SyscallExit(straceContext, t, sysno, rval, err) } return }
システムコールのテーブルを生成し,システムコール番号(システムコールは内部では全て一意な番号が振られている)をキーに上記のforkシステムコールのように pkg/sentry/syscalls/sys_*.go
で実装されているシステムコールの関数ポインタを引っ張ってきて実行しているのがわかる
結果
なんとなくシステムコールフックから実行までの最短距離は見えた気がする. ただいくつかなんのためにやってるかわからない処理があったりしたのでもう少し深く突っ込みたい
所感
一般的にgVisorの処理が遅いと言われているのはこのシステムコールフックとユーザカーネルのせいであるのは自明だった.またissueに上がっている点で言えばネットワーク周りが非常に弱い(ubuntuのイメージ動かしてapt-getが一切動かないレベル)なのでこれからのパフォーマンス改善と使いやすさ向上に期待.
引用
- https://qiita.com/mamomamo/items/ed5db2ab1555078f8a24
- https://thinkit.co.jp/article/14032
- https://github.com/google/gvisor/tree/master/pkg/sentry/kernel
- https://gvisor.dev/docs/architecture_guide/
おまけ: gVisorのデバッグの仕方
環境
Vagrant: Ubuntu18.04
- bazelインストール
- go1.11 >= インストール
- dockerインストール
mkdir ~/go/src//home/vagrant/go/src/gvisor.googlesource.com
git clone https://gvisor.googlesource.com/gvisor gvisor
cd gvisor
ここからビルドしていく. 初回は結構時間かかる
bazel build runsc sudo cp ./bazel-bin/runsc/linux_amd64_pure_stripped/runsc /usr/local/bin
バイナリをコピーしたので次にdocker daemonで動くOCI Runtimeをrunscに切り替える
/etc/docker/daermon.json
をvimで開いて以下のように編集します(最初は存在しないはず)
{ "runtimes": { "runsc": { "path": "/usr/local/bin/runsc", "runtimeArgs": [ "--debug-log=/tmp/runsc/", "--debug", "--strace" ] } } }
debug-logのパスは自由.好きなとこにどうぞ.
--debugフラグオンでlogがいい感じにみれます.
書いたら sudo systemctl restart docker
でデーモンを再起動します.
再起動が終わったらhello worldしてみる.
docker run --runtime=runsc --rm hello-world
これで /tmp/runsc/
以下に
runsc.log.20190517-062853.940057.create runsc.log.20190517-062853.947028.boot runsc.log.20190517-062853.979842.start runsc.log.20190517-062854.139347.state runsc.log.20190517-062853.946004.gofer runsc.log.20190517-062853.973704.state runsc.log.20190517-062854.079995.state runsc.log.20190517-062854.145467.delete
のようにログが吐かれる.*.bootログがアプリケーション実行ログなのでアプリからのシステムコール実行の形跡はこのログで確認できる.
【論文読み】Fast key-value stores: An idea whose time has come and gone
http://pages.cs.wisc.edu/~rgrandl/papers/link.pdf
上記の論文を読んだ.これからもちょくちょく論文読んでまとめるみたいなのやっていきたい
問題
現代のWebアプリにおけるIn-memory KVS,要はRedisやMemcachedはアプリケーションからロジックを切り離しステートレスにするために活躍している. だけど,シンプルなAPIゆえに,複雑なデータの格納や読み込み がやり辛いしネットワークレイテンシがでかくなる,読み込んだ後もアプリ側でバイナリデータをプログラム上のオブジェクトにマッピング((Un)marshaling)しなきゃいけなくてこいつはかなり時間がかかるといった問題があった.
関連研究
論文ではこういったネットワーク越しに存在するKVSを RInK(Remote In memory Key-value) として呼称している. RInKのパフォーマンス改善はいくつか事例があるが,単純にRDMAを使ってパフォーマンス改善をする手法や,アプリケーションサーバと同じサーバにKVSを立てるなどが挙がっている.しかし,これらは上記の(Un)marshlingを改善しようとはしてない.
解決方法
LInK(Linked In memory Key-value) と筆者は名付けている手法を提案している.こいつは単純にアプリケーションにKVSを埋め込む. データへのアクセスはライブラリが抽象化するけど基本的にメモリ内にアプリケーションのオブジェクトそのまま格納するので上記の(Un)marshalingがボトルネックじゃなくなる.
じゃあスケールアウトというかKeyの分散はどうするかというとReshardingは普通にサポートするっぽい(具体的なアルゴリズムは書いてない). まぁこれでネットワークと(Un)marshlingのレイテンシは解決できたねとのこと
まとめ
面白かったけど試験的な論文なのか(筆者はGoogleとスタンフォードの人)分散KVSとして重要な耐久性やデータの一貫性とかについては「この論文では触れない」としている. 確かにアプリに埋め込めばそりゃ速くなるだろうけど当然アプリケーションとしてはステートフルになるし, ライブラリの開発とかはプログラミング言語依存になるしでまだ解決しなきゃいけない問題は多そう.
でも,これがもう少しいい感じに使えるようになったらWebのアプリはもっと爆速になるのはロマンがある.
Vagrantで仮想的なWANみたいなのを作る
研究の検証用環境として以下のような環境をVMのみで一つのホストPC上に構築したかった. Vagrantで一応できたので手順とかをメモしておく.
ストーリー
それぞれ 192.168.10.100 (Node A) と 172.168.10.100 (Node B) のVMはネットワークのセグメントが異なるので直接アクセスできない. Node AがNode Bにアクセスしたい場合はNode BのGlobal IPを指定してアクセスするようにさせる.
Node Aは100.100.100.100にNATで変換され,Node Bは100.100.100.200に変更されるとする.
Node AがNode BのGlobal IPである100.100.100.200にパケットを投げるとデフォルトゲートウェイを介してRouter VMに届き,Router VMがiptablesを用いてパケットの送信元,宛先IPを変換することでNode Bにパケットを転送したい.
Vagrantfile
まず各VMの設定をVagrantfileに書いておく
# -*- mode: ruby -*- # vi: set ft=ruby : Vagrant.configure("2") do |config| # The most common configuration options are documented and commented below. # For a complete reference, please see the online documentation at # https://docs.vagrantup.com. # Every Vagrant development environment requires a box. You can search for # boxes at https://vagrantcloud.com/search. config.vm.box = "bento/ubuntu-18.04" # Network A config.vm.define :proxyA do |proxyA| proxyA.vm.hostname = "proxyA" proxyA.vm.network :private_network, ip: "192.168.10.100", virtual__intnet: "intnetA" end config.vm.define :peerA do |peerA| peerA.vm.hostname = "peerA" peerA.vm.network :private_network, ip: "192.168.10.101", virtual__intnet: "intnetA" end # Internal router config.vm.define "router" do |router| router.vm.hostname = "router" router.vm.network :private_network, ip: "192.168.10.10", virtua__intnet: "intnetA" router.vm.network :private_network, ip: "172.168.10.10", virtua__intnet: "intnetB" end # Network B config.vm.define :proxyB do |proxyB| proxyB.vm.hostname = "proxyB" proxyB.vm.network :private_network, ip: "172.168.10.100", virtual__intnet: "intnetB" end config.vm.define :peerB do |peerB| peerB.vm.hostname = "peerB" peerB.vm.network :private_network, ip: "172.168.10.101", virtual__intnet: "intnetB" end end
名前は変わっているが Proxy A, Proxy BがそれぞれNode A, Node Bにあたる. 今回はPeer* のマシンは関係ないので無視
各Nodeでデフォルトゲートウェイを設定
VMを立ち上げたら vagrant ssh
で各NodeのVMにログインしてデフォルトゲートウェイを設定しておく
# Node A sudo route add default gw 192.168.10.10 dev eth1 # Node B sudo route add default gw 172.168.10.10 dev eth1
各NodeのVMには上記のVagrantfileで割り当てた静的なIPがeth1に割り当てられているはずなのでそれに合わせてデフォルトゲートウェイを設定する
設定した後に 各NodeのVMから ping 100.100.100.200
のような存在しない,アクセスできないはずのアドレスにpingを打ち,
Router側で sudo tcpdump -i any icmp
と叩くことで各VMからのpingを観測できるはず
Router VM上でiptablesを設定する
まずパケットの転送を許可する設定を行う
vi /etc/sysctl.conf
でファイルを開き
#net.ipv4.ip_forward=1
のコメントアウトを外す. その後に sudo sysctl -p
で有効化する.これでVMを再起動してもパケットの転送設定はOnのまま
次に iptables に変換テーブルを追加する
sudo iptables -t nat -A PREROUTING -d 100.100.100.200 -i eth1 -j DNAT --to-destination 172.168.10.100 sudo iptables -t nat -A POSTROUTING -o eth1 -s 192.168.10.100 -j MASQUERADE sudo iptables -t nat -A PREROUTING -d 100.100.100.100 -i eth2 -j DNAT --to-destination 192.168.10.100 sudo iptables -t nat -A POSTROUTING -o eth2 -s 172.168.10.100 -j MASQUERADE
1行目はeth1に流れてくる100.100.100.200が宛先となっているパケットを172.168.10.100に変換する. 2行目はNode Aへの返信のパケットを逆変換して届けるための設定 3, 4行目はそれぞれNode Bからのパケット用に書き換えたもの.
これで上記の図のパケット転送がうまくいくはず
ドラゴンクエストXを支える技術を読んで 〜普通のソフトウェアエンジニアから見た視点〜
「ドラゴンクエストXを支える技術」を読みました
読んだ感想
非常に面白い本でした.オンラインゲームのプログラミングに関わったことがない人でも参考になる知見が沢山詰まっています. 本書はドラゴンクエストXにおける元テクニカルディレクター,現プロデューサーの青山さんが執筆しています.
元々,Web+DB Pressで連載されたものをリライトしたものです. 折角なので普段はWeb系の開発に携わっている & ドラゴンクエストXの一般ユーザーとして面白かったところをいくつかピックアップしようと思います.
前提知識
まず読者である僕が持っている知識について書いておきます. なぜそんなことを書くかというと,僕が面白いと言ってもそれはある程度プログラミング等の開発知識があるから面白いと思えたことも多々あると考えているからです.プログラミングに一切関わりがないドラクエXユーザーが抱く感想とはまた違うかもしれないということ予め伝えておきます.
- プログラミング: 普段はWeb関係の開発に従事しています. ドラクエXで言うなら冒険者の広場やお出かけ便利ツールの開発がこれに当たります.
- コンピュータに関する知識: 大学院で専攻している内容でもあるので基礎的なものは大丈夫だと思います.ドラクエXではメモリの最適化,グラフィック処理,移動干渉やトランザクション処理のロジック全般がこれに当たります.
- ネットワークに関する知識: 所謂TCP/IPレベルのネットワークや負荷分散などのノウハウについては研究している分野でもあるのでほぼ大丈夫でした.
と,こんな感じの人が読んだらどういう感想やどこが面白いと思ったのかというのを書いていきます. 順番としては本書の章立てに従っていきます.
メモリの最適化を意識したコーディング
コンピュータにおけるプログラムの実行は基本的にメモリとCPUが肝です.
プログラムを実行する際にはWindowsで言えば .exe
ファイル,所謂実行形式のファイルをOSがメモリに領域を確保して,展開し,CPUがメモリに展開された命令を一つずつ実行するというフローになっています.
これは当然オンラインゲームでも同じです.PCやSwitch, WiiUなどのメモリ上でドラクエXのクライアントが展開され実行されているわけです.
勿論重要なプログラムはインターネット上にあるドラクエXの心臓部でもあるゲームサーバで実行されているわけですが.
ここで問題になるのがメモリには限界があるということです.オンラインゲームはどんどんアップデートを重ねてそのプログラムの大きさは非常に大きいものになっていきます.しかし,メモリには容量の限界があるため例えば20GBもあるプログラムは大抵の家庭用PCでは満足に動かないと思います.
本書でも再三言われていますが,ドラクエXではそんなメモリの容量を事細かに気にしながらプログラミングをしています. コラムでは構造体のメモリアラインメントに関する知識に関しても触れていました.
例えば多くのCPUで要求される構造体(size_t)のアライメントは 32bit環境では 4byte
, 64bit環境では 8byte
だったりします.
次の擬似コードではメンバ変数のサイズの合計は6byte(int: 4byte, char: 1byte)です.
struct Hoge { int A; char B; char C; }
このコード自体はエラーなどはないのですが,アラインメントされていない場合は環境(CPUやメモリ)によっては構造体のサイズが変わってしまうかもしれないのです. そこで次のように明示的に確保される容量を示します(本書とは違うように書いてます)
struct Hoge {
Size32int A;
Size8char B;
Size8char C;
Size16bits padding;
}
ここまでのレベルで最適化を意識することはほぼないので読んでて非常に新鮮でした.
使用するプログラミング言語
ドラクエXではC++とLuaが主な開発言語として使用されているようです. ゲームならまぁC++だろうなというのは思いましたがLuaが使用されているのは以外でした.
Luaは高負荷にはならないクライアント側のちょっとした処理をPDCAのサイクルを早く回すために用いられているようです. ただ,LuaとC++の連携は具体的にどうやってやっているかはちょっとわかりません(RPCなのかC++からLuaを直接呼び出しているのかそのまた逆か).
大規模プロジェクトならではのビルド環境
ビルドというものはプログラミング言語でかかれたファイル群を実行可能な一つのファイルにコンパイルすることを指しています. 往々にして大きなプログラムになるほどこのビルドには時間がかかるものです. 例えば有名なコンパイラである LLVM というOSSは自分のPCでビルドしようとすると2, 3時間かかったりします. ソースコードを修正するたびにそんなに時間がかかっていたら生産性も下がってしまいますよね. ビルドを自動化したり高速化するのもプログラマ界隈では重要なミッションです.
ドラクエXでは distcc
という分散コンパイラというものを使っています.大体インターネットでコンパイラと調べると一つのPC上で処理が完結するものが殆どですが,distccは複数のPCで分散してコンパイルを行うことで高速化を図るというものです.プロダクション環境で使っている例を初めて見たので感動でした.もしかしたら色々なところで使われているのかもしれませんがあまり話題にならないのとオープンにしていない可能性もあります.
また自動ビルドによってテストを一定間隔で実行して報告するような仕組みも作っているようです.これは似たような事例が ゼルダの伝説 ブレスオブザワイルド
の開発の話でも上がっていましたね.大きいプロジェクトですとこういった仕組みの構築が開発の生産性向上に繋がりやすいのでしょう.
小さいプロジェクトとかだとMakeだけとかでもいいですけど何万行とかのコードベースなってくると色々な工夫が必要になってくるって感じはよくわかります.
厳密なバージョン管理とPDCAの回し方
コードベースが巨大になるにつれて1コミットが思わぬバグを生んだりするっていうのはまぁよくあることです. どうやらドラクエXの開発でも実際にそういうことがあったらしく原因や改善策について触れています. 特にリリースしたバージョンのコードベースに変更を加える際には小さい変更でも必ずテクニカルディレクターのレビューを通してからじゃないとマージされないそうです.
ちなみに,バージョン管理にはSVNを使ってるらしいです.Gitでも良かったそうですが使い慣れてるほうにした模様. コードベースが巨大になるとブランチのチェックアウトにもかなり時間がかかるそうでそれは体験したことないので興味深いです.
PDCAは基本的に最近のWeb開発とあまり変わらないようです.ただ,ゲームなのでドッグフーディングに費やす時間はとても多いように文面から感じました. そこからリリースまでに何回もFixを重ねてリリースブランチとするみたいな感じらしいです.
クライアントとサーバそれぞれのメモリ管理
クライアント側
クライアント側は基本的にグラフィック周りがボトルネックになる模様. 例えば,フィールドを散策してモンスターが画面に写ったり消えたりするたびに
// モンスターをメモリに確保 Monster *m = (Monster *)malloc(sizeof(Monster)); // なんか処理(バトルだったり話しかけたり) // モンスターをメモリから解放 free(m);
みたいな処理が行われるわけです.こういうことを繰り返しているとメモリの断片化ということが起こってメモリが中途半端に開いてる箇所が沢山増えてしまう. そうすると効率的にメモリを使えなくて処理が遅くなったりしちゃうわけですね.
ドラクエXではこのメモリの断片化を防ぐために解放したメモリ領域を適切に再利用することで解決しています.
この問題を読んでいる時にドルボードのブースト機能を思い出しました. いつだったかりっきーが「ドルボードの高速化は技術的に非常に難しい問題」というようなことを言っていたと思います.
これは後述されるグラフィックの処理と移動干渉の問題も絡んでいると思いますが,基本的にユーザーが移動するたびにユーザーの位置や視点から画面に映るオブジェクトを算出しメモリに確保したりしなきゃいけません.これは非常に計算量の大きい問題なのでそれがドルボードの加速によってより短いスパンで行われると描画演算が追いつかないみたいな問題があったのかもしれません(移動してるけど風景が何もないみたいな).
常時高速化だとクライアント側の負荷も相当になるので一時的なブースト機能ということで実装されたのでしょう(あくまで憶測です)
もう一つクライアント側ではプログラムオーバーレイという技術を使ってメモリの節約を図っています. 一見小難しい言葉ですが要は「普段使わないプログラムは読み込まないで必要になったら読み込む」ということです.
本書の例では住宅村や魔法の迷宮などのインスタンスオブジェクトが挙げられていました.メギストリスで討伐を買うために並んでいるときに住宅村のプログラムをメモリに読み込む必要はありませんからね.
サーバ側のメモリ管理
サーバ側は基本的にコアロジックが集約されているので速度が重視されるものが多いです. そのためメモリのスワップアウトはなるべく防ぎたいというのがドラクエXの開発側の意見です.
スワップアウトというのはプロセスに割り当てられたメモリ領域をオーバーした場合は足りない部分はHDDやSSDの記憶領域を利用させてもらうことでメモリ不足を解決するわけです.
メモリが足りてるならいいじゃないかと思うかもしれませんが問題は速度です.
一般的なメモリの読み込み速度は 10GB/s で SSD, HDDは最速で 100MB/s となっています. SSDですらメモリの100倍遅いです.
というわけでサーバプロセスがサーバのメモリを食い尽くしてしまわないように各サーバで適切にプロセスを割り当てて稼働させているようです.
サーバ側で動くゲームプロセスにおける工夫
サーバプロセスはシングルスレッドで動く
シングルスレッドは文字通りCPUから割り当てられるスレッドが一つということです. 一般的にプログラムはマルチスレッドで処理した方が高速になります(マルチスレッドの高速化手法については非常に説明が大変なので割愛). しかし,マルチスレッドは排他制御を必然的に組み込まなくてはならなくなり,デバッグが大変になります.
コードベースが巨大になるとどこがバグの原因なのか突き止めるのはもっと大変になります.そういった点を考慮してドラクエXではシングルスレッドで実装したそうです.
ゲームDB
ドラクエXのイベントでも度々開発者の方々が口にするDB(データベース),ドラクエXのデータの全てが詰まっているDBは単一障害点として非常に慎重に扱わなければなりません.
Oracle Exadata
ドラクエXではバックエンドのDBとしてOracle Exadataが使われています.なんかのイベントでよーすぴが言っていたように 銀行で使うようなDB の通りエグい検索速度,容量,そしてお値段(調べるとわかりますが数千万円は軽く越えます).
大規模なWebシステムで使うクラウドベンダが提供しているDBを一ヶ月利用しても高くて10万ちょい,一年で100万ぐらいと考えるとその規模の大きさがわかります.
Exadataには基本的にプレイヤーの情報全てが入っていると考えていいでしょう(持ち物,ステータス,ストーリーの進行状況etc...).
個人的に一番すごいなと思ったのが旅人バザーにおけるDBの使い方です.
旅人バザーはユーザーが出品した商品を他のユーザーが買うことができるシステムです.ドラクエXの経済は基本的にこれを基準に回っており,現実世界の経済と何ら変わりはありません.しかし,問題になるのがトランザクション管理です.
トランザクションというのは例えば
- AさんがBさんの商品を1000ゴールドで購入
- Aさんが1000ゴールド消費し商品を獲得
- Bさんが1000ゴールド獲得
というような一連の取引を指します.銀行のシステムなどでよく使われる言葉です. インターネットの世界では何があるかわからないので手順2と3の間で回線が切れてしまう可能性もあります.
そうした場合Aさんが1000ゴールド消費したにもかかわらずBさんは何も得ていないことになってしまい不整合が発生します. そのためにもDBやプログラムでこのトランザクションを途中で失敗したら状態を戻して0から取引をやり直す というような処理にしてあげなくてはなりません.
こういうった処理は非常に厳密にやらなくてはならないので大変です.
更に旅人バザーは全サーバ共通となっているので全ユーザが日夜取引を行うことでその負荷は最早現実世界の銀行システムと何ら変わらないのではないのでしょうか. 驚くべきはこういったシステムを銀行システムの開発者でもないオンライゲーム開発者がやっているということです.
同じ開発者からして,その大変さは推して図るべしとでもいいましょうか.とにかく凄いです.
Kyoto Tycoon
Kyoto TycoonはインメモリKVSというものです. 立ち位置としては毎回DBのアクセスするのは負荷がかかるので読み込み頻度が高く,更新されにくいデータなどはこちらに保存しておいて読み込ませることで,負荷の分散と読み込みの高速化を図っているという感じです.
TycoonとExadataが上手く協力して落ちないシステムを構築しているわけですね.
この章ではアイテム情報テーブルの構造なども書かれていますがそれは読んで確かめてみてください(書くのがだるい).
まとめ
僕が技術的に面白いなと思ったことをさらっと触れてみました. 移動干渉については少し濃密すぎるしどう考えても簡単にかけないので省きました.ぜひ買って読んでみてください.
プログラマの視点から見てもユーザーの視点から見ても本当に面白い本でした
というわけで安西先生!バトマスは天下無双が強化されて非常に楽しいです.ただ,はやぶさ斬り も強化してくださいお願いします
【Tensorflow with Keras】 Warning on loading model
妙にハマったのでメモ
Kerasでweightを保存してロードしようとした
Save
model.save_weights("model.hdf5", save_format="h5")
Load
model.load_weights("model.hdf5")
これでロード時にWarinigが出た
2019-03-28 03:56:35.810375: W tensorflow/core/util/tensor_slice_reader.cc:95] Could not open model.hdf5: Data loss: not an sstable (bad magic number): perhaps your file is in a different file format and you need to use a different restore operator?
ちゃんと保存したはずなのにフォーマットが違うと怒られる
調べてみると次のようなissueが上がっていた
上記の処理は Tensorflow-gpu 1.12
で行なっていたがこのバージョンでもまだ修正されていなかったらしい
なので拡張子を hdf5
=> h5
に修正したらうまくいった.
普通にファイル読み込んでヘッダ確認する実装にすればいいじゃんと思ったけどなんでそうしなかったんだろう・・・
【自分用まとめ】Google Developers ML Summit
Google Developers ML Summitに忍び込んできたので一応まとめる
Keynote
Edge TPU
- 250画像/sec をシステムで処理してる
- 60%のコストカットに成功
MLKit
- ビジネスはわかるけどMLはわからないとかのためにある
- 機械学習全部はできないけど色々やりた人向け
- Disney e-shop Auto-ML使ってレコメンドの改善を行なっている
30分でわかる機械学習
- 理研が猫の小脳ぐらいをスパコン使ってようやく再現できたくらい
- 機械学習とはデータから賢さを得る技術
- 既存のデータから特徴量抽出するのもあり
- 機械学習は絶対どこかで間違える.
- あくまで人間の生産性を高めるためのサポータ的な立ち位置が重要かもしれない
- Tensorflow playground 入門として良さそう
Tensorflow 2.0
- OpenImages dataset 50万ぐらい
- Cloud AI Platform <= カスタムモデルの開発効率化(これよさそう)
ここまで書いて,疲れたのでもういいかなってなった. 技術的に超絶深いところに突っ込んだ話はあんまりなかった気がするしあとでスライドも公表されそう(知らないけど)なのでまあいいかなって.
Tensorflow 2.0はMirrored Strategyが個人的に一番気になる.どれだけ分散学習のコードが抽象化されて実装しやすくなっているかはDeveloperとして非常に興味深い
また,2.0では既存のモジュールに破壊的変更が加わるので既存のコードはどうなるのだろうと思ったら,ちゃんとコードを書き換えるツールを用意してくれるっぽい. さすがGoogle www.tensorflow.org
午後のCode Lab
午後のハンズオンではTensorflowのセッションに参加してきた. 基本的にTensorflowやNNの理論は嫌っていうほど触れてきたのでわかっていたつもりだったがやはりDeveloper Advocateによる説明は格段にわかりやすかった
ハンズオンでやったコードは以下のリンクにある. 本当に機械学習に触れたことないプログラマでもここからやればNNやTensorflowのコーディングの基礎はほぼ大丈夫なのではないだろうか.
Codelabs from #GoogleMLSummit – Laurence Moroney – Medium
基礎的な一次関数の重みを更新していくNNからCNNの実装までをkerasを用いて行う.
Colabは何回か使ったがこのクオリティを無料で使えるのはすごいと思う. Tensorflow playgroundもそうだがGoogleが儲かるお陰でこのシステムを無料で使えるというのはインターネット広告様様である.