攻略記事一覧:
現状確認
access.log をもう一度見直しましょう。
Request by total time 74.113 0.0307140489018 GET / 70.007 0.00532696697611 GET /image/* 43.428 0.0575205298013 GET /@user 24.058 0.283035294118 GET /posts?max_created_at= 23.976 0.0522352941176 POST / 12.767 0.012987792472 GET /posts/* 6.642 0.00390476190476 POST /login ...
CPU を一番使っているのはトップページですが、レスポンスタイムに占める時間で言えば /image/*
がほとんど並びました。
そろそろ /image/*
をリバースプロキシするのは止めないといけません。
Go を使う場合は nginx を使うのをやめるのも候補になるのですが、静的ファイル配信は nginx に任せたほうがてっとり早いので、 nginx を外さない方針で行きましょう。
nginx の設定
僕も ISUCON 前以外は nginx の設定に詳しくないので、とりあえずこんな感じで設定しました。
root /home/isucon/private_isu/webapp/public/; location / { try_files $uri @app; } location @app { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://app; }
public ディレクトリは css とか js も入ってるので、その中に mkdir image
しておき、 try_files
ディレクティブでファイルが有ればそれを返す、なければリバースプロキシの設定を書いた @app
location を使う設定にしています。
アプリ側は、 /image/*
にアクセスがあったときに画像を返すついでにファイルに書き出すのと、画像がアップロードされたときにファイルに書いてDBには書かないように修正します。DBに書かないのは Disk IO 負荷低減のためです。
--- a/app.go +++ b/app.go @@ -78,6 +78,29 @@ func init() { store = gsm.NewMemcacheStore(memcacheClient, "isucogram_", []byte("sendagaya")) } +func writeImage(id int, mime string, data []byte) { + var ext string + switch mime { + case "image/jpeg": + ext = ".jpg" + case "image/png": + ext = ".png" + case "image/gif": + ext = ".gif" + default: + fmt.Println("Failed to write file: ", id, mime) + return + } + + fn := fmt.Sprintf("../public/image/%d%s", id, ext) + f, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE, 0666) + if err != nil { + panic(err) + } + f.Write(data) + f.Close() +} + func dbInitialize() { sqls := []string{ "DELETE FROM users WHERE id > 1000", @@ -652,7 +675,7 @@ func postIndex(w http.ResponseWriter, r *http.Request) { query, me.ID, mime, - filedata, + []byte(""), r.FormValue("body"), ) if eerr != nil { @@ -665,7 +688,7 @@ func postIndex(w http.ResponseWriter, r *http.Request) { fmt.Println(lerr.Error()) return } - + writeImage(int(pid), mime, filedata) http.Redirect(w, r, "/posts/"+strconv.FormatInt(pid, 10), http.StatusFound) return } @@ -695,6 +718,7 @@ func getImage(c web.C, w http.ResponseWriter, r *http.Request) { if err != nil { fmt.Println(err.Error()) } + writeImage(pid, post.Mime, post.Imgdata) return }
メモリが足りない!!
ここで問題にぶつかりました。メモリが足りなくてベンチが完走しません。 静的ファイル配信を nginx に任せることでメモリ使用量が減らせると思っていたのに!
多分、性能が向上したためにメモリアロケートのペースが早くなって問題が顕在化したのでしょう。プロファイラーで、ベンチマーク中 (メモリ使用量が増えてきたけどメモリ不足で落ちるよりは前) のメモリ使用量を調査してみます。
$ go tool pprof -inuse_space app.e27b8a http://localhost:3000/debug/pprof/heap Fetching profile from http://localhost:3000/debug/pprof/heap Saved profile in /home/isucon/pprof/pprof.app.e27b8a.localhost:3000.inuse_objects.inuse_space.002.pb.gz Entering interactive mode (type "help" for commands) (pprof) top40 -cum 319.72MB of 338.26MB total (94.52%) Dropped 338 nodes (cum <= 1.69MB) Showing top 40 nodes out of 52 (cum >= 8.34MB) flat flat% sum% cum cum% 0 0% 0% 337.76MB 99.85% runtime.goexit 0 0% 0% 336.72MB 99.54% net/http.(*conn).serve 0 0% 0% 322.22MB 95.26% github.com/zenazn/goji/web.(*mStack).newStack.func1 0 0% 0% 322.22MB 95.26% github.com/zenazn/goji/web.(*router).route 0 0% 0% 322.22MB 95.26% github.com/zenazn/goji/web/middleware.AutomaticOptions.func1 0 0% 0% 322.22MB 95.26% github.com/zenazn/goji/web/middleware.Logger.func1 0 0% 0% 322.22MB 95.26% github.com/zenazn/goji/web/middleware.Recoverer.func1 0 0% 0% 322.22MB 95.26% github.com/zenazn/goji/web/middleware.RequestID.func1 0 0% 0% 322.22MB 95.26% net/http.HandlerFunc.ServeHTTP 0 0% 0% 321.72MB 95.11% github.com/zenazn/goji/web.(*Mux).ServeHTTP 0 0% 0% 321.72MB 95.11% github.com/zenazn/goji/web.(*cStack).ServeHTTP 0 0% 0% 321.72MB 95.11% net/http.(*ServeMux).ServeHTTP 0 0% 0% 321.72MB 95.11% net/http.serverHandler.ServeHTTP 0 0% 0% 309.80MB 91.59% github.com/zenazn/goji/web.netHTTPHandlerFuncWrap.ServeHTTPC 0 0% 0% 301.29MB 89.07% net/http.(*Request).FormValue 0 0% 0% 301.29MB 89.07% net/http.(*Request).ParseMultipartForm 0 0% 0% 299.29MB 88.48% main.postIndex 0.50MB 0.15% 0.15% 298.79MB 88.33% mime/multipart.(*Reader).ReadForm 0 0% 0.15% 298.29MB 88.18% bytes.(*Buffer).ReadFrom 298.29MB 88.18% 88.33% 298.29MB 88.18% bytes.makeSlice 0 0% 88.33% 298.29MB 88.18% io.Copy 0 0% 88.33% 298.29MB 88.18% io.CopyN 0 0% 88.33% 298.29MB 88.18% io.copyBuffer 0 0% 88.33% 15MB 4.44% net/http.(*conn).readRequest 1MB 0.3% 88.63% 15MB 4.44% net/http.readRequest 0 0% 88.63% 12.42MB 3.67% github.com/zenazn/goji/web.handlerFuncWrap.ServeHTTPC 10.50MB 3.11% 91.73% 11MB 3.25% net/textproto.(*Reader).ReadMIMEHeader 0 0% 91.73% 10.50MB 3.10% github.com/bradleypeabody/gorilla-sessions-memcache.(*MemcacheStore).Get 0 0% 91.73% 10.50MB 3.10% main.getSession 0 0% 91.73% 9.42MB 2.78% database/sql.(*Rows).Next 9.42MB 2.78% 94.52% 9.42MB 2.78% github.com/go-sql-driver/mysql.(*buffer).fill 0 0% 94.52% 9.42MB 2.78% github.com/go-sql-driver/mysql.(*buffer).readNext 0 0% 94.52% 9.42MB 2.78% github.com/go-sql-driver/mysql.(*mysqlConn).readPacket 0 0% 94.52% 9.42MB 2.78% github.com/go-sql-driver/mysql.(*textRows).Next 0 0% 94.52% 9.42MB 2.78% github.com/go-sql-driver/mysql.(*textRows).readRow 0 0% 94.52% 8.34MB 2.47% github.com/jmoiron/sqlx.(*DB).Get 0 0% 94.52% 8.34MB 2.47% github.com/jmoiron/sqlx.(*Row).Scan 0 0% 94.52% 8.34MB 2.47% github.com/jmoiron/sqlx.(*Row).scanAny 0 0% 94.52% 8.34MB 2.47% github.com/jmoiron/sqlx.Get 0 0% 94.52% 8.34MB 2.47% main.getImage (pprof) list ReadForm Total: 338.26MB ROUTINE ======================== mime/multipart.(*Reader).ReadForm in /home/isucon/.local/go/src/mime/multipart/formdata.go 512.02kB 298.79MB (flat, cum) 88.33% of Total . . 19:// ReadForm parses an entire multipart message whose parts have . . 20:// a Content-Disposition of "form-data". . . 21:// It stores up to maxMemory bytes of the file parts in memory . . 22:// and the remainder on disk in temporary files. . . 23:func (r *Reader) ReadForm(maxMemory int64) (f *Form, err error) { 512.02kB 512.02kB 24: form := &Form{make(map[string][]string), make(map[string][]*FileHeader)} . . 25: defer func() { . . 26: if err != nil { . . 27: form.RemoveAll() . . 28: } . . 29: }() . . 30: . . 31: maxValueBytes := int64(10 << 20) // 10 MB is a lot of text. . . 32: for { . . 33: p, err := r.NextPart() . . 34: if err == io.EOF { . . 35: break . . 36: } . . 37: if err != nil { . . 38: return nil, err . . 39: } . . 40: . . 41: name := p.FormName() . . 42: if name == "" { . . 43: continue . . 44: } . . 45: filename := p.FileName() . . 46: . . 47: var b bytes.Buffer . . 48: . . 49: if filename == "" { . . 50: // value, store as string in memory . . 51: n, err := io.CopyN(&b, p, maxValueBytes) . . 52: if err != nil && err != io.EOF { . . 53: return nil, err . . 54: } . . 55: maxValueBytes -= n . . 56: if maxValueBytes == 0 { . . 57: return nil, errors.New("multipart: message too large") . . 58: } . . 59: form.Value[name] = append(form.Value[name], b.String()) . . 60: continue . . 61: } . . 62: . . 63: // file, store in memory or on disk . . 64: fh := &FileHeader{ . . 65: Filename: filename, . . 66: Header: p.Header, . . 67: } . 298.29MB 68: n, err := io.CopyN(&b, p, maxMemory+1) . . 69: if err != nil && err != io.EOF { . . 70: return nil, err . . 71: } . . 72: if n > maxMemory { . . 73: // too big, write to disk and flush buffer (pprof) list postIndex Total: 338.26MB ROUTINE ======================== main.postIndex in /home/isucon/private_isu/webapp/golang/app.go 0 299.29MB (flat, cum) 88.48% of Total . . 612: Me User . . 613: }{p, me}) . . 614:} . . 615: . . 616:func postIndex(w http.ResponseWriter, r *http.Request) { . 512.17kB 617: me := getSessionUser(r) . . 618: if !isLogin(me) { . . 619: http.Redirect(w, r, "/login", http.StatusFound) . . 620: return . . 621: } . . 622: . 298.79MB 623: if r.FormValue("csrf_token") != getCSRFToken(r) { . . 624: w.WriteHeader(StatusUnprocessableEntity) . . 625: return . . 626: }
メモリーを使ってる箇所はわかりましたが、どう見てもリークではないです。
ファイルアップロードの効率化
ググったり、multipart の ReadForm 周辺のソースコードを読んで、原因を調べ、対策を考えます。
原因:
postIndex
で最初にRequest.FormValue
を呼んだときにフォームを解析する処理が実行されていて、そこでアップロードされたファイルを読み込んでいる。- アップロードされたファイルは、ある程度 (デフォルト32MB) までは
bytes.Buffer
に読み込まれ、それを超える場合はテンポラリファイルに書き出される。 bytes.Buffer
は小さいサイズからどんどんリアロケートして拡大していくので、短時間に大量のアロケートが発生する。アプリの性能が上がるのに連動してファイルのアップロードの頻度が増え、アロケートのペースが速すぎてGCが追いつかなくなったようだ。
対策:
- multipart を解析するときに明示的に
ParseMultipartForm
を呼び出し、32MBよりももっと早めにテンポラリファイルを使うようにする。 - 並列にファイルアップロードの解析が走らないように Mutex で保護する。
--- a/app.go +++ b/app.go @@ -17,6 +17,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "github.com/bradfitz/gomemcache/memcache" @@ -613,6 +614,8 @@ func getPostsID(c web.C, w http.ResponseWriter, r *http.Request) { }{p, me}) } +var uploadM sync.Mutex + func postIndex(w http.ResponseWriter, r *http.Request) { me := getSessionUser(r) if !isLogin(me) { @@ -620,6 +623,9 @@ func postIndex(w http.ResponseWriter, r *http.Request) { return } + uploadM.Lock() + defer uploadM.Unlock() + r.ParseMultipartForm(1 << 10) if r.FormValue("csrf_token") != getCSRFToken(r) { w.WriteHeader(StatusUnprocessableEntity) return
これでもまだ足りませんでした。なので、一旦メモリに置いてからファイルに書き出すのを止め、一時ファイルにコピーして、それを image/
ディレクトリ配下にリネームするように改修しました。
--- a/app.go +++ b/app.go @@ -80,26 +80,21 @@ func init() { } func writeImage(id int, mime string, data []byte) { - var ext string - switch mime { - case "image/jpeg": - ext = ".jpg" - case "image/png": - ext = ".png" - case "image/gif": - ext = ".gif" - default: - fmt.Println("Failed to write file: ", id, mime) - return + fn := imagePath(id, mime) + err := ioutil.WriteFile(fn, data, 0666) + if err != nil { + log.Println("failed to write file; path=%q, err=%v", fn, err) } +} - fn := fmt.Sprintf("../public/image/%d%s", id, ext) - f, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE, 0666) - if err != nil { - panic(err) +func copyImage(id int, src, mime string) { + dst := imagePath(id, mime) + if err := os.Chmod(src, 0666); err != nil { + log.Println("failed to chmod: path=%v, %v", src, err) + } + if err := os.Rename(src, dst); err != nil { + log.Println("failed to rename; src=%q, dst=%q; %v", src, dst, err) } - f.Write(data) - f.Close() } func dbInitialize() { @@ -260,6 +255,19 @@ func imageURL(p Post) string { return "/image/" + strconv.Itoa(p.ID) + ext } +func imagePath(id int, mime string) string { + var ext string + switch mime { + case "image/jpeg": + ext = ".jpg" + case "image/png": + ext = ".png" + case "image/gif": + ext = ".gif" + } + return fmt.Sprintf("../public/image/%d%s", id, ext) +} + func isLogin(u User) bool { return u.ID != 0 } @@ -662,16 +670,20 @@ func postIndex(w http.ResponseWriter, r *http.Request) { } } - filedata, rerr := ioutil.ReadAll(file) - if rerr != nil { - fmt.Println(rerr.Error()) + tf, err := ioutil.TempFile("../upload", "img-") + if err != nil { + log.Panicf("failed to create image: %v", err) } - - if len(filedata) > UploadLimit { + written, err := io.CopyN(tf, file, UploadLimit+1) + if err != nil && err != io.EOF { + log.Panicf("failed to write to temporary file: %v", err) + } + if written > UploadLimit { + os.Remove(tf.Name()) + tf.Close() session := getSession(r) session.Values["notice"] = "ファイルサイズが大きすぎます" session.Save(r, w) - http.Redirect(w, r, "/", http.StatusFound) return } @@ -694,7 +706,8 @@ func postIndex(w http.ResponseWriter, r *http.Request) { fmt.Println(lerr.Error()) return } - writeImage(int(pid), mime, filedata) + tf.Close() + copyImage(int(pid), tf.Name(), mime) http.Redirect(w, r, "/posts/"+strconv.FormatInt(pid, 10), http.StatusFound) return }
これでメモリ不足は落ち着き、とりあえず静的ファイルと投稿画像を nginx から返す設定でベンチが完走しました。
{"pass":true,"score":32771,"success":27352,"fail":0,"messages":[]}
本当は、 http.Request の Form 解析によって作られる一時ファイルからアプリケーションの一時ファイルへのコピーが無駄なので、 Request の Form 系のAPIを使わず直接 Body を解析するともっと効率が良くなります。(難しそうに聞こえるかもしれませんが、ほぼ Request.ParseMultipartForm
のコピペで行けるはずです)
ただし、この時点でメモリ不足は収まったので、これ以上はディスク書き込みがネックになってどうしようもなくなるまで置いておいて、本来やりたかったチューニングにもどりましょう。
まとめ
スコア: 4745 (初期状態) -> 30076 (前回) -> 32771 (nginxで画像配信)
思っていたより性能向上が少ないです。これは次回に持ち越しです。
今回はファイルアップロードによるメモリ不足に悩まされましたが、(2010年代も後半になって)1GBメモリのマシンで、すごい大量にアップロードされる画像を裁くのは ISUCON だとありがちです。ちゃんと効率のいいファイルアップロードのやり方を調べておきましょう。
また、自分が使う予定の言語でメモリ不足を調査する方法も調べておきましょう。(例えば Python なら標準ライブラリの tracemalloc が使えると思います。)
@methane
PlanetMySQL Voting: Vote UP / Vote DOWN