ISUCON11 New Relic 支援プログラムにあわせて、ISUCON10 予選問題(以下、予選問題)をテーマにNew Relic Go APM Agentをインストールし計測するための手順について解説したいと思います。ISUCON運営による、ISUCON10予選問題の解説と講評もあわせてご一読ください。また、Infrastructure Agentの導入についての記事も公開しています。
この記事では、Go言語での予選問題の参考実装にNew Relic Agentを適用し、より詳細に計装するためのコードを記述する手順を説明します。
New RelicのAPM Agentはほとんどの言語で数行のコードを追加するだけで計装でき、トランザクションあるいは分散トレーシングの計測を開始することができます。しかし、Go言語では自動でトランザクションの計測ができず手動でのコード追加が必要です。Go言語以外の言語をISUCONで使う方は、この記事で説明している手順の多くは不要です。各言語向けのAPM Agentのドキュメントをご参照ください。
また、Go言語で必要な計装の詳細についてはドキュメントや、New Relic Go Agent向けMaster Classのハンズオン資料をご一読ください。
Go Agentパッケージを追加してセットアップする
まず、New Relicライセンスキーをメモしておきます。ライセンスキーはデフォルトで作成されていますが、このデフォルトのキーを変更するためにはテクニカルサポートへの問い合わせが必要です。デフォルトとは別に作成し好きなタイミングで削除することもできますので、こちらの記事もご参照ください。
ライセンスキーが準備できたら、github.com/newrelic/go-agent パッケージを例えば次のようにアプリケーションに追加します。
go get github.com/newrelic/go-agent
そして、main関数の先頭に次の処理を追加します。
app, err := newrelic.NewApplication(
newrelic.ConfigAppName("ISUUMO"),
newrelic.ConfigFromEnvironment(),
newrelic.ConfigDistributedTracerEnabled(true),
newrelic.ConfigDebugLogger(os.Stdout),
)
そして、Dockerfileに次の行を追加します。
ENV NEW_RELIC_LICENSE_KEY コピーしたライセンスキー
これは、設定のうちアプリケーション名と分散トレーシングの有効化をソースコードで、ライセンスキーをDockerfileに記述した環境変数経由で設定しています。混在させる必要はないので、どちらかに揃えることも可能です。設定できるすべての項目と対応する環境変数名はドキュメントを参照ください。
もし、Go Agentを利用するのが初めてなのであれば、ここで一度アプリケーションを起動してみましょう。New Relic Oneのホーム画面(Explorer)に指定したアプリケーション名でアプリケーションが表示されているはずです。リンクをクリックするとAPMに移動できますが、まだほとんどデータは表示されないはずです。
また、標準出力にたくさんログがでますが、api-server_1から次のようなメッセージが出ているはずです。
Reporting to: https://rpm.newrelic.com/accounts/<アカウントID>/applications/<アプリケーションID>
このログはAgentが正常に起動していると判断してよいメッセージです。このURLを開くとAPMの画面に移動します。
もし、表示されていない場合はログのメッセージを確認してみてください。ライセンスキーが指定されていない・誤っている、New Relicへの通信がファイヤーウォールなどで遮断されている、などが考えられます。
トランザクションを計測するためにecho向けのintegrationをセットアップする
いよいよ、意味ある計測するための計装コードを追加していきます。まずはトランザクションを検出しましょう。New Relic APMにおけるトランザクションは計測するための処理の単位で、Webアプリの場合、HTTPリクエストに対応する処理の全体となります。予選問題の例では /api/chair/low_priced
というAPIに対応する処理 getLowPricedChair
が1つのトランザクションとなります。
1つ1つの処理に個別にトランザクションとして認識させる計装コードを入れることもできますが、煩雑になるため、よく使われているいくつかのHTTPミドルウェアのライブラリに対しては簡潔に適用できるintegrationが提供されています。予選問題の場合、labstack/echo v3が使われていますが、これに対応したnrecho integrationが提供されているため、これを利用します。integrationの利用方法はそれぞれのリンク先のgodocに記載されています。nrechoの場合、次のようにルーティング処理の前にミドルウェアとして追加します。
import nrecho "github.com/newrelic/go-agent/v3/integrations/nrecho-v3" | |
func main() { | |
app, err := newrelic.NewApplication( | |
newrelic.ConfigAppName("ISUUMO"), | |
newrelic.ConfigFromEnvironment(), | |
newrelic.ConfigDistributedTracerEnabled(true), | |
newrelic.ConfigDebugLogger(os.Stdout), | |
) | |
// Echo instance | |
e := echo.New() | |
e.Debug = true | |
// nrecho | |
e.Use(nrecho.Middleware(app)) | |
// 略 | |
} |
これでトランザクションとして検出できるようになります。試しに予選問題のベンチマークを動かすと先程開いたAPMの画面にデータが表示され始めます。(なお、下の図はデータベースの計測を設定済みであるため多少異なります)特に、Transactionsのところにエンドポイントごとの処理時間の統計が表示されていることを確認してください。
カスタム属性を追加する
トランザクションの分析をすすめる際に、個別のトランザクションに任意の名前と値の組み合わせで属性をトランザクショントレースおよび分散トレースのスパンに追加できます。例えば/api/chair/searchの searchChairs でパラメーターによって処理時間が異なるのではないかと予想したのであれば、そのパラメーターの値を属性として記録し、分析に活用することができます。
Go Agentでは、AddAttribute関数を使ってカスタム属性を追加します。AddAttribute関数はnewrelic.Transactionをレシーバーとしているため、属性を追加したい関数内(例でいえばsearchChairs)でTransactionを取得する必要があります。
New Relic Go Agentでは、Transactionを始めとする現在実行している関数が計測されているオブジェクトにアクセスするAPIを提供していますが、原則としてcontext.Contextオブジェクトが必要です。echoでは、context.Contextではなくecho.Contextが利用されているため、nrechoに用意されているnrecho.FromContext関数を使ってTransactionを取得します。したがって次のような計装コードを記述します。
import ( | |
"github.com/labstack/echo" | |
"github.com/labstack/echo/middleware" | |
nrecho "github.com/newrelic/go-agent/v3/integrations/nrecho-v3" | |
"github.com/newrelic/go-agent/v3/newrelic" | |
) | |
func searchChairs(c echo.Context) error { | |
txn := nrecho.FromContext(c) | |
//略 | |
page, err := strconv.Atoi(c.QueryParam("page")) | |
//略 | |
perPage, err := strconv.Atoi(c.QueryParam("perPage")) | |
// | |
txn.AddAttribute("sql_page", page) | |
txn.AddAttribute("sql_perpage", perPage) | |
//略 | |
} |
Transactinを取得するのは、このあとのデータベースの計測でも利用するため、機械的にすべてのメソッドの先頭に挿入するようにして、不要なものは削除するようにしてもよいかもしれません。
データベースへのクエリを計測するために計装コードを追加する
次に、ISUCONで最も改善する場所といっても過言ではないデータベースのクエリを計測します。次の図のように分散トレースで表示した際に、トレースの中でデータベースへのクエリが占める割合とそのSQLクエリが表示できるようにします。
トランザクションと同様にミドルウェアごとにintegrationが提供されているのですが、MySQL向けのintegration nrmysqlが標準で用意している手順では実現できない機能があります。それはSQLクエリの表示です。標準の手順では chair select のようにテーブル名と操作名しか表示されません。
データベース操作を記録する仕組みは、1つのトランザクション内の詳細な経過時間を記録するセグメントの仕組みを利用しています。nrmysqlは内部で自動的にDataStoreSegementを作成するのですが、手動で作成することで対応します。DataStoreSegmentを作成するcreateDataStoreSegment関数とその利用例は以下のようになります。
// createDataStoreSegment の利用例 | |
func getChairDetail(c echo.Context) error { | |
txn := nrecho.FromContext(c) | |
id, err := strconv.Atoi(c.Param("id")) | |
if err != nil { | |
c.Echo().Logger.Errorf("Request parameter \"id\" parse error : %v", err) | |
return c.NoContent(http.StatusBadRequest) | |
} | |
chair := Chair{} | |
query := `SELECT * FROM chair WHERE id = ?` | |
//クエリの直前でDataStoreSegementを作成し、Startする | |
s := createDataStoreSegment(query, id) | |
s.StartTime = txn.StartSegmentNow() | |
//クエリの実行はそのまま | |
err = db.Get(&chair, query, id) | |
//実行後、セグメントを終了させる | |
s.End() | |
if err != nil { | |
if err == sql.ErrNoRows { | |
c.Echo().Logger.Infof("requested id's chair not found : %v", id) | |
return c.NoContent(http.StatusNotFound) | |
} | |
c.Echo().Logger.Errorf("Failed to get the chair from id : %v", err) | |
return c.NoContent(http.StatusInternalServerError) | |
} else if chair.Stock <= 0 { | |
c.Echo().Logger.Infof("requested id's chair is sold out : %v", id) | |
return c.NoContent(http.StatusNotFound) | |
} | |
return c.JSON(http.StatusOK, chair) | |
} | |
//queryはクエリ文。paramsにはクエリのパラメーターを可変長引数で渡す | |
func createDataStoreSegment(query string, params ...interface{}) newrelic.DatastoreSegment { | |
queryParams := make(map[string]interface{}) | |
var i = 0 | |
for _, param := range params { | |
switch x := param.(type) { | |
case []interface{}: | |
for _, p := range x { | |
queryParams["?_"+strconv.Itoa(i)] = p | |
i++ | |
} | |
case interface{}: | |
queryParams["?_"+strconv.Itoa(i)] = x | |
i++ | |
default: | |
//ignore | |
} | |
} | |
s := cCommentRegex.ReplaceAllString(query, "") | |
s = lineCommentRegex.ReplaceAllString(s, "") | |
s = sqlPrefixRegex.ReplaceAllString(s, "") | |
op := strings.ToLower(firstWordRegex.FindString(s)) | |
var operation, collection = "", "" | |
if rg, ok := sqlOperations[op]; ok { | |
operation = op | |
if nil != rg { | |
if m := rg.FindStringSubmatch(s); len(m) > 1 { | |
collection = extractTable(m[1]) | |
} | |
} | |
} | |
segment := newrelic.DatastoreSegment{ | |
Product: newrelic.DatastoreMySQL, | |
Collection: collection, | |
Operation: operation, | |
ParameterizedQuery: query, | |
QueryParameters: queryParams, | |
Host: mySQLConnectionData.Host, | |
PortPathOrID: mySQLConnectionData.Port, | |
DatabaseName: mySQLConnectionData.DBName, | |
} | |
return segment | |
} | |
//クエリからテーブル名と操作名をパースする処理はAgentのコードから流用 | |
//the following code is copied from https://github.com/newrelic/go-agent/blob/06c801d5571056abac8ac9dfa07cf12ca869e920/v3/newrelic/sqlparse/sqlparse.go | |
func extractTable(s string) string { | |
s = extractTableRegex.ReplaceAllString(s, "") | |
if idx := strings.Index(s, "."); idx > 0 { | |
s = s[idx+1:] | |
} | |
return s | |
} | |
var ( | |
basicTable = `[^)(\]\[\}\{\s,;]+` | |
enclosedTable = `[\[\(\{]` + `\s*` + basicTable + `\s*` + `[\]\)\}]` | |
tablePattern = `(` + `\s+` + basicTable + `|` + `\s*` + enclosedTable + `)` | |
extractTableRegex = regexp.MustCompile(`[\s` + "`" + `"'\(\)\{\}\[\]]*`) | |
updateRegex = regexp.MustCompile(`(?is)^update(?:\s+(?:low_priority|ignore|or|rollback|abort|replace|fail|only))*` + tablePattern) | |
sqlOperations = map[string]*regexp.Regexp{ | |
"select": regexp.MustCompile(`(?is)^.*?\sfrom` + tablePattern), | |
"delete": regexp.MustCompile(`(?is)^.*?\sfrom` + tablePattern), | |
"insert": regexp.MustCompile(`(?is)^.*?\sinto?` + tablePattern), | |
"update": updateRegex, | |
"call": nil, | |
"create": nil, | |
"drop": nil, | |
"show": nil, | |
"set": nil, | |
"exec": nil, | |
"execute": nil, | |
"alter": nil, | |
"commit": nil, | |
"rollback": nil, | |
} | |
firstWordRegex = regexp.MustCompile(`^\w+`) | |
cCommentRegex = regexp.MustCompile(`(?is)/\*.*?\*/`) | |
lineCommentRegex = regexp.MustCompile(`(?im)(?:--|#).*?$`) | |
sqlPrefixRegex = regexp.MustCompile(`^[\s;]*`) | |
) |
この計装コードをDBクエリしているすべての箇所に適用します。ある程度機械的なコードになるのと、予選問題ではクエリする箇所が20前後であるためそこまで時間はかかりませんでした。これを完了させると上の図のように分析できるようになります。
Logs in Contextを利用するためにログフォーマッターを追加する
予選問題ではログ出力自体は実装されています。このログをファイル出力し、そのファイルをInfrastructure同梱のfluentbitやfluentdでNew Relic Logsに送信すると、ひとまずアプリケーションログの分析は可能になります。
さらにLogs in Contextという機能を利用すると、APMのエラーページや先ほどの分散トレースの画面から関連するログをすぐに表示することができます。詳しくはこちらの記事を参照ください。
https://blog.newrelic.co.jp/uncategorized/new-relic-logs-in-context/
さて、Logs in Contextは技術的には、ログ出力する際にAPM Agentからの情報を取得し所定のフォーマットにして出力することで実現しています。このため、New Relicではそのような処理を行うフォーマッターをログライブラリごとに提供していますが、Goではlogrusのみ提供しています。予選問題はechoのログ機能を利用しているので、logrusを利用し、echoのロガーとしてlogrusを指定できるplutov/echo-logrusを利用することにします。実際のコードは次のようになります。
import ( | |
"github.com/labstack/echo" | |
"github.com/labstack/echo/middleware" | |
"github.com/newrelic/go-agent/v3/integrations/logcontext/nrlogrusplugin" | |
nrecho "github.com/newrelic/go-agent/v3/integrations/nrecho-v3" | |
"github.com/newrelic/go-agent/v3/newrelic" | |
echologrus "github.com/plutov/echo-logrus" | |
logrus "github.com/sirupsen/logrus" | |
) | |
var logger = logrus.New() | |
func main() { | |
app, err := newrelic.NewApplication( | |
newrelic.ConfigAppName("ISUUMO"), | |
newrelic.ConfigFromEnvironment(), | |
newrelic.ConfigDistributedTracerEnabled(true), | |
newrelic.ConfigDebugLogger(os.Stdout), | |
) | |
//logrusにLogs in Context用のフォーマッターを設定 | |
logger.SetFormatter(nrlogrusplugin.ContextFormatter{}) | |
// Echo instance | |
e := echo.New() | |
e.Debug = true | |
//nrechoの利用 | |
e.Use(nrecho.Middleware(app)) | |
//echoのロガーにlogrusを設定 | |
logger.SetLevel(logrus.DebugLevel) | |
echologrus.Logger = logger | |
e.Logger = echologrus.GetEchoLogger() | |
e.Use(echologrus.Hook()) | |
e.Use(middleware.Recover()) | |
// 略 | |
} | |
//ログの出力例 | |
func postChair(c echo.Context) error { | |
header, err := c.FormFile("chairs") | |
if err != nil { | |
//loggerの出力時にWithContextを追加する | |
logger.WithContext(c.Request().Context()).Errorf("failed to get form file: %v", err) | |
return c.NoContent(http.StatusBadRequest) | |
} | |
//略 | |
} |
このようにしてLogs in Contextを有効にすると、例えば先程の分散トレースの右上にsee logsというリンクが表示され、クリックすると次のように関連するログが表示されるようになります。
ここまで計装コードをセットアップするとかなり分析できるようになっているはずですので、問題発見・改善策の適用・効果の確認、といったフローが回しやすくなるはずです。ぜひ、ISUCON11 New Relic 支援プログラムをご利用いただき、このブログの内容含めて気になることがあればISUCON向けのサポートプログラムをご利用ください。
本ブログに掲載されている見解は著者に所属するものであり、必ずしも New Relic 株式会社の公式見解であるわけではありません。また、本ブログには、外部サイトにアクセスするリンクが含まれる場合があります。それらリンク先の内容について、New Relic がいかなる保証も提供することはありません。