Gunosy Tech Blog
Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。
2024-02-29T17:00:00+09:00
gunosy_data
Hatena::Blog
hatenablog://blog/8599973812270646797
Android Jetpack Compose 独自 TextField 実装まとめ
hatenablog://entry/6801883189086569436
2024-02-29T17:00:00+09:00
2024-02-29T17:00:01+09:00 Android Jetpack Compose での独自 TextField の実装についてまとめましたので、その内容をお伝えします。
<p>こんにちは。Android アプリ開発担当の nagayama(<a href="https://twitter.com/nagayan_dev">@nagayan_dev</a>)です。<br>
今回は Jetpack Compose での独自 TextField の実装についてまとめましたので、その内容をお伝えします。
<br>
<br>
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20240227/20240227165733.png" width="720" height="123" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span>
<br></p>
<ul class="table-of-contents">
<li><a href="#背景">背景</a></li>
<li><a href="#実装">実装</a><ul>
<li><a href="#1-文字の見切れと不要な下線の対応">1. 文字の見切れと不要な下線の対応</a></li>
<li><a href="#2-ヒント文字を表示する">2. ヒント文字を表示する</a></li>
<li><a href="#3-入力文字やカーソル色を変更">3. 入力文字やカーソル色を変更</a></li>
<li><a href="#4-TextField-にフォーカスを当てる外す">4. TextField にフォーカスを当てる/外す</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<p><br></p>
<h1 id="背景">背景</h1>
<p>私が担当している「auサービスToday」で Jetpack Compose に置き換える対応を着々と進めています。記事検索画面の対応をしている時に、文字入力部分でハマった部分があるため、それをまとめていきます。</p>
<p>Material3 のライブラリでは既に <a href="https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary#SearchBar(kotlin.String,kotlin.Function1,kotlin.Function1,kotlin.Boolean,kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Boolean,kotlin.Function0,kotlin.Function0,kotlin.Function0,androidx.compose.ui.graphics.Shape,androidx.compose.material3.SearchBarColors,androidx.compose.ui.unit.Dp,androidx.compose.ui.unit.Dp,androidx.compose.foundation.layout.WindowInsets,androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.Function1">SearchBar</a> が存在します。「auサービスToday」ではまだ Material3 の導入をしていないため、代わりに TextField を用いて独自実装を行います。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.android.com%2Freference%2Fkotlin%2Fandroidx%2Fcompose%2Fmaterial%2Fpackage-summary%3F_gl%3D1*k56g15*_up*MQ..*_ga*NjkyNTExODI4LjE3MDkwMjA3NDc.*_ga_6HH9YJMN9M*MTcwOTAyMDc0Ni4xLjAuMTcwOTAyMDc0Ni4wLjAuMA..%23TextField(androidx.compose.ui.text.input.TextFieldValue%2Ckotlin.Function1%2Candroidx.compose.ui.Modifier%2Ckotlin.Boolean%2Ckotlin.Boolean%2Candroidx.compose.ui.text.TextStyle%2Ckotlin.Function0%2Ckotlin.Function0%2Ckotlin.Function0%2Ckotlin.Function0%2Ckotlin.Boolean%2Candroidx.compose.ui.text.input.VisualTransformation%2Candroidx.compose.foundation.text.KeyboardOptions%2Candroidx.compose.foundation.text.KeyboardActions%2Ckotlin.Boolean%2Ckotlin.Int%2Ckotlin.Int%2Candroidx.compose.foundation.interaction.MutableInteractionSource%2Candroidx.compose.ui.graphics.Shape%2Candroidx.compose.material.TextFieldColors)" title="androidx.compose.material | Android Developers" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.android.com/reference/kotlin/androidx/compose/material/package-summary?_gl=1*k56g15*_up*MQ..*_ga*NjkyNTExODI4LjE3MDkwMjA3NDc.*_ga_6HH9YJMN9M*MTcwOTAyMDc0Ni4xLjAuMTcwOTAyMDc0Ni4wLjAuMA..#TextField(androidx.compose.ui.text.input.TextFieldValue,kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Boolean,kotlin.Boolean,androidx.compose.ui.text.TextStyle,kotlin.Function0,kotlin.Function0,kotlin.Function0,kotlin.Function0,kotlin.Boolean,androidx.compose.ui.text.input.VisualTransformation,androidx.compose.foundation.text.KeyboardOptions,androidx.compose.foundation.text.KeyboardActions,kotlin.Boolean,kotlin.Int,kotlin.Int,androidx.compose.foundation.interaction.MutableInteractionSource,androidx.compose.ui.graphics.Shape,androidx.compose.material.TextFieldColors)">developer.android.com</a></cite></p>
<h1 id="実装">実装</h1>
<p>TextField をそのまま利用すると、下記のような実装となります。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@Composable</span>
<span class="synType">fun</span> ArticleSearchBar(〜省略〜) {
TextField(
value = query,
modifier = Modifier
.fillMaxWidth()
.height(<span class="synConstant">48</span>.dp),
onValueChange = onQueryChanged,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = { onSearch.invoke()},),
leadingIcon = {
IconButton(onClick = onClickBack) {
Icon(Icons.Rounded.ArrowBack, 〜省略〜)
}
},
trailingIcon = {
IconButton(onClick = onClickClose) {
Icon(Icons.Rounded.Close,〜省略〜)
}
}
)
}
</pre>
<p>今回は TextField の左側に Back ボタンを、右側にテキストを削除する Close ボタンを表示します。
また、keyboardOptions / keyboardActions でキーボードを検索ボタンにすることができます。</p>
<p><br>
<figure class="figure-image figure-image-fotolife">
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20240227/20240227170047.png" width="696" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span>
</figure>
<br></p>
<p>こちらの実装だけでは目的のデザインを実現することができなかったため、ここから下記の <strong>4</strong> 点について対応・解説をしていきます。</p>
<ol>
<li>文字の見切れと不要な下線の対応</li>
<li>ヒント文字を表示する</li>
<li>入力文字やカーソル色を変更</li>
<li>画面を表示した時にフォーカスを当て、検索実行をした時にフォーカスを外す</li>
</ol>
<h2 id="1-文字の見切れと不要な下線の対応">1. 文字の見切れと不要な下線の対応</h2>
<p>TextField は残念ながら固定で padding が設定されてしまっています。今回の記事検索画面のバーの高さが固定になっている影響で、デザインした padding よりも大きいサイズが設定されているため、文字が見切れてしまっていました。また表示されている下線も同様で、TextField を使用するとセットで付いてきてしまいます。</p>
<p>こちらを解消するため TextField ではなく、その元となっている <a href="https://developer.android.com/reference/kotlin/androidx/compose/foundation/text/package-summary#BasicTextField(kotlin.String,kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Boolean,kotlin.Boolean,androidx.compose.ui.text.TextStyle,androidx.compose.foundation.text.KeyboardOptions,androidx.compose.foundation.text.KeyboardActions,kotlin.Boolean,kotlin.Int,kotlin.Int,androidx.compose.ui.text.input.VisualTransformation,kotlin.Function1,androidx.compose.foundation.interaction.MutableInteractionSource,androidx.compose.ui.graphics.Brush,kotlin.Function1">BasicTextField</a> とその中身をアレンジするための <a href="https://developer.android.com/reference/kotlin/androidx/compose/material/TextFieldDefaults#TextFieldDecorationBox(kotlin.String,kotlin.Function0,kotlin.Boolean,kotlin.Boolean,androidx.compose.ui.text.input.VisualTransformation,androidx.compose.foundation.interaction.InteractionSource,kotlin.Boolean,kotlin.Function0,kotlin.Function0,kotlin.Function0,kotlin.Function0,androidx.compose.material.TextFieldColors,androidx.compose.foundation.layout.PaddingValues">TextFieldDecorationBox</a> を使用します。置き換えた場合のコードは下記になります。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@Composable</span>
<span class="synType">fun</span> ArticleSearchBar(〜省略〜) {
BasicTextField(
value = query,
modifier = Modifier
.fillMaxWidth()
.height(<span class="synConstant">48</span>.dp),
onValueChange = onQueryChanged,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearch.invoke() }),
decorationBox = <span class="synIdentifier">@Composable</span> { innerTextField <span class="synType">-></span>
TextFieldDefaults.TextFieldDecorationBox(
value = query,
innerTextField = innerTextField,
enabled = <span class="synConstant">true</span>,
singleLine = <span class="synConstant">true</span>,
visualTransformation = visualTransformation,
interactionSource = interactionSource,
leadingIcon = {
IconButton(onClick = onClickBack) {
Icon(Icons.Rounded.ArrowBack, 〜省略〜)
}
},
trailingIcon = {
IconButton(onClick = onClickClose) {
Icon(Icons.Rounded.Close, 〜省略〜)
}
},
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(<span class="synConstant">0</span>.dp, <span class="synConstant">0</span>.dp, <span class="synConstant">0</span>.dp, <span class="synConstant">0</span>.dp)
)
}
)
}
</pre>
<p>一番最後の <strong>contentPadding</strong> で padding の設定をしています。TextField ではここで固定の padding を指定していたため、スペースが表示されていました。</p>
<p><br>
<figure class="figure-image figure-image-fotolife">
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20240227/20240227170434.png" width="695" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span>
</figure>
<br></p>
<h2 id="2-ヒント文字を表示する">2. ヒント文字を表示する</h2>
<p>BasicTextField を用いた場合のヒント文字を表示するためには、TextFieldDecorationBox の <strong>placeholder</strong> に Text を指定します。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink>TextFieldDefaults.TextFieldDecorationBox(
〜省略〜
placeholder = {
Text(
text = <span class="synConstant">"ここに文字を入力"</span>,
color = Color.Gray,
)
},
)
</pre>
<p><br>
<figure class="figure-image figure-image-fotolife">
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20240227/20240227170513.png" width="696" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span>
</figure>
<br></p>
<h2 id="3-入力文字やカーソル色を変更">3. 入力文字やカーソル色を変更</h2>
<p>文字フォントを変更したい場合は BasicTextField の引数の <strong>textStyle</strong> を変更します。
またカーソル色を変更したい場合は <strong>cursorBrush</strong> を変更します。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@Composable</span>
<span class="synType">fun</span> ArticleSearchBar(〜省略〜) {
BasicTextField(
〜省略〜
textStyle = LocalTextStyle.current.merge(TextStyle(Color.Red)),
cursorBrush = SolidColor(Color.Blue),
)
</pre>
<p><br>
<figure class="figure-image figure-image-fotolife">
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20240227/20240227170549.png" width="696" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span>
<figcaption>分かりやすいように赤文字、青カーソルと設定してみました</figcaption>
</figure>
<br></p>
<h2 id="4-TextField-にフォーカスを当てる外す">4. TextField にフォーカスを当てる/外す</h2>
<p>最後にユーザービリティの観点から、</p>
<ul>
<li>画面を開いた時に自動で TextField にフォーカスが当てる</li>
<li>キーボードから検索を実行した時、自動でフォーカスを外す</li>
</ul>
<p>をやっていきたいと思います。</p>
<p>フォーカスを当てる場合は <strong>FocusRequester</strong> 、フォーカスを外す場合は <strong>FocusManager</strong> を使用します。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@Composable</span>
<span class="synType">fun</span> ArticleSearchBar(〜省略〜) {
<span class="synType">val</span> focusRequester = remember { FocusRequester() }
<span class="synType">val</span> focusManager = LocalFocusManager.current
BasicTextField(
modifier = 〜省略〜
.focusRequester(focusRequester),
keyboardActions = KeyboardActions(onSearch = {
focusManager.clearFocus()
onSearch.invoke()
}),
〜省略〜
)
SideEffect {
focusRequester.requestFocus()
}
}
</pre>
<p>FocusRequester はフォーカスを当てたい Compose の Modifier#focusRequester に設定し、requestFocus でフォーカスを当てる実行を行います。SideEffect の中で行うことで 画面が表示された = Compose が完了した 時に、フォーカスを当てることができます。</p>
<p>FocusManager は clearFocus を実行することで、任意のタイミングでフォーカスを外すことができます。</p>
<p>以上で実装は完了です。</p>
<p><br>
<figure class="figure-image figure-image-fotolife">
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20240227/20240227170712.png" width="697" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span>
</figure>
<br></p>
<h1 id="まとめ">まとめ</h1>
<p>Jetpack Compose の独自 TextField の実装をまとめてみました。以前の SearchView と同様に一癖も二癖もあるものですが、一つ一つ対処方法はありそうなので粘り強く取り組みましょう。これからも Jetpack Compose ライフを楽しみましょう。</p>
nagayama_gn
Datadog Agentがkubeletと疎通できなくてもAWS IMDSv2からhostnameを取得させる
hatenablog://entry/6801883189081293312
2024-02-14T12:00:00+09:00
2024-02-14T12:00:04+09:00 こんにちは。プロダクト開発部 Ads チームの johnmanjiro です。普段は広告配信の API や管理画面を開発しています。 先日、EKS 上で動いている Datadog Agent のバージョンを更新した際、Pod の起動時に複数回 Restart するという事象が発生しました。この記事では、その際に発生した問題とその解決策について紹介します。 また、今回の調査には Datadog のサポートにもご協力いただきました。ありがとうございます。 Datadog Agent のバージョンアップ 起動時に Restart する問題の発生 原因の調査 仮説 DEBUG ログの確認 解決策 まと…
<p>こんにちは。プロダクト開発部 Ads チームの johnmanjiro です。普段は広告配信の API や管理画面を開発しています。</p>
<p>先日、EKS 上で動いている Datadog Agent のバージョンを更新した際、Pod の起動時に複数回 Restart するという事象が発生しました。この記事では、その際に発生した問題とその解決策について紹介します。</p>
<p>また、今回の調査には Datadog のサポートにもご協力いただきました。ありがとうございます。</p>
<ul class="table-of-contents">
<li><a href="#Datadog-Agent-のバージョンアップ">Datadog Agent のバージョンアップ</a></li>
<li><a href="#起動時に-Restart-する問題の発生">起動時に Restart する問題の発生</a></li>
<li><a href="#原因の調査">原因の調査</a><ul>
<li><a href="#仮説">仮説</a></li>
<li><a href="#DEBUG-ログの確認">DEBUG ログの確認</a></li>
</ul>
</li>
<li><a href="#解決策">解決策</a></li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h1 id="Datadog-Agent-のバージョンアップ">Datadog Agent のバージョンアップ</h1>
<p>前提として、Ads チームが管理しているプロダクトはすべて EKS 上で動作し、<a href="https://github.com/DataDog/datadog-agent">Datadog Agent</a> を使用してモニタリングを行っています。Datadog Agent は EKS の各 Node 上で DaemonSet として動作します。</p>
<p>また、Datadog Agent を含む Kubernetes 向けのツールは helmfile で管理しており、バージョンアップに関しても helmfile で行なっています。</p>
<p>今回は、<a href="https://github.com/DataDog/helm-charts">Datadog Agent の helm chart</a> のバージョンを <code>2.37.9</code> から <code>3.2.2</code> に更新しました。appVersion としては <code>7.38.2</code> から <code>7.40.1</code> に更新していることになります。</p>
<h1 id="起動時に-Restart-する問題の発生">起動時に Restart する問題の発生</h1>
<p>バージョンアップ後、一時は正常に動作しているように見えましたが、しばらくすると一部の Pod の起動時に複数回の Restart が発生してしまいました。一度正常に起動した後は問題なく稼働していましたが、新しく起動する Pod のうち一部が不定期に Restart してしまうという状況が続きました。</p>
<p>Restart した Pod で前回の agent コンテナのログを見てみると、以下のようなエラーが出ていました。</p>
<blockquote><p>Error while getting hostname, exiting: unable to reliably determine the host name. You can define one in the agent config file or in your hosts file Error: Error while getting hostname, exiting: unable to reliably determine the host name. You can define one in the agent config file or in your hosts file</p></blockquote>
<p>このエラーから、agent コンテナの起動時に hostname が取得できずに起動に失敗しているということがわかりました。
調べたところ、Datadog Agent は 7.40.0 から kubelet から hostname を取得しようとし、取得できない場合には起動に失敗するようになっていました<a href="#f-36305762" id="fn-36305762" name="fn-36305762" title="https://github.com/DataDog/datadog-agent/releases/tag/7.40.0">*1</a>。</p>
<p><a href="https://github.com/DataDog/datadog-agent/issues/14152">関連する issue</a> を調べ、以下のように <code>DD_HOSTNAME</code> 環境変数に nodeName を指定するなどを試しましたが、今度は別の Warning が出力されたため、根本の解決には至らず切り戻しました。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">env</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> DD_HOSTNAME
<span class="synIdentifier">valueFrom</span><span class="synSpecial">:</span>
<span class="synIdentifier">fieldRef</span><span class="synSpecial">:</span>
<span class="synIdentifier">fieldPath</span><span class="synSpecial">:</span> spec.nodeName
</pre>
<p>後ほどサポートに確認したのですが、EC2 環境においては <code>ip-10-126-85-146</code> のような IP アドレスベースの名前になってしまい、この形式の名前は複数のインスタンス間で重複する可能性があるため、hostname として使用することは推奨されないとのことでした。</p>
<h1 id="原因の調査">原因の調査</h1>
<h2 id="仮説">仮説</h2>
<p>エラーと Restart 時の状況を調べていくと、どうやらスケーリングによって Node が新しくスケジューリングされた際に、新しい Node に配置される Datadog Agent で発生しているということがわかりました。
このことから、Node がスケジュールされたばかりで kubelet がまだ起動中の状態のときに hostname を取得しに行こうとしているのではないかという仮説が立ちました。</p>
<p>しかし、kubelet への疎通ができない場合にはすぐエラーで落ちてしまうことや、agent コンテナの kubelet へのアクセス自体を遅らせることができないことから、自力での調査を断念して Datadog のサポートチケットを切りました。</p>
<h2 id="DEBUG-ログの確認">DEBUG ログの確認</h2>
<p>サポートに問い合わせたところ、hostname を解決する際のログは基本的に DEBUG レベルで出力されるため、DEBUG レベルでコンテナの起動からエラーにより終了するまでのログを取得することを勧めていただきました。</p>
<p>実際に DEBUG レベルのログを取得したところ、仮説通り kubelet との疎通に失敗していることを確認できました。</p>
<blockquote><p>Failed to reach Kubelet at: xx.xx.xx.xx:10255 - error: Get "<a href="http://xx.xx.xx.xx:10255/spec">http://xx.xx.xx.xx:10255/spec</a>": dial tcp xx.xx.xx.xx:10255: connect: connection refused</p></blockquote>
<h1 id="解決策">解決策</h1>
<p>この問題は、agent コンテナの環境変数に <code>DD_EC2_PRIORITIZE_INSTANCE_ID_AS_HOSTNAME=true</code> を設定することで解決しました。</p>
<p>通常であれば agent コンテナは kubelet と疎通を確認し、成功した場合には hostname の取得は成功した扱いになります。その後、さらに AWS IMDSv2 からインスタンス ID を取得し、取得できればインスタンス ID を hostname として利用します<a href="#f-d4bb6a1b" id="fn-d4bb6a1b" name="fn-d4bb6a1b" title="注意点として、コンテナ環境で AWS IMDSv2 を利用する際にはホップ数の制限を2以上に設定する必要があります。https://docs.datadoghq.com/ja/containers/troubleshooting/duplicate_hosts/#%E4%BF%AE%E5%BE%A9">*2</a>。
しかし、今回は kubelet との疎通に失敗していたため、hostname の取得は失敗と扱われ、AWS IMDSv2 からのインスタンス ID の取得も行われていませんでした。</p>
<p>そこで、 <code>DD_EC2_PRIORITIZE_INSTANCE_ID_AS_HOSTNAME=true</code> を設定することで、kubelet と疎通ができなかった場合にも AWS IMDSv2 からインスタンス ID を取得するようになるとのことでした<a href="#f-8ab03157" id="fn-8ab03157" name="fn-8ab03157" title="関連するコード:https://github.com/DataDog/datadog-agent/blob/86fc8167bcf03842316e39d5f00b228a46df89fe/pkg/util/hostname/common.go#L134-L137">*3</a>。</p>
<p>これを設定した結果、今まで発生していた Restart が発生しなくなり、安定して稼働するようになりました。</p>
<h1 id="まとめ">まとめ</h1>
<p>今回は EKS で稼働している Datadog Agent のバージョンを上げた際に Pod の起動時に複数回 Restart が発生するようになった問題と、その解決策についてご紹介しました。</p>
<p>原因はスケジュールされたばかりの Node で kubelet がまだ起動しきっていない状態で agent コンテナがアクセスしにいっていることでした。
対応として、<code>DD_EC2_PRIORITIZE_INSTANCE_ID_AS_HOSTNAME=true</code> を設定することで、kubelet と疎通できなかった場合にも AWS IMDSv2 から hostname を取得させることができるようになりました。</p>
<p>自分1人で調べていてもこの解決策にはたどりつけなかったと思うので、サポートに問い合わせることの重要性をあらためて感じました。対応していただいたサポートの方には感謝の気持ちでいっぱいです。</p>
<p>この記事が同じ問題に遭遇している方の助けになれば幸いです。</p>
<div class="footnote">
<p class="footnote"><a href="#fn-36305762" id="f-36305762" name="f-36305762" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://github.com/DataDog/datadog-agent/releases/tag/7.40.0">https://github.com/DataDog/datadog-agent/releases/tag/7.40.0</a></span></p>
<p class="footnote"><a href="#fn-d4bb6a1b" id="f-d4bb6a1b" name="f-d4bb6a1b" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">注意点として、コンテナ環境で AWS IMDSv2 を利用する際にはホップ数の制限を2以上に設定する必要があります。<a href="https://docs.datadoghq.com/ja/containers/troubleshooting/duplicate_hosts/#%E4%BF%AE%E5%BE%A9">https://docs.datadoghq.com/ja/containers/troubleshooting/duplicate_hosts/#%E4%BF%AE%E5%BE%A9</a></span></p>
<p class="footnote"><a href="#fn-8ab03157" id="f-8ab03157" name="f-8ab03157" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">関連するコード:<a href="https://github.com/DataDog/datadog-agent/blob/86fc8167bcf03842316e39d5f00b228a46df89fe/pkg/util/hostname/common.go#L134-L137">https://github.com/DataDog/datadog-agent/blob/86fc8167bcf03842316e39d5f00b228a46df89fe/pkg/util/hostname/common.go#L134-L137</a></span></p>
</div>
johnmanjiro13
Android Macrobenchmark テストの導入
hatenablog://entry/6801883189078117757
2024-01-30T12:00:00+09:00
2024-01-30T12:00:04+09:00 Macrobenchmark ライブラリを用いたテストを導入したため、その内容をお伝えします。
<p>こんにちは。Android アプリ開発担当の nagayama(<a href="https://twitter.com/nagayan_dev">@nagayan_dev</a>)です。<br>
今回私が担当している「auサービスToday」で Macrobenchmark ライブラリを用いたテストを導入したため、その内容をお伝えします。
<br>
<br>
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20240126/20240126105926.png" width="760" height="253" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span>
<br></p>
<ul class="table-of-contents">
<li><a href="#Benchmark-とは">Benchmark とは</a></li>
<li><a href="#対応方針">対応方針</a></li>
<li><a href="#実装">実装</a><ul>
<li><a href="#compileSDKバージョンのアップデート">compileSDKバージョンのアップデート</a></li>
<li><a href="#macrobenchmark-モジュールを作成">macrobenchmark モジュールを作成</a></li>
<li><a href="#ライブラリ追加">ライブラリ追加</a></li>
<li><a href="#Gradle-ファイルの設定">Gradle ファイルの設定</a></li>
<li><a href="#テストクラスを作成">テストクラスを作成</a></li>
<li><a href="#テスト対象モジュールの-AndroidManifest-に-profileable-タグを追加">テスト対象モジュールの AndroidManifest に profileable タグを追加</a></li>
</ul>
</li>
<li><a href="#実行結果">実行結果</a></li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<p><br></p>
<h1 id="Benchmark-とは">Benchmark とは</h1>
<p>Benchmark とは、アプリのパフォーマンスを検査およびモニタリングするための手段です。Benchmark を定期的に実施することで、開発で想定していないパフォーマンス劣化のキャッチアップができたりと、メリットが大きいテスト手法です。</p>
<p>Android の Benchmark ライブラリは、 Macrobenchmark と Microbenchmark の 2 種類があります。</p>
<p>Macrobenchmark ライブラリでは、起動・UIの操作・アニメーションのようなユーザーが体感する部分のパフォーマンス検査を実施することができます。</p>
<p>Microbenchmark ライブラリでは、Kotlin や Java のコードパフォーマンス検査ができます。スクロール時の処理やデータ変換といった、繰り返し使用されるコードブロックの検査に用いられることが多いです。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.android.com%2Ftopic%2Fperformance%2Fbenchmarking%2Fbenchmarking-overview" title="Benchmark your app | App quality | Android Developers" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.android.com/topic/performance/benchmarking/benchmarking-overview">developer.android.com</a></cite></p>
<h1 id="対応方針">対応方針</h1>
<p>今回は Macrobenchmark ライブラリを用いたテストの導入を行います。
<br>
アプリの起動時間を改善するにあたり、定期的に起動時間を測定する必要がありました。Macrobenchmark ライブラリを用いた起動時間テストであれば、容易にその環境を作成することが可能です。また後日対応予定である Baseline Profiles でも、実際にパフォーマンス改善がされたかどうかを確認するために Macrobenchmark のテスト実行を行う必要がありました。</p>
<p><br></p>
<h1 id="実装">実装</h1>
<p>Android Developers に Macrobenchmark テストの導入方法について記載されています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.android.com%2Fstudio%2Fprofile%2Fmacrobenchmark" title="Write a Macrobenchmark | App quality | Android Developers" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.android.com/studio/profile/macrobenchmark">developer.android.com</a></cite></p>
<p>こちらを元に導入をすすめていきます。</p>
<h2 id="compileSDKバージョンのアップデート">compileSDKバージョンのアップデート</h2>
<p>compileSDKバージョンの対象が 34 以上であったため、バージョンを設定します</p>
<pre class="code" data-lang="" data-unlink>android {
compileSdk 34</pre>
<h2 id="macrobenchmark-モジュールを作成">macrobenchmark モジュールを作成</h2>
<p>他のソースコードと別管理をするため Macrobenchmark 用のモジュール を作成します。今回のテストはこのモジュール内で完結するように作成します。</p>
<p>Android のプロジェクトから [New] > [Module] から作成します。Templates の中に <code>Benchmark</code> があるため、それを選択するとこの後に設定するものが全て設定済みな状態になってくれます。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20240126/20240126110329.png" width="760" height="484" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h2 id="ライブラリ追加">ライブラリ追加</h2>
<p>今回は下記のライブラリを追加しました。</p>
<pre class="code" data-lang="" data-unlink>dependencies {
implementation "androidx.benchmark:benchmark-macro-junit4:$benchmark_version"
implementation "androidx.test.uiautomator:uiautomator:$uiautomator_version"
}</pre>
<h2 id="Gradle-ファイルの設定">Gradle ファイルの設定</h2>
<p>まず、プラグインを <code>com.android.library</code> から <code>com.android.test</code> に変更します。</p>
<pre class="code" data-lang="" data-unlink>plugins {
id 'com.android.test'</pre>
<p>今回は独立したテスト実行環境として管理しうるため、<code>benchmark</code> という buildTypes を追加します。</p>
<p>またテスト対象となるモジュール等、必要な設定を追記します。</p>
<pre class="code gradle" data-lang="gradle" data-unlink>android {
buildTypes {
benchmark {
debuggable = true
signingConfig = debug.signingConfig
matchingFallbacks = ["release"]
}
}
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true</pre>
<h2 id="テストクラスを作成">テストクラスを作成</h2>
<p>Macrobenchmark テストを実行するテストクラスを作成します。今回は下記のようにアプリ起動をするだけのテストになります。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@RunWith</span>(AndroidJUnit4<span class="synStatement">::</span><span class="synType">class</span>)
<span class="synType">class</span> ExampleStartupBenchmark {
<span class="synIdentifier">@get:Rule</span>
<span class="synType">val</span> benchmarkRule = MacrobenchmarkRule()
<span class="synIdentifier">@Test</span>
<span class="synType">fun</span> startup() = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
iterations = <span class="synConstant">5</span>,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}
}
</pre>
<h2 id="テスト対象モジュールの-AndroidManifest-に-profileable-タグを追加">テスト対象モジュールの AndroidManifest に profileable タグを追加</h2>
<p>Benchmark を実行するには、対象の AndroidManifest に <code>profileable</code> タグを設定する必要があります。</p>
<p>また buildType が benchmark 時のみに付与したいため [テスト対象のモジュール] / src / benchmark に AndroidManifest を作成し、下記の <code>profileable</code> タグを設定しました。</p>
<pre class="code lang-xml" data-lang="xml" data-unlink> <span class="synIdentifier"><application ... ></span>
<span class="synIdentifier"><profileable</span>
<span class="synIdentifier"> </span><span class="synType">android</span><span class="synComment">:</span><span class="synType">shell</span>=<span class="synConstant">"true"</span>
<span class="synIdentifier"> </span><span class="synType">tools</span><span class="synComment">:</span><span class="synType">targetApi</span>=<span class="synConstant">"p"</span><span class="synIdentifier"> /></span>
<span class="synIdentifier"></application></span>
</pre>
<p>実装は以上です。</p>
<p><br></p>
<h1 id="実行結果">実行結果</h1>
<p>テストを実行していきます。
実行すると下記のような結果が得られます。<code>timeToInitialDisplayMs</code> には起動時間の 最小値・中央値・最大値 が出力されました。
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20240126/20240126105926.png" width="760" height="253" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>これでアプリの起動時間をMacrobenchmark テストを用いて、測定できるようになりました。</p>
<p><br></p>
<h1 id="まとめ">まとめ</h1>
<p>Macrobenchmark テストの導入実装についてお伝えしました。今回は本当に導入部分のみで、こちらを用いて定期的なパフォーマンス監視体制を整えたり、このテストコードを用いて Baseline Profiles のパフォーマンス確認を行なっていきたいと思います。</p>
nagayama_gn
LLMのプロンプトをCI/CDで評価する。promptfooを使って
hatenablog://entry/6801883189074274190
2024-01-16T09:30:00+09:00
2024-01-16T09:55:58+09:00 こんにちは。 LLM事業部のUTです。 概要 promptfoo の紹介 起動 CI での評価 GitHub Action まとめ 概要 OpenAI による ChatGPT 登場の衝撃から1年ほど経ちましたが、 LLM を活用する企業はものすごく増えました。 OpenAI だけでなく、大手クラウドや Hugging Face などを通して各企業も提供しています。 遊びで使う分にはどの LLM を使うかは適当で良いかと思いますが、プロダクトとして提供する場合利用を想定しているシーンに対して、最適なモデルを選びたいと思うのは必然でしょう。 また LLM を利用してレスポンスを受けるに当たり、最も…
<p>こんにちは。
LLM事業部の<a href="https://twitter.com/mocyuto">UT</a>です。</p>
<ul class="table-of-contents">
<li><a href="#概要">概要</a></li>
<li><a href="#promptfoo-の紹介">promptfoo の紹介</a><ul>
<li><a href="#起動">起動</a></li>
</ul>
</li>
<li><a href="#CI-での評価">CI での評価</a><ul>
<li><a href="#GitHub-Action">GitHub Action</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h1 id="概要">概要</h1>
<p>OpenAI による ChatGPT 登場の衝撃から1年ほど経ちましたが、 LLM を活用する企業はものすごく増えました。
OpenAI だけでなく、大手クラウドや Hugging Face などを通して各企業も提供しています。</p>
<p>遊びで使う分にはどの LLM を使うかは適当で良いかと思いますが、プロダクトとして提供する場合利用を想定しているシーンに対して、最適なモデルを選びたいと思うのは必然でしょう。</p>
<p>また LLM を利用してレスポンスを受けるに当たり、最も重要なのがプロンプトです。
様々な研究結果にもある通り、プロンプトの書き方一つで出力結果が大きく変わります。
欲しいアウトプットを出すプロンプトを探すには、プロンプトを複数作り比較する必要があります。</p>
<h1 id="promptfoo-の紹介">promptfoo の紹介</h1>
<p>今回は promptfoo というプロンプトやモデルの比較を行うためのツールを紹介します。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fpromptfoo.dev%2F" title="LLM evaluation | promptfoo" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://promptfoo.dev/">promptfoo.dev</a></cite></p>
<p>promptfoo は Node.js で作られた LLM のアウトプットの品質を評価する CLI です。
OpenAI や Gemini など複数の LLM に対応していて、テスト関数も多数用意されています。</p>
<p>それでは使い方から紹介していきます。</p>
<h2 id="起動">起動</h2>
<p>まずは以下のコマンドでプロジェクトを作成します。</p>
<pre class="code lang-sh" data-lang="sh" data-unlink>npx promptfoo@latest init
</pre>
<p>上記を実行すると <code>promptfooconfig.yaml</code> が生成されます。
この生成された yaml が実行に必要な最小限の設定ファイルとなります。</p>
<p>デフォルトでは、 OpenAI がプロバイダとして選択されているので以下のようにシークレットキーと共に実行すると、画像のように評価されます。</p>
<pre class="code lang-sh" data-lang="sh" data-unlink><span class="synIdentifier">OPENAI_API_KEY</span>=sk-abc123 npx promprfoo@latest <span class="synStatement">eval</span>
</pre>
<p><a href="https://user-images.githubusercontent.com/310310/244891726-480e1114-d049-40b9-bd5f-f81c15060284.gif" class="http-image"><img src="https://user-images.githubusercontent.com/310310/244891726-480e1114-d049-40b9-bd5f-f81c15060284.gif" class="http-image" alt="https://user-images.githubusercontent.com/310310/244891726-480e1114-d049-40b9-bd5f-f81c15060284.gif"></a></p>
<p>CLI 上だと確認し辛いかと思うので、以下のコマンドを使ってブラウザから確認してみましょう。</p>
<pre class="code lang-sh" data-lang="sh" data-unlink>npx promptfoo@latest view
</pre>
<p>すると、ローカルにキャッシュされた最新の実行結果を以下のようにブラウザで確認することができます。</p>
<p><figure class="figure-image figure-image-fotolife" title="Azure OpenAI の実行結果"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yuutookun/20240112/20240112201429.png" width="1200" height="615" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Azure OpenAI の実行結果</figcaption></figure></p>
<p>上の図では Azure OpenAI API で実行した結果となっていますが、モデルを変更すれば以下のように Amazon Bedrock や Google の Gemini も混ぜることができます。</p>
<p><figure class="figure-image figure-image-fotolife" title="複数の LLM モデル比較"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yuutookun/20240112/20240112201505.png" width="1200" height="474" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>複数の LLM モデル比較</figcaption></figure></p>
<p>上記のように複数モデルを混ぜる場合は以下のように yaml を書きます。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">prompts</span><span class="synSpecial">:</span>
<span class="synIdentifier">prompts/azure.yaml</span><span class="synSpecial">:</span> openai_prompt
<span class="synIdentifier">prompts/gemini.yaml</span><span class="synSpecial">:</span> gemini_prompt
<span class="synIdentifier">prompts/claude.yaml</span><span class="synSpecial">:</span> claude_prompt
<span class="synIdentifier">providers</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">id</span><span class="synSpecial">:</span> azureopenai:chat:gpt-35-turbo
<span class="synIdentifier">prompts</span><span class="synSpecial">:</span> openai_prompt
<span class="synStatement">- </span><span class="synIdentifier">id</span><span class="synSpecial">:</span> vertex:gemini-pro
<span class="synIdentifier">prompts</span><span class="synSpecial">:</span> gemini_prompt
<span class="synStatement">- </span><span class="synIdentifier">id</span><span class="synSpecial">:</span> bedrock:completion:anthropic.claude-v2:1
<span class="synIdentifier">prompts</span><span class="synSpecial">:</span> claude_prompt
<span class="synIdentifier">tests</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">vars</span><span class="synSpecial">:</span>
<span class="synIdentifier">topic</span><span class="synSpecial">:</span> bananas
</pre>
<p>各モデルでプロンプトのフォーマットが違うため、このような書き方になります。
それぞれ Azure OpenAI では prompts/azure.yaml に、 Gemini では prompts/gemini.yaml にプロンプトを書くと、呼び出されます。</p>
<h1 id="CI-での評価">CI での評価</h1>
<p>ここまで promptfoo の紹介をしましたが、これをCIで回してプロンプトを変えたときに各モデルの結果が正しいかをチェックしなければなりません。</p>
<p>promptfoo には、テスト関数が多く実装されています。</p>
<p>以下のような LLM の出力結果に対して決定的な関数でテストすることができます。</p>
<ul>
<li>contains_all</li>
<li>regex</li>
</ul>
<p>また、評価したい LLM のアウトプットに対して、別の LLM との比較を実施する関数もあります。</p>
<ul>
<li>similar</li>
<li>llm-rubric</li>
</ul>
<p>詳しくは
<a href="https://promptfoo.dev/docs/configuration/expected-outputs/">Test assertions | promptfoo</a>
に記載されています。</p>
<p>その他、Python や JavaScript を用いて独自の Assertion を導入することも可能です。
弊社では研究開発をもとに、準拠性(プロンプトの指示に従っているか)、可読性(人間が読みやすいか)、正確性(情報の正確性はあるか)等の数十種類の評価アルゴリズムを開発し導入しています。</p>
<h2 id="GitHub-Action">GitHub Action</h2>
<p>promptfoo には GitHub Actions も用意されているので、以下のように記載することですぐに CI でテストを実行させることができます。</p>
<p><a href="https://github.com/promptfoo/promptfoo-action#configuration">GitHub - promptfoo/promptfoo-action</a></p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink> <span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> typpo/promptfoo-action@v1
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">github-token</span><span class="synSpecial">:</span> ${{ secrets.GITHUB_TOKEN }}
<span class="synIdentifier">prompts</span><span class="synSpecial">:</span> <span class="synConstant">'prompts/**/*.yaml'</span>
<span class="synIdentifier">config</span><span class="synSpecial">:</span> <span class="synConstant">'prompts/promptfooconfig.yaml'</span>
<span class="synIdentifier">cache-path</span><span class="synSpecial">:</span> ~/.cache/promptfoo
<span class="synIdentifier">env</span><span class="synSpecial">:</span>
<span class="synIdentifier">OPENAI_API_KEY</span><span class="synSpecial">:</span> hoge
</pre>
<p>この GitHub Actions では、 prompts で指定された入力内のプルリクエストで差分が発生したプロンプトだけ実行するようになっています(最初なぜ動かないのかでハマりました)。
このアクションを使う場合、プルリクエストで差分が発生しているプロンプトだけ実行されるということを意識して CI を回しましょう。</p>
<p>ちなみにデフォルトで promptfoo サーバーに実行結果を送って URL で結果を確認できるようになっています。
また、 <code>no-share</code> オプションを使うことで、実行結果を送らないようにできます。</p>
<h1 id="まとめ">まとめ</h1>
<p>promptfoo 自体、まだまだ発展途上で私もプルリクエストを出したりしています。
LLM の進化が早すぎて、何が陳腐化して何が生き残るかの予想は全く付きませんが、検証のスピードを上げることは開発サイクルを早くすることなので、導入してみてはいかがでしょうか。</p>
yuutookun
生成系AIを活用した開発者支援
hatenablog://entry/6801883189069557833
2023-12-25T19:26:23+09:00
2024-01-11T21:22:31+09:00 こんにちは。koidです。 この記事は Gunosy Advent Calendar 2023 の25日目の記事です。 24日目の記事は hyamamotoさんの 大規模データ基盤における冪等性を確保した dbt のオーケストレーション でした。 早いもので、今年の Advent Calendar も最終日となりました。 今年の Advent Calendar では、昨年に引き続き各技術領域についてのエントリを中心としつつも、若干LLM関連のエントリも見られるようになってきたかなと思います。 弊社では今年、業務支援特化・生成AIサービス「ウデキキ」をリリースしましたが、並行して、LLMを含めた…
<p>こんにちは。koidです。</p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の25日目の記事です。
24日目の記事は hyamamotoさんの <a href="https://tech.gunosy.io/entry/dbt-orchestration">大規模データ基盤における冪等性を確保した dbt のオーケストレーション</a> でした。</p>
<p>早いもので、今年の Advent Calendar も最終日となりました。
今年の Advent Calendar では、昨年に引き続き各技術領域についてのエントリを中心としつつも、若干LLM関連のエントリも見られるようになってきたかなと思います。</p>
<p>弊社では今年、業務支援特化・生成AIサービス「<a href="https://udekiki.jp/">ウデキキ</a>」をリリースしましたが、並行して、LLMを含めた生成系AIを活用し、社内の色々な業務を効率化していこうという動きが盛んになっています。<a href="#f-63022deb" id="fn-63022deb" name="fn-63022deb" title="もちろん手段ありきではなく、ケースによっては生成系AIを使わずに解決できる/したものもありますが">*1</a></p>
<p>そんな中、今回は、生成系AI・LLMを活用した開発者支援について書きたいと思います。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y-koid/20231225/20231225163046.png" width="550" height="436" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<ul class="table-of-contents">
<li><a href="#生成系AIを活用した開発者支援">生成系AIを活用した開発者支援</a></li>
<li><a href="#もっと活用したい便利にしたい">もっと活用したい/便利にしたい</a></li>
<li><a href="#Pull-Requestのレビューをサポートしてくれるサービスツール">Pull Requestのレビューをサポートしてくれるサービス・ツール</a></li>
<li><a href="#手軽に始めたい体験したい">手軽に始めたい・体験したい</a></li>
<li><a href="#試してみた">試してみた</a><ul>
<li><a href="#describe">/describe</a></li>
<li><a href="#ask">/ask</a></li>
<li><a href="#improve">/improve</a></li>
</ul>
</li>
<li><a href="#最後に">最後に</a></li>
</ul>
<h1 id="生成系AIを活用した開発者支援">生成系AIを活用した開発者支援</h1>
<p>生成系AIを活用しての開発者支援にはどんなものがあるでしょうか。ご多分に漏れずChatGPT先生に聞いてみました<a href="#f-9b00bac5" id="fn-9b00bac5" name="fn-9b00bac5" title="って書いたら、社内で「ウデキキじゃないんですか?」って煽られました汗">*2</a>。</p>
<pre class="code" data-lang="" data-unlink>生成系AIを利用した開発者支援には、さまざまな方法が存在します。以下に、その具体的な方法をいくつか挙げてみます:
1. 自動コード生成:
- 開発者が与えた仕様や要件に基づいて、AIがコードを生成する。これにより、簡単なタスクやパターンを自動化し、開発速度を向上させることが可能です。
2. コード補完と提案:
- 開発者がコードを入力する際に、AIが自動的にコードの補完や次に来るであろうコードの提案を行う。これにより、開発者は迅速にコーディングできます。
3. エラーチェックとデバッグ支援:
- AIがコードを解析し、潜在的なエラーやバグを検出する。また、エラーメッセージをよりわかりやすく説明することで、開発者が問題を特定しやすくなります。
4. リファクタリングの提案:
- AIがコードベースを解析し、リファクタリングの提案を行う。コードの可読性やパフォーマンスを向上させ、保守性を高めます。
5. 自動テストケース生成:
- AIがコードに基づいてテストケースを自動的に生成し、コードのテストカバレッジを向上させます。これにより、品質管理が強化されます。
6. 自動ドキュメンテーション:
- AIがコードを解析し、適切なコメントやドキュメンテーションを自動的に生成する。開発者はコードの理解を助ける文書を手軽に作成できます。
7. 質問応答サポート:
- 開発者が技術的な質問を投げかけると、AIが適切な回答や解決策を提供する。これにより、開発者は問題解決に迅速に取り組むことができます。
8. プロトタイピングの自動生成:
- 開発者がアイデアを述べると、AIがそれを元にプロトタイプを自動的に生成する。アイデアの概念検証や迅速なプロトタイピングが容易になります。
これらの方法は、生成系AIを開発者のワークフローに統合することで、開発プロセスの効率性向上や品質向上を実現します。AIがルーチンなタスクを担当することで、開発者はより創造的で高度な作業に集中できるようになります。</pre>
<p>弊社では年初より、<a href="https://github.com/features/copilot">GitHub Copilot</a>を導入し、コード生成や補完、質問応答サポートなどに活用してきました。
各々、普段利用しているIDEのExtentionを利用し、開発に役立てています<a href="#f-2379ed0b" id="fn-2379ed0b" name="fn-2379ed0b" title="と信じています">*3</a>。</p>
<h1 id="もっと活用したい便利にしたい">もっと活用したい/便利にしたい</h1>
<p>一定 GitHub Copilot も浸透してきたかなというところで、前述の社内業務効率化の取り組みの中で各所ヒアリングしてみると、「Pull Requestのレビューをもっと効率化したい」という声が上がってきました。</p>
<p>ChatGPT先生に、さらに聞いてみました。</p>
<pre class="code" data-lang="" data-unlink>レビューをサポートするためには、様々な方法があります。以下に、生成系AIを活用したレビューサポートの方法をいくつか挙げてみます:
1. コードレビューコメントの自動生成:
- AIがコード変更差分を解析し、コードレビューコメントを自動的に生成する。これにより、コードレビューコメントの一貫性を高め、開発者同士のコミュニケーションを向上させます。
2. コード品質の自動評価:
- AIがコードの品質メトリクスを解析し、潜在的な問題や改善点を指摘する。これにより、コードの品質を標準に合わせる助けとなります。
3. コードスタイルの確認:
- AIがコードのスタイルガイドに準拠しているか確認し、スタイルの不一致を指摘する。統一されたコーディングスタイルを保つための助けになります。
4. セキュリティチェック:
- AIがコードをセキュリティの観点から解析し、潜在的なセキュリティリスクを検出する。セキュリティの脆弱性を早期に発見し、修正するサポートを提供します。
5. テストカバレッジの確認:
- AIがコードの変更に対するテストカバレッジを自動的に評価し、不足している部分を指摘する。テストの充実度を向上させ、品質を確保します。
6. 文書の自動生成:
- コード変更に基づいて、AIが自動的に変更された機能やAPIのドキュメンテーションを生成する。新しい機能や変更点の理解を助けます。
7. コード変更の意図解析:
- AIがコード変更の意図を解析し、変更セットに関する説明文を自動生成する。これにより、他の開発者がコード変更の目的を理解しやすくなります。
これらの方法は、AIを活用してレビュープロセスを強化し、品質向上やチームのコミュニケーションをサポートします。特にルーチンなチェックや解析作業をAIに任せることで、開発者はより高度なタスクに集中できるようになります。</pre>
<p>コードスタイルやテストカバレッジ、セキュリティチェックについては、ある程度CIに組み込んでいますが、ディスクリプション等での説明不足<a href="#f-81791d5e" id="fn-81791d5e" name="fn-81791d5e" title="書き手によって粒度が違ったりするので">*4</a>に際しての意図の把握は骨が折れるものです。</p>
<h1 id="Pull-Requestのレビューをサポートしてくれるサービスツール">Pull Requestのレビューをサポートしてくれるサービス・ツール</h1>
<p>Pull Requestのレビューをサポートしてくれるサービス・ツールにはどんなものがあるでしょうか。</p>
<p>ざっくりですが、有償のサービスとしては以下のようなものがありました。</p>
<ul>
<li><a href="https://githubnext.com/projects/copilot-for-pull-requests">Copilot for Pull Requests</a>
<ul>
<li>現在Technical Previewは終了し、<a href="https://docs.github.com/ja/copilot/github-copilot-enterprise/overview/about-github-copilot-enterprise">GitHub Copilot Enterprise</a> の一部として取り込まれています <a href="#f-a60f54fc" id="fn-a60f54fc" name="fn-a60f54fc" title="ここでもまたwaitlist...">*5</a></li>
</ul>
</li>
<li><a href="https://www.codium.ai/">CodiumAI</a></li>
<li><a href="https://coderabbit.ai/">CodeRabbit</a></li>
<li><a href="https://www.codacy.com/">Codacy</a></li>
</ul>
<p>機能比較の星取表までは作りませんが、どのサービスも広範囲にカバーされています。
課金体系はどのサービスもユーザ数(Developer数)課金です。</p>
<p>また、GitHub Action等で利用可能なOSSについては、以下のようなものがありました。</p>
<ul>
<li><a href="https://github.com/freeedcom/ai-codereviewer">freeedcom/ai-codereviewer</a></li>
<li><a href="https://github.com/microsoft/gpt-review">microsoft/gpt-review</a></li>
<li><a href="https://github.com/Codium-ai/pr-agent">Codium-ai/pr-agent</a></li>
</ul>
<p>有償のサービスに比べると、できることが一部限定的になる場合もありますが、OpenAI等のキーを自前で用意する&実行環境を自前で用意できれば、ライトに開始できそうです。</p>
<h1 id="手軽に始めたい体験したい">手軽に始めたい・体験したい</h1>
<p>いくつかサービス・ツールがある中で、手軽に始めたい・体験したいというモチベーション<a href="#f-9541e86d" id="fn-9541e86d" name="fn-9541e86d" title="と、Copilot for Pull Requestがいつまでもwaitlistでしびれを切らしたというのもあり">*6</a>で、<a href="https://github.com/Codium-ai/pr-agent">Codium-ai/pr-agent</a>を試験運用してみることにしました。</p>
<p>試験運用なので、そこまで他ツールと丁寧な比較・吟味はしていないのですが、トライしてみようと思ったポイントは下記です。</p>
<ul>
<li>GitHub Actionsだけでなく、GitHub Appとしても導入可能
<ul>
<li>自前でホストする必要はあるものの、各リポジトリにworkflowの設定をすることなく、どのリポジトリでもコメントで呼び出しができる(ので、撤退するのも楽)</li>
<li>自前ホストについて、AWS Lambdaでホストする選択肢もあり、GitHub Actionsのランナーに比べると安価</li>
</ul>
</li>
<li>OpenAI / Azure OpenAI (gpt-3.5, gpt-4) 以外のモデルも利用可能
<ul>
<li>Vertex-AIやBedrockなども利用可能</li>
</ul>
</li>
<li>promptを追加する余地があり、レスポンスの日本語化がいけそうだった</li>
</ul>
<h1 id="試してみた">試してみた</h1>
<p>トライしてみようと思ったポイントにある通り、ActionsではなくAppとして、コストを抑えるためにAWS Lambdaでホストしてみました。
(実質従量課金にできるので、利用が進まなければコストはほぼかからない状態で導入できます)</p>
<p>また、現時点ではAWS BedrockのAnthropic Claude v2.1を利用して導入しています。</p>
<p>全部は紹介しきれず、当たり障りのなさそうなところを選んでのキャプチャですが、こんなイメージです</p>
<h3 id="describe">/describe</h3>
<p>変更内容からdescriptionを自動生成してもらっています</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y-koid/20231225/20231225165618.png" width="846" height="762" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="ask">/ask</h3>
<p>コードの説明をしてもらっています<a href="#f-9e049b56" id="fn-9e049b56" name="fn-9e049b56" title="Copilotでも似たようなことができますね">*7</a></p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y-koid/20231225/20231225165614.png" width="846" height="678" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="improve">/improve</h3>
<p>改善案を提案してもらっています(ある日のテックブログのエントリです)</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y-koid/20231225/20231225192503.png" width="846" height="559" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>ほかにもreviewそのものや、change logの作成、ドキュメント追加等のコマンドもあります</p>
<h1 id="最後に">最後に</h1>
<p>モデルの特性か、日本語化している影響か切り分けられていませんが、モデルからのレスポンスが期待しているフォーマットにならず、途中で処理がコケてしまうケースがチラホラあります。
弊社の開発チームで <a href="https://github.com/promptfoo/promptfoo">promptfoo</a> を利用した複数モデルを比較できる環境が用意されているので、時間があるときに他のモデルとの比較を試してみたいなと思っています。</p>
<p>いったんまずは試験運用という形で、実際どこまで便利になるか、そしてみんなに使ってもらえるか探りつつ、今後の行く末を決めようかなと思っています。
(あまり使われないようであれば撤退するかもしれませんし、他のサービスも検討して乗り換える可能性もゼロではないかなと思います)</p>
<p>「その後どうなった?」というのは、しばらく運用してみて、また機会があれば書きたいと思います。</p>
<div class="footnote">
<p class="footnote"><a href="#fn-63022deb" id="f-63022deb" name="f-63022deb" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">もちろん手段ありきではなく、ケースによっては生成系AIを使わずに解決できる/したものもありますが</span></p>
<p class="footnote"><a href="#fn-9b00bac5" id="f-9b00bac5" name="f-9b00bac5" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">って書いたら、社内で「ウデキキじゃないんですか?」って煽られました汗</span></p>
<p class="footnote"><a href="#fn-2379ed0b" id="f-2379ed0b" name="f-2379ed0b" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">と信じています</span></p>
<p class="footnote"><a href="#fn-81791d5e" id="f-81791d5e" name="f-81791d5e" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">書き手によって粒度が違ったりするので</span></p>
<p class="footnote"><a href="#fn-a60f54fc" id="f-a60f54fc" name="f-a60f54fc" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text">ここでもまたwaitlist...</span></p>
<p class="footnote"><a href="#fn-9541e86d" id="f-9541e86d" name="f-9541e86d" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text">と、Copilot for Pull Requestがいつまでもwaitlistでしびれを切らしたというのもあり</span></p>
<p class="footnote"><a href="#fn-9e049b56" id="f-9e049b56" name="f-9e049b56" class="footnote-number">*7</a><span class="footnote-delimiter">:</span><span class="footnote-text">Copilotでも似たようなことができますね</span></p>
</div>
y-koid
大規模データ基盤における冪等性を確保した dbt のオーケストレーション
hatenablog://entry/6801883189068630875
2023-12-24T09:00:00+09:00
2024-01-11T21:22:29+09:00 はじめに はじめに データ基盤におけるバッチのオーケストレーション 移行時の課題 Helm を活用した dbt 実行基盤の構築 Helm の採用理由 Helm による dbt を実行する template の記述 Helm による k8s Job の実行 実行時の冪等性を確保する dbt の工夫 さいごに DRE&MLOps チームの hyamamoto です。 この記事は Gunosy Advent Calendar 2023 の 24 日目の記事です。 23 日目の記事は Liang さんのCircleCI + Android UI Test スクリーンショットの確認仕組みでした。 さて、…
<h1 id="はじめに">はじめに</h1>
<ul class="table-of-contents">
<li><a href="#はじめに">はじめに</a></li>
<li><a href="#データ基盤におけるバッチのオーケストレーション">データ基盤におけるバッチのオーケストレーション</a></li>
<li><a href="#移行時の課題">移行時の課題</a></li>
<li><a href="#Helm-を活用した-dbt-実行基盤の構築">Helm を活用した dbt 実行基盤の構築</a><ul>
<li><a href="#Helm-の採用理由">Helm の採用理由</a></li>
<li><a href="#Helm-による-dbt-を実行する-template-の記述">Helm による dbt を実行する template の記述</a></li>
<li><a href="#Helm-による-k8s-Job-の実行">Helm による k8s Job の実行</a></li>
<li><a href="#実行時の冪等性を確保する-dbt-の工夫">実行時の冪等性を確保する dbt の工夫</a></li>
</ul>
</li>
<li><a href="#さいごに">さいごに</a></li>
</ul>
<p>DRE&MLOps チームの hyamamoto です。</p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 24 日目の記事です。 23 日目の記事は Liang さんの<a href="https://tech.gunosy.io/entry/android_test_screenshot">CircleCI + Android UI Test スクリーンショットの確認仕組み</a>でした。</p>
<p>さて、本記事では大規模データ基盤における冪等性を確保した dbt のオーケストレーションについて紹介します。
今回 DRE&MLOps ではデータ基盤における変換処理を dbt に移行するプロジェクトを進めてきました。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Fmigrate-to-dbt" title="稼働中データ基盤を安全に dbt 移行する仕組み - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/migrate-to-dbt">tech.gunosy.io</a></cite></p>
<p>上記の記事では dbt のクエリをデグレなく移行するための仕組みについて内容を深掘って紹介しました。
今回の記事では、弊社で生じた移行時のもう一つの課題である dbt 実行のオーケストレーションについて紹介します。</p>
<h1 id="データ基盤におけるバッチのオーケストレーション">データ基盤におけるバッチのオーケストレーション</h1>
<p>まず、弊社のデータ基盤の既存の仕組みについて簡単に紹介します。
より詳細な内容は先程上げた記事の<a href="https://tech.gunosy.io/entry/migrate-to-dbt#%E7%A7%BB%E8%A1%8C%E5%89%8D%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E5%A4%89%E6%8F%9B%E5%87%A6%E7%90%86%E3%81%AE%E4%BB%95%E7%B5%84%E3%81%BF">移行前のデータ変換処理の仕組み</a>にゆずりつつ、ここでは弊社のデータ基盤におけるバッチのオーケストレーション方法にフォーカスしてお伝えします。</p>
<p>弊社ではバッチのオーケストレーションを <a href="https://www.digdag.io/">Digdag</a> を使っていっています。
この Digdag サーバーは EKS 上に構築されており、一部の処理については k8s Job として Digdag から呼び出されています。</p>
<p>データ変換については基本的に 1 テーブル を 1 ワークフローの粒度で行っており、かなりこまごまと存在しています。
このような粒度で管理することは、ワークフローの実行スケジュールを柔軟に設定できること、エラーハンドリングを簡便に保つことや backfill を容易にすることを目的としています。</p>
<h1 id="移行時の課題">移行時の課題</h1>
<p>以上の背景を踏まえて dbt に移行する際に生じた課題は以下の通りです。</p>
<ul>
<li>Digdag workflow から dbt を実行する仕組みを用意する必要がある
<ul>
<li>粒度の細かい実行のために、dbt に適切なパラメーターを渡す必要がある</li>
</ul>
</li>
<li>backfill を実行できるように、実行時の冪等性を確保する必要がある</li>
</ul>
<p>これらの問題を解決するために <a href="https://helm.sh/">Helm</a> を用いて k8s Job として dbt を実行する仕組みを用意しました。</p>
<h1 id="Helm-を活用した-dbt-実行基盤の構築">Helm を活用した dbt 実行基盤の構築</h1>
<p>それでは、Helm を用いて k8s Job として dbt を実行する仕組みについて詳しく紹介していきます。</p>
<h2 id="Helm-の採用理由">Helm の採用理由</h2>
<p>まず、なぜ Helm を採用したのかについて説明します。
Helm は k8s 上でアプリケーションをデプロイするためのツールです。
go template に基づき、柔軟に k8s のリソースを生成することができます。</p>
<p>この柔軟性が移行時の課題を解決するために非常に有効であると考えました。
というのも、様々な dbt CLI のパラメータを Digdag から外挿する必要があったためです。</p>
<h2 id="Helm-による-dbt-を実行する-template-の記述">Helm による dbt を実行する template の記述</h2>
<p>上記の課題を解決するために、dbt を実行する k8s Job の container 部分は以下のように記述しました。
例からも分かるように <code>--selects</code> パラメータや <code>--vars</code> パラメータを外挿できる形になっています。
また、<code>--selects</code> パラメータを渡すときに入力の配列をループして展開できていることは、Helm の強みだと感じています。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synComment"># ...</span>
<span class="synIdentifier">containers</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> dbt-job
<span class="synIdentifier">image</span><span class="synSpecial">:</span> <span class="synConstant">"{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"</span>
<span class="synIdentifier">command</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synConstant">"sh"</span>
<span class="synIdentifier">args</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synConstant">"-c"</span>
<span class="synStatement">- </span>|
dbt build \
<span class="synSpecial">{{</span>- with .Values.dbtRunParameters.selects <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- range . <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- printf <span class="synConstant">"--select %s </span><span class="synSpecial">\\</span><span class="synConstant">"</span> . | nindent <span class="synConstant">16</span> <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- end <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- end <span class="synSpecial">}}</span>
--vars <span class="synSpecial">{{</span> .Values.dbtRunParameters.vars | toJson | quote <span class="synSpecial">}}</span>
<span class="synIdentifier">volumeMounts</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">mountPath</span><span class="synSpecial">:</span> /usr/app/dbt/.dbt
<span class="synIdentifier">name</span><span class="synSpecial">:</span> profiles-volume
<span class="synComment"># ...</span>
</pre>
<p>さらに dbt の profiles についても ConfigMap を用いることで柔軟性と管理のしやすさを両立させました。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">apiVersion</span><span class="synSpecial">:</span> v1
<span class="synIdentifier">kind</span><span class="synSpecial">:</span> ConfigMap
<span class="synIdentifier">metadata</span><span class="synSpecial">:</span>
<span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synSpecial">{</span> <span class="synSpecial">{</span> include <span class="synConstant">"dbt-job.fullname"</span> . <span class="synSpecial">}</span> <span class="synSpecial">}</span>
<span class="synIdentifier">labels</span><span class="synSpecial">:</span> <span class="synSpecial">{</span> <span class="synSpecial">{</span> - include <span class="synConstant">"dbt-job.labels"</span> . | nindent <span class="synConstant">4</span> <span class="synSpecial">}</span> <span class="synSpecial">}</span>
<span class="synIdentifier">data</span><span class="synSpecial">:</span>
<span class="synIdentifier">profiles.yml</span><span class="synSpecial">:</span> |
<span class="synSpecial">{{</span>- toYaml .Values.dbt.profiles | nindent <span class="synConstant">4</span> <span class="synSpecial">}}</span>
</pre>
<h2 id="Helm-による-k8s-Job-の実行">Helm による k8s Job の実行</h2>
<p>次に Helm による k8s Job の実行方法です。
Helm を利用されている方ならイメージが湧きやすいかもしれませんが、基本的に Helm は永続的なアプリケーションをデプロイするためのツールです。
そのため、Helm を用いてリリースされたものは <a href="https://helm.sh/docs/intro/using_helm/#:~:text=A%20Release%20is%20an%20instance%20of%20a%20chart%20running%20in%20a%20Kubernetes%20cluster.">Release</a> という形で管理されます。</p>
<p>一方で今回の要件は永続的なアプリケーションではなく、一度実行したら終了するような k8s Job であり、そのためには Release として管理されると不都合が生じます。</p>
<p>この問題を解決するための <code>helm apply</code> を実行するのではなく、Helm によってレンダリングされた k8s のリソースを <code>kubectl apply</code> することで解決しました。</p>
<p>以下は Digdag 内で記述している dbt の実行部分を簡便化したものです。
一部 Digdag で利用できる JavaScript ベースの変数テンプレートを利用しています。</p>
<pre class="code lang-sh" data-lang="sh" data-unlink><span class="synStatement">mkdir</span> <span class="synSpecial">-p</span> tmpdir/
<span class="synComment"># rendering values.yaml</span>
<span class="synStatement">cat</span> <span class="synStatement"><< EOF</span><span class="synConstant"> > tmpdir/values.yaml</span>
<span class="synConstant">dbt:</span>
<span class="synConstant"> profiles:</span>
<span class="synConstant"> project_name:</span>
<span class="synConstant"> target: </span><span class="synPreProc">${env_short}</span>
<span class="synConstant"> config:</span>
<span class="synConstant"> debug: </span><span class="synPreProc">${typeof</span><span class="synError">(dbt_debug) == 'undefined' ? false:dbt_debug</span><span class="synPreProc">}</span>
<span class="synConstant"> log_format: </span><span class="synPreProc">${typeof</span><span class="synError">(dbt_log_format) == 'undefined' ? 'json':dbt_log_format</span><span class="synPreProc">}</span>
<span class="synConstant"> dbtRunParameters:</span>
<span class="synConstant"> selects: </span><span class="synPreProc">${selects}</span>
<span class="synConstant"> vars: </span><span class="synPreProc">${vars}</span>
<span class="synStatement">EOF</span>
<span class="synComment"># login to helm registry</span>
aws ecr get-login-password <span class="synSpecial">--region</span> ap-northeast-1 | helm registry login <span class="synSpecial">--username</span> AWS <span class="synSpecial">--password-stdin</span> <span class="synPreProc">${ecr_registry}</span>
<span class="synComment"># rendering downloaded manifests</span>
helm pull <span class="synSpecial">--destination</span> tmpdir/ <span class="synSpecial">--version</span> X.Y.Z <span class="synSpecial">--untar</span> <span class="synSpecial">--untardir</span><span class="synStatement"> . </span>oci://<span class="synPreProc">${ecr_registry}</span>/dbt-job
helm template tmpdir/dbt-job <span class="synSpecial">-f</span> tmpdir/values.yaml <span class="synStatement">></span> tmpdir/manifests.yaml
<span class="synComment"># run kubernetes job</span>
kubectl apply <span class="synSpecial">-f</span> tmpdir/manifests.yaml
</pre>
<p>このようなスクリプトを Digdag 内から呼び出すことで、Helm Release ではなく単なる k8s Job として dbt を実行することができます。
さらに、values.yaml をワークフロー内で生成することで、Helm の柔軟性を活かして実行時に必要なパラメータ外装することも可能になります。</p>
<h2 id="実行時の冪等性を確保する-dbt-の工夫">実行時の冪等性を確保する dbt の工夫</h2>
<p>最後に、実行時の冪等性を確保するための dbt の工夫について紹介します。
弊社ではログなどの大規模なデータの変換には incremental model を活用しています。
incremental model の特徴として、時間に応じて部分的なデータを更新するというものがあります。</p>
<p>dbt のよくあるチュートリアルでは incremental model に対して <code>current_time</code> を基準にしてデータを取得しているものが多く見られます。
しかしながら、<code>current_time</code> に依存してしまうと冪等な結果を得ることができず、エラー発生時などに過去のデータを取り直すということが難しくなってしまいます。</p>
<p>そこで、Digdag の cron が発火した論理的な時間である <code>session_time</code> を dbt に外挿できるようにする仕組みを設けることで、冪等性を確保しました。
例えば、<code>2023-12-01 00:00:00</code> に cron が発火した workflow の <code>session_time</code> は、実行された時間に依らず(例えば、後ほど workflow のリトライをしても変わらず) <code>2023-12-01 00:00:00</code> になります。</p>
<p><code>session_time</code> の外挿としては <code>--vars</code> パラメータを利用しており、次のようなマクロを用意しました。</p>
<pre class="code lang-python" data-lang="python" data-unlink>{%- macro digdag_session_time_jst() -%}
at_timezone(from_iso8601_timestamp(<span class="synConstant">'{{ var("digdag_session_time") }}'</span>), <span class="synConstant">'Asia/Tokyo'</span>)
{%- endmacro -%}
</pre>
<p>このマクロに基づいてデータ変換対象の時間軸を絞り込むことで、Digdag のワークフローとして冪等なデータ変換を実現することができました。</p>
<h1 id="さいごに">さいごに</h1>
<p>以上のように、Helm を用いて k8s Job として dbt を実行する仕組みを用意しました。
今回は弊社の元々の技術スタックが Digdag であったため、Digdag の機能にフォーカスした面も多かったですが、Helm による k8s Job の実行は汎用的に利用できると考えています。
また、dbt の実行に対する冪等性の確保についても、他のワークフロー管理ツールでも応用できると考えています。
今回ご紹介した内容が皆様のデータ基盤の運用において参考になれば幸いです。</p>
<p>最終日は koid さんの記事です!
お楽しみに!</p>
hiro-o918
CircleCI + Android UI Test スクリーンショットの確認仕組み
hatenablog://entry/6801883189062927158
2023-12-23T12:00:00+09:00
2024-01-11T21:22:01+09:00 こんにちは、グノシー Android アプリの開発担当の Liang です。 この記事は Gunosy Advent Calendar 2023 の23日目の記事です。前回の記事は Naoto Koizumi さんの「CircleCIからGithub Actionsに大引っ越しした話」でした。 今回は CircleCI での自動化テスト:Android Instrumentation Test において、スクリーンショットを確認出来る仕組みを紹介したいと思います。 背景 前提対応:CI/CD での自動化テスト テストケースの実行結果を検知 スクリーンショットを取得 CI/CD の artifa…
<p>こんにちは、グノシー Android アプリの開発担当の Liang です。</p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の23日目の記事です。前回の記事は Naoto Koizumi さんの「CircleCIからGithub Actionsに大引っ越しした話」でした。</p>
<p>今回は CircleCI での自動化テスト:Android Instrumentation Test において、スクリーンショットを確認出来る仕組みを紹介したいと思います。</p>
<ul class="table-of-contents">
<li><a href="#背景">背景</a></li>
<li><a href="#前提対応CICD-での自動化テスト">前提対応:CI/CD での自動化テスト</a></li>
<li><a href="#テストケースの実行結果を検知">テストケースの実行結果を検知</a></li>
<li><a href="#スクリーンショットを取得">スクリーンショットを取得</a></li>
<li><a href="#CICD-の-artifacts-に保存">CI/CD の artifacts に保存</a></li>
<li><a href="#感想">感想</a></li>
</ul>
<h2 id="背景">背景</h2>
<p>グノシー Android アプリでは、CircleCI を用いた自動 UI テストを導入しています。テスト失敗の原因をログだけで判断していたので、十分な情報が足りないため、対応に時間がかかっていました。そこでテスト失敗時のスクリーンショットを見れるようにして、確認・修正の効率を向上させようと考えました。
<figure class="figure-image figure-image-fotolife" title="CircleCI TESTS"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/jinrong/20231201/20231201114004.png" width="986" height="591" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>CircleCI TESTS</figcaption></figure></p>
<h2 id="前提対応CICD-での自動化テスト">前提対応:CI/CD での自動化テスト</h2>
<p>スクリーンショットを撮る UI テストを導入するにあたり、エミュレーターを立ち上げられる Virtual Machine が必要になります。下記を参考に、Github Actions 等の CI / CD ツールで Android System Image を導入してください。
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcircleci.com%2Fdocs%2Fandroid-machine-image%2F%23no-orb-example" title="Android images with the machine executor - CircleCI" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://circleci.com/docs/android-machine-image/#no-orb-example">circleci.com</a></cite></p>
<h2 id="テストケースの実行結果を検知">テストケースの実行結果を検知</h2>
<p>グノシー Android アプリの運用では、テストケースが失敗の時だけ、スクリーンショットを撮るので、junit の<code>TestWatcher</code>を使って、各ケースの<code>Rule</code>として失敗のコールバックを検知します。</p>
<ul>
<li>FailedTestWatcher</li>
</ul>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">class</span> FailedTestWatcher : TestWatcher() {
<span class="synType">override</span> <span class="synType">fun</span> succeeded(description: Description?) {
<span class="synStatement">super</span>.succeeded(description)
}
<span class="synType">override</span> <span class="synType">fun</span> failed(e: <span class="synType">Throwable</span>?, description: Description?) {
<span class="synStatement">super</span>.failed(e, description)
<span class="synComment">// take a screenshot</span>
}
}
</pre>
<ul>
<li>Test Class</li>
</ul>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@Rule</span>
<span class="synType">val</span> ruleChain = RuleChain
.emptyRuleChain()
.around(FailedTestWatcher())
</pre>
<h2 id="スクリーンショットを取得">スクリーンショットを取得</h2>
<p>テストが失敗したタイミング<code>FailedTestWatcher.failed</code>で、まず<code>uiAutomation.takeScreenshot</code>を通じて今のスクリーンショットである<code>Bitmap</code>を取得します。 実行したエミュレーターのストレージに画像を書き込むため、<code>ContentResolver.insert</code>の<code>Uri</code>を<code>MediaStore.VOLUME_EXTERNAL_PRIMARY</code>に指定して、書き込む用のパス<code>Uri</code>を取得します。
書き込む内容<code>ContentValues</code>には下記のカラムを記入し、外部から画像をダウンロード出来るようにします</p>
<ul>
<li><code>Media.DISPLAY_NAME</code>画像ファイル名
<ul>
<li><code>{TestClass}_{TestMethod}</code>となるように指定しています。</li>
</ul>
</li>
<li><code>Media.MIME_TYPE: image/png</code>ファイルタイプ</li>
<li><code>MediaColumns.RELATIVE_PATH</code>ディレクトリ名
<ul>
<li><code>Environment.DIRECTORY_PICTURES</code>メディアコンテンツとして保存します。</li>
</ul>
</li>
</ul>
<p>最後に<code>contentResolver.openOutputStream</code>を経由し、<code>Bitmap</code>を PNG フォマットの<code>InputStream</code>として、画像ファイルに転換します。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">val</span> bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
<span class="synType">val</span> contentResolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver
<span class="synType">val</span> contentUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
<span class="synType">val</span> contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, <span class="synConstant">"</span><span class="synIdentifier">${</span>description?.testClass?.simpleName<span class="synIdentifier">}</span><span class="synConstant">_</span><span class="synIdentifier">${</span>description?.methodName<span class="synIdentifier">}</span><span class="synConstant">"</span>)
put(MediaStore.Images.Media.MIME_TYPE, <span class="synConstant">"image/png"</span>)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
}
<span class="synType">val</span> uri = contentResolver.insert(contentUri, contentValues) <span class="synStatement">?:</span> <span class="synStatement">return</span>
contentResolver.openOutputStream(uri)?.use {
bitmap.compress(Bitmap.CompressFormat.PNG, <span class="synConstant">100</span>, it)
}
</pre>
<h2 id="CICD-の-artifacts-に保存">CI/CD の artifacts に保存</h2>
<p>CircleCI では、<code>ARTIFACTS</code> を通じてスクリーンショットである画像ファイルを確認するため、該当<code>Job</code>には保存用<code>adb</code>コマンドの Bash を実行させます。<code>adb shell find</code>を経由し、エミュレーターの<code>storage/emulated/0/Pictures</code>に保存した PNG 画像ファイルのパスを探し出します。 次に<code>adb shell cat</code>から、指定したフォルダーに各画像をコピーして保存します。</p>
<pre class="code" data-lang="" data-unlink>mkdir -p failed_screenshots
screenshots = $(adb shell find storage/emulated/0/Pictures -name "*.png")
for screenshot in screenshots; do \
adb shell cat "${screenshot}" > "failed_screenshots/${screenshot##*/}"; \
done</pre>
<p>これで CircleCI Dashbord の<code>ARTIFACTS</code>上でテスト失敗のスクリーンショットを確認出来るようになりました。
<figure class="figure-image figure-image-fotolife" title="CircleCI ARTIFACTS"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/jinrong/20231130/20231130180151.png" width="806" height="248" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>CircleCI ARTIFACTS</figcaption></figure></p>
<h2 id="感想">感想</h2>
<p>Android 開発においての UI Test は、異なる端末環境での実行結果も変わる可能性が大いにあり、レアケースを含めた不具合を解消するには、やはりテスト過程の確認が必要不可欠です。動画としてレコードしたい場合も<code>TestWatcher</code>と<code>adb shell screenrecord</code>を通じで、実現が可能でしょう。</p>
<p>明日は Hironori Yamamoto さんの「大規模データ基盤における dbt のオーケストレーション」です。お楽しみに!</p>
jinrong
CircleCIからGitHub Actionsへ大引越した話
hatenablog://entry/6801883189068058899
2023-12-22T09:30:00+09:00
2024-01-12T13:04:31+09:00 こんにちは、テクノロジー本部 プロダクト開発部 SRE の koizumi です。 この記事は Gunosy Advent Calendar 2023 の 22 日目の記事です。昨日の記事は TksYamaguchi さんの「Snyk を導入してコンテナセキュリティ対策の運用を回している話」でした。 本日はこの1年を振り返り、弊社の主要なGitHubリポジトリのCIをCircleCIからGitHub Actionsへと移行した話について取り上げたいと思います。 きっかけ CircleCIからの移行 知見の横展開 共通アクションの整備 移行して感じたこと 課題 まとめ きっかけ GitHub A…
<p>こんにちは、テクノロジー本部 プロダクト開発部 SRE の koizumi です。</p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 22 日目の記事です。昨日の記事は TksYamaguchi さんの<a href="https://tech.gunosy.io/entry/snyk-container-security">「Snyk を導入してコンテナセキュリティ対策の運用を回している話」</a>でした。</p>
<p>本日はこの1年を振り返り、弊社の主要なGitHubリポジトリのCIをCircleCIからGitHub Actionsへと移行した話について取り上げたいと思います。</p>
<ul class="table-of-contents">
<li><a href="#きっかけ">きっかけ</a></li>
<li><a href="#CircleCIからの移行">CircleCIからの移行</a></li>
<li><a href="#知見の横展開">知見の横展開</a></li>
<li><a href="#共通アクションの整備">共通アクションの整備</a></li>
<li><a href="#移行して感じたこと">移行して感じたこと</a></li>
<li><a href="#課題">課題</a></li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h1 id="きっかけ">きっかけ</h1>
<p>GitHub Actionsへの移行を始めたきっかけは遡ること丁度1年前の出来事となります。
正月休み明け早々、CircleCIから以下のセキュリティインシデントが発表されました。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcircleci.com%2Fblog%2Fjanuary-4-2023-security-alert%2F" title="CircleCI security alert: Rotate any secrets stored in CircleCI (Updated Jan 13)" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://circleci.com/blog/january-4-2023-security-alert/">circleci.com</a></cite></p>
<p>CircleCIが不正アクセスを受け、contextなどに保存された利用者の環境変数、キー、トークンなどの情報の一部が流出したことがわかりました。
CircleCIからは保存されているSecretのローテーションを指示されました。</p>
<p>弊社でも、一旦開発を止めてCircleCI上の各種SecretをRevokeし、再発行と差し替えを実施しました。</p>
<p><figure class="figure-image figure-image-fotolife" title="当時のSlackタイムライン"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231219/20231219122422.png" width="479" height="522" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>当時のSlackタイムライン</figcaption></figure></p>
<p>GitHub Actionsの技術コミュニティが活性化してきていることも相まって、社内においてもGitHub Actionsへの関心が徐々に高まってきていましたが、CircleCIからの移行コストを考えると、後回しになっていた状況でした。
そのようなタイミングで、このインシデントが発生したことがきっかけとなり、ようやく重い腰が上がりました。</p>
<h1 id="CircleCIからの移行">CircleCIからの移行</h1>
<p>弊社ではAWS環境を主に使用しており、プロビジョニングにはTerraformおよびHelmを使用しています。
このTerraformやHelmを管理しているリポジトリにて、GitHub Actionsへの移行が先行的に進んでいきました。</p>
<p>ただ、ご存知の様にCircleCIとGitHub Actionsではワークフローの設定方法に違いがあるため、一気に全てのワークフローを移行するのは少し大変でした。</p>
<p>そこで、まずは影響の少ない一部のワークフロー(git-pr-releaseやreviewdog)をGitHub Actionsに移行していきました。</p>
<p>これらの一部ワークフローの移行が完了した後、残りの各ワークフロー( <code>terraform apply</code> や <code>helmfile apply</code> )を順次移行していきました。</p>
<h1 id="知見の横展開">知見の横展開</h1>
<p>移行は全てのチームで同時並行的に進んでいたわけではなく、いくつかのチームで先行的に進んでいた状況でした。
そこで、移行を進めているチームにおいて得た知見を他チームにも共有する場を作りたいと思い、Slackチャンネルを作成しました。</p>
<p>これにより、各チームで動いているワークフローにおける課題をどう解決したか、便利そうな機能やアクションの共有などが行われ、うまく知見の横展開ができたと思います。</p>
<p><figure class="figure-image figure-image-fotolife" title="Slackチャンネルの様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231220/20231220140258.png" width="704" height="493" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Slackチャンネルの様子</figcaption></figure></p>
<h1 id="共通アクションの整備">共通アクションの整備</h1>
<p>移行しているなかで、さまざまなリポジトリにおいて複数の共通したアクションがありました。
この共通したアクションを切り出すことにより、コードをシンプルに保ち、新たに移行するワークフローにおいてもその共通アクションを利用すれば良いので比較的楽に移行できます。</p>
<p>このアクションの共通化は <code>Composite Action</code> と呼ばれています。(以下の公式ドキュメントが参考になります)
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdocs.github.com%2Fja%2Factions%2Fcreating-actions%2Fcreating-a-composite-action" title="複合アクションを作成する - GitHub Docs" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://docs.github.com/ja/actions/creating-actions/creating-a-composite-action">docs.github.com</a></cite></p>
<p>弊社では、Composite Action を管理するリポジトリを作成し、そこに共通したアクションを整備しています。
例として、レビュワーを自動アサインするアクションや、ワークフローの結果をSlackに通知するアクションなどがあります。</p>
<p>使用方法としては、Stepの中で以下のようにリポジトリとアクション名、そのバージョンを指定して、設定している入力変数を渡してあげれば簡単にワークフローを実装することができます。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">jobs</span><span class="synSpecial">:</span>
<span class="synIdentifier">my-job</span><span class="synSpecial">:</span>
<span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest
<span class="synIdentifier">steps</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> hoge/fuga/composite-action@composite-action_v0.1.1
</pre>
<h1 id="移行して感じたこと">移行して感じたこと</h1>
<p>まず初めに感じたこととしては、OSSで提供されているActionsがとても便利な点です。
例えば、先日の「<a href="https://tech.gunosy.io/entry/tfaction_ci_cd">tfaction を導入したら便利だった話</a>」でも紹介した <a href="https://github.com/suzuki-shunsuke/tfaction">tfaction</a> は社内でも広く使われています。</p>
<p>その他にも様々な便利なActionsがOSSで用意されており、車輪の再発明をせずに比較的簡単にワークフローを実装できます。</p>
<p>また、AWSがGitHub ActionsにおいてOIDC認証を提供しているため、クレデンシャル情報をGitHub ActionsのSecretsに保存する必要がなく、CircleCIのようにGitHub Actionsへ不正アクセスが発生したとしても、クレデンシャル情報が漏洩するリスクを軽減できます。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdocs.github.com%2Fen%2Factions%2Fdeployment%2Fsecurity-hardening-your-deployments%2Fconfiguring-openid-connect-in-amazon-web-services" title="Configuring OpenID Connect in Amazon Web Services - GitHub Docs" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services">docs.github.com</a></cite></p>
<h1 id="課題">課題</h1>
<p>ある程度主要なリポジトリにおいてはGitHub Actionsへの移行が完了しているものの、依然としてCircleCIを利用しているリポジトリも多く存在します。
これらのリポジトリをどのように移行していくかは今後の課題点だと感じています。</p>
<p>また、CircleCIからGitHub Actionsへの移行には <a href="https://docs.github.com/ja/actions/migrating-to-github-actions/automated-migrations/automating-migration-with-github-actions-importer">GitHub Actions Importer</a> というツールがあります。
しかし、実際に利用してみるとImport後のワークフローの修正なども発生することもあったため、移行に際しては基本的に1からワークフローを作り直していく必要があります。</p>
<p>OSSで提供されているActionを使用したり、共通化されたAction(Composite Action)の整備を進めていくことで移行障壁を下げていければと思っています。</p>
<h1 id="まとめ">まとめ</h1>
<p>弊社におけるCircleCIからGitHub Actionsへの移行した話について紹介しました。
少しでも参考になれば幸いです。</p>
<p>明日は Liang さんの「CircleCI + Android UI Test スクリーンショットの確認仕組み」についてです!お楽しみに!</p>
kozumikun
Snyk を導入してコンテナセキュリティ対策の運用を回している話
hatenablog://entry/6801883189067797150
2023-12-21T12:00:00+09:00
2023-12-21T13:40:57+09:00 Snyk を導入してコンテナセキュリティ対策の運用を回している話
<p>こんにちは、SRE チーム マネージャーの TksYamaguchi です。</p>
<p>こちらの記事は<a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a>の 21 日目の記事です。
前回の記事は m-hamashita さんの <a href="https://tech.gunosy.io/entry/ads_server_inter_az">広告スコアリングサーバのエラーを大幅に減らした話</a>でした。</p>
<ul class="table-of-contents">
<li><a href="#概要">概要</a></li>
<li><a href="#外部発表資料">外部発表資料</a></li>
<li><a href="#全体的な導入の流れ">全体的な導入の流れ</a></li>
<li><a href="#コンテナセキュリティ対応ツールの選定">コンテナセキュリティ対応ツールの選定</a></li>
<li><a href="#導入フェーズ">導入フェーズ</a><ul>
<li><a href="#優先順位付けトリアージ">優先順位付け(トリアージ)</a></li>
<li><a href="#導入開始前の合意が大事">導入開始前の合意が大事</a></li>
<li><a href="#Snyk-の-Organizaton-設定">Snyk の Organizaton 設定</a></li>
</ul>
</li>
<li><a href="#運用フェーズ">運用フェーズ</a></li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h1 id="概要">概要</h1>
<p>SRE チームでは、去年からクラウドセキュリティとコンテナセキュリティの開発チームへの導入に取り組んできました。</p>
<p>その過程を AWS Summit Tokyo 2023 や JAWS-UG、SRE Lounge で発表してきました。
現在の Gunosy のステータスは DevSecOps の導入が完了して、定常的な運用への道筋が見えてきた段階です。</p>
<p>導入過程をふりかえると、こんな感じで導入すると良かったかもというのが見えてきたので、今回はコンテナセキュリティの導入について共有します。</p>
<h1 id="外部発表資料">外部発表資料</h1>
<ul>
<li><a href="https://speakerdeck.com/yamaguchitk333/kuraudosekiyuriteinoji-shu-xuan-ze-to-sekiyuriteidui-ying-siteikuzhong-dechu-telai-take-ti">クラウドセキュリティの技術選択と、セキュリティ対応していく中で出て来た課題 - Speaker Deck</a></li>
<li><a href="https://speakerdeck.com/yamaguchitk333/jaws-ug-kontenazhi-bu-x-jaws-ug-qian-xie-zhi-bu-number-1-kontenasekiyuriteidui-ying-desnykwodao-ru-zhong-ni-fa-sheng-sitafa-sheng-zhong-noironnakoto">JAWS-UG コンテナ支部 × JAWS-UG 千葉支部 #1 コンテナセキュリティ対応でSnykを導入中に 発生した・発生中のいろんなこと - Speaker Deck</a></li>
<li><a href="https://speakerdeck.com/yamaguchitk333/security-jaws-number-27-aws-security-hub">Security-JAWS #27 AWS Security Hubの導入から 運用を回すためにやってきたこと - Speaker Deck</a></li>
<li><a href="https://speakerdeck.com/yamaguchitk333/aws-security-hubgaiketenainodeaws-security-lakewoshi-sitemita">JAWS-UG千葉支部 #19 AWS Security HubのダッシュボードがイケてないのでAWS Security Lakeを試してみた - Speaker Deck</a></li>
</ul>
<h1 id="全体的な導入の流れ">全体的な導入の流れ</h1>
<p>まず初めに本番環境の穴埋めから着手しましょう。ShiftLeft は大事ですが、効率的にリスク軽減することを考えると本番環境の穴埋めから着手することをお勧めします。コンテナセキュリティだけ導入しても本番環境が穴だらけだとリスクの軽減にはなりません。</p>
<p>下の図は Gunosy でセキュリティを導入したときの流れです。</p>
<script defer class="speakerdeck-embed" data-slide="38" data-id="ebfa1b84cef94238931049c8645fc0e7" data-ratio="1.7772511848341233" src="//speakerdeck.com/assets/embed.js"></script>
<p>それぞれのフェーズごとに、事前調整、PoC、導入・初期対応、運用を繰り返していきます。</p>
<script defer class="speakerdeck-embed" data-slide="46" data-id="ebfa1b84cef94238931049c8645fc0e7" data-ratio="1.7772511848341233" src="//speakerdeck.com/assets/embed.js"></script>
<p>特に有償ツールを利用するときは、費用が高額になりがちなこともあり念押しの確認が重要です。</p>
<script defer class="speakerdeck-embed" data-slide="48" data-id="ebfa1b84cef94238931049c8645fc0e7" data-ratio="1.7772511848341233" src="//speakerdeck.com/assets/embed.js"></script>
<h1 id="コンテナセキュリティ対応ツールの選定">コンテナセキュリティ対応ツールの選定</h1>
<p>各ツールを試験的に導入し、少し運用してみてツールの評価を行いました。</p>
<p>以下のような星取表を作成して評価を行いました。</p>
<p><figure class="figure-image figure-image-fotolife" title="星取表"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/T/TksYamaguchi/20231218/20231218163740.png" width="956" height="476" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>星取表</figcaption></figure></p>
<p>重要なのは脆弱性をスキャンする観点だけではなく、検知された脆弱性を継続的に潰していく運用が負担感なく回していけるか?という観点を入れることです。</p>
<p>そこで Gunosy では脆弱性を修正するための情報が得られるか?という観点と、修正すべき脆弱性が絞り込んで表示できるか?という観点を入れました。</p>
<p><figure class="figure-image figure-image-fotolife" title="星取表_スキャン観点"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/T/TksYamaguchi/20231218/20231218164139.png" width="1000" height="550" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>星取表_結果表示観点</figcaption></figure></p>
<p>結果として以下の項目を重視して Snyk の導入を決定しました。</p>
<ul>
<li>トリアージする SRE 視点
<ul>
<li>ダッシュボードがあり、ダッシュボードで修正すべき脆弱性が存在するイメージやリポジトリを確認しやすい</li>
<li>Tag でスキャン対象イメージを絞り込める
<ul>
<li><strong>本番環境で利用しているイメージだけをスキャンしてトリアージできる</strong></li>
</ul>
</li>
</ul>
</li>
<li>修正する開発者視点
<ul>
<li>どう修正したらいいかの Suggest が表示され、それがわかりやすい</li>
</ul>
</li>
</ul>
<p>以下は Snyk のダッシュボードの表示です。</p>
<script defer class="speakerdeck-embed" data-slide="40" data-id="ebfa1b84cef94238931049c8645fc0e7" data-ratio="1.7772511848341233" src="//speakerdeck.com/assets/embed.js"></script>
<script defer class="speakerdeck-embed" data-slide="41" data-id="ebfa1b84cef94238931049c8645fc0e7" data-ratio="1.7772511848341233" src="//speakerdeck.com/assets/embed.js"></script>
<h1 id="導入フェーズ">導入フェーズ</h1>
<script defer class="speakerdeck-embed" data-slide="75" data-id="ebfa1b84cef94238931049c8645fc0e7" data-ratio="1.7772511848341233" src="//speakerdeck.com/assets/embed.js"></script>
<p>スキャンツールで網羅的にスキャンしていない環境だと、初期導入時に大量に脆弱性が検知されます。</p>
<p>最終的には対応が必要な脆弱性は全て対応する必要があるのですが、大量に検出されるので優先順位をつけて対応していく必要があります。</p>
<h2 id="優先順位付けトリアージ">優先順位付け(トリアージ)</h2>
<p>Gunosy では以下の基準でトリアージを行いましたが、必要に応じて SSVC<a href="#f-3b7f6e5c" id="fn-3b7f6e5c" name="fn-3b7f6e5c" title="2019 年にカーネギーメロン大学 ソフトウェア工学研究所により提案された、「脆弱性管理でのアクションの優先順位付け(prioritizing actions during vulnerability management)」をする為のフレームワークです。https://insights.sei.cmu.edu/library/prioritizing-vulnerability-response-a-stakeholder-specific-vulnerability-categorization-version-20/">*1</a> 等を参考にしてください。</p>
<ul>
<li>攻撃面(internet-facing / internal)</li>
<li>攻撃コードの有無・成熟度</li>
<li>脆弱性の重大度(Critical、High)</li>
</ul>
<p>対応期日を含めて表にするとこんな感じです。
対応期日は開発チームの余力や、対象システムの性質によって変わってきますので事前調整が必要です。</p>
<table>
<thead>
<tr>
<th> </th>
<th> Critical + 攻撃コードが Mature </th>
<th> それ以外(Critical、High) </th>
</tr>
</thead>
<tbody>
<tr>
<td> Internet-facing </td>
<td> できるだけ早く対応 </td>
<td> 四半期を目処に対応 </td>
</tr>
<tr>
<td> Internal </td>
<td> 1 ヶ月以内を目処に対応 </td>
<td> 任意のタイミングで対応 </td>
</tr>
</tbody>
</table>
<h2 id="導入開始前の合意が大事">導入開始前の合意が大事</h2>
<p>想像以上に大量の脆弱性が検知されるので、導入前に開発チームとの合意が大切です。</p>
<ul>
<li>セキュリティ対策が大事なこと</li>
<li>全社的にセキュリティに取り組むこと</li>
<li>できるだけ負担なく導入していきたいこと</li>
</ul>
<p>コスト(ツールのコスト、対応工数)もかかります。こちらは幹部レベルに対して念押ししての合意が大事です。</p>
<p>最初からゼロ(完璧)を目指すと息切れします。頑張りすぎないのが大事。</p>
<p>受容可能な範囲にリスクを軽減することを目指しましょう。どのあたりをラインとするかは、対象とするシステムや保有するデータの性質によって変わってきます。</p>
<p>Gunosy では導入していませんが、セキュリティチャンピオン制度も有効だと思います。<a href="#f-e9d66cad" id="fn-e9d66cad" name="fn-e9d66cad" title="Gunosy では開発メンバー全員でセキュリティを担保する文化となっています。">*2</a></p>
<h2 id="Snyk-の-Organizaton-設定">Snyk の Organizaton 設定</h2>
<p>基本は開発チーム単位で Snyk Organization を作成します。</p>
<p>しかし、Snyk は Organization 単位で AWS Integration を設定する必要があるので、複数の AWS アカウントに ECR リポジトリを持つチームについては、ECR をスキャンする必要がある AWS アカウント単位で Snyk Organaization を作成します。</p>
<p>例外として、IaC コードが格納されている GitHub リポジトリに関しては、全社統一ルールを設定する関係で IaC 専用の Organization に Import しています。</p>
<p>このようにすることで開発チームが関係する Organization のみを確認すれば良い状況となり、認知負荷の軽減にもなっています。</p>
<h1 id="運用フェーズ">運用フェーズ</h1>
<script defer class="speakerdeck-embed" data-slide="76" data-id="ebfa1b84cef94238931049c8645fc0e7" data-ratio="1.7772511848341233" src="//speakerdeck.com/assets/embed.js"></script>
<script defer class="speakerdeck-embed" data-slide="77" data-id="ebfa1b84cef94238931049c8645fc0e7" data-ratio="1.7772511848341233" src="//speakerdeck.com/assets/embed.js"></script>
<p>一定量、初期導入で検出された脆弱性の対応ができたら運用フェーズに移行していきます。</p>
<p>定期的な棚卸しで検出された脆弱性の対応をしていきましょう。
スクラムに組み込んだり、セキュリティ対応 Day 的な対応日を設定したり、<strong>継続的にセキュリティ対応できる体制</strong>を開発チームと作りましょう。</p>
<p>運用フェーズでもトリアージが必須なので、マンパワーに応じてトリアージ対象を絞る等の対策が必要です。トリアージがボトルネックにならないようにしましょう。</p>
<p>期日を決めないと対応されずに塩漬けにされがちなので、期日を決めてプッシュしましょう。対応が進んでいない状況が確認されたら開発チームの余力を確認して、対応する脆弱性を絞る方向にトリアージ基準を変更することも大事です。<strong>継続的にセキュリティ対応できる体制</strong>を作りましょう。</p>
<h1 id="まとめ">まとめ</h1>
<script defer class="speakerdeck-embed" data-slide="76" data-id="ebfa1b84cef94238931049c8645fc0e7" data-ratio="1.7772511848341233" src="//speakerdeck.com/assets/embed.js"></script>
<p>検知された脆弱性に対応するまでがセキュリティ対応です。ツールを導入するだけ、検知するだけでは完了しません。</p>
<p>銀の弾丸はありませんが、高速道路には乗れます。ツールを導入してうまく高速道路に乗りましょう</p>
<p>ツールの導入にはお金がかかります。脆弱性対応・セキュリティ対応にも工数がかかります。事前調整が大事です(コスト、工数)。</p>
<p>導入前から開発チームを巻き込みましょう。セキュリティを自分ごとにしてもらうのが大事です。セキュリティはみんなの責任です。
セキュリティチャンピオン制度も有効なので検討してください。</p>
<p>明日は koizumi さんが CircleCI から Github Actions に大引っ越しした話について書くそうです!
どんな話になのかとても楽しみです!</p>
<div class="footnote">
<p class="footnote"><a href="#fn-3b7f6e5c" id="f-3b7f6e5c" name="f-3b7f6e5c" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">2019 年にカーネギーメロン大学 ソフトウェア工学研究所により提案された、「脆弱性管理でのアクションの優先順位付け(prioritizing actions during vulnerability management)」をする為のフレームワークです。<a href="https://insights.sei.cmu.edu/library/prioritizing-vulnerability-response-a-stakeholder-specific-vulnerability-categorization-version-20/">https://insights.sei.cmu.edu/library/prioritizing-vulnerability-response-a-stakeholder-specific-vulnerability-categorization-version-20/</a></span></p>
<p class="footnote"><a href="#fn-e9d66cad" id="f-e9d66cad" name="f-e9d66cad" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">Gunosy では開発メンバー全員でセキュリティを担保する文化となっています。</span></p>
</div>
TksYamaguchi
広告スコアリングサーバのエラーを大幅に減らした話
hatenablog://entry/6801883189052870590
2023-12-20T18:15:00+09:00
2023-12-20T19:42:20+09:00 こんにちは、m-hamashita です。最近 AdsML(DRE 兼務)から新規事業開発室に移って LLM 応用をしています。学ぶことが多くて楽しいですね*1。 この記事は Gunosy Advent Calendar 2023 の 20 日目の記事です。 19 日目の記事は田辺さんのデータアナリストが dbt seed を使って予算テーブルを作った話でした。 先日、広告のスコアリングサーバをフルリプレイスしました。その後、速報やリクエストが多くなるタイミングなどでエラー(タイムアウト)が急増することがありました。 今回はこのエラーの急増を解消した話について紹介したいと思います。 最初に背景…
<p>こんにちは、m-hamashita です。最近 AdsML(DRE 兼務)から新規事業開発室に移って LLM 応用をしています。学ぶことが多くて楽しいですね<a href="#f-cb9d07ae" id="fn-cb9d07ae" name="fn-cb9d07ae" title="一方で難しさも感じています">*1</a>。</p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 20 日目の記事です。 19 日目の記事は田辺さんの<a href="https://data.gunosy.io/entry/dbt_seed">データアナリストが dbt seed を使って予算テーブルを作った話</a>でした。</p>
<p>先日、広告のスコアリングサーバをフルリプレイスしました。その後、速報やリクエストが多くなるタイミングなどでエラー(タイムアウト)が急増することがありました。
今回はこのエラーの急増を解消した話について紹介したいと思います。
最初に背景とその原因を説明し、解決方法について紹介します<a href="#f-f62741b5" id="fn-f62741b5" name="fn-f62741b5" title="本当は色々試したことがあり、紹介したかったのですが時間がありませんでした...">*2</a>。</p>
<p>広告スコアリングサーバのフルリプレイスに関しては以下の記事を参考にしてください<a href="#f-525b1e49" id="fn-525b1e49" name="fn-525b1e49" title="この時からさらにレイテンシ改善をおこなっています">*3</a>。
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Freplace_ads_scoring_server" title="広告のスコアリングサーバをフルリプレイスしました - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/replace_ads_scoring_server">tech.gunosy.io</a></cite>
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Fads-scoring-server-performance-tuning" title="広告スコアリングサーバのレイテンシ改善 - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/ads-scoring-server-performance-tuning">tech.gunosy.io</a></cite></p>
<ul class="table-of-contents">
<li><a href="#背景">背景</a><ul>
<li><a href="#原因">原因</a></li>
</ul>
</li>
<li><a href="#やったこと">やったこと</a><ul>
<li><a href="#Helm-の設定">Helm の設定</a><ul>
<li><a href="#valuesyaml-の設定">values.yaml の設定</a></li>
<li><a href="#Argo-Rollouts-の設定">Argo Rollouts の設定</a></li>
<li><a href="#HPA-の設定">HPA の設定</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#さいごに">さいごに</a></li>
</ul>
<h1 id="背景">背景</h1>
<p>広告配信サーバはその名の通り広告を配信するためのサーバです。広告配信サーバはスコアリングサーバに対してリクエストを送信し、広告のスコアや入札単価を取得しています。</p>
<p>スコアリングサーバのリプレイス後、速報やお昼時などリクエストが多くなるタイミングで大きな負荷がかかり、スコアリングサーバ側でエラー(タイムアウト)が急増することがありました。
Datadog を確認しても全体の平均 CPU 使用率等も高くなく、エラーが発生している原因がすぐにはわかりませんでした。</p>
<h2 id="原因">原因</h2>
<p>結論から言うと、広告配信サーバとスコアリングサーバの Pod 数が AZ ごとに偏っていたため、同一 AZ 間通信による負荷集中が起きていました。</p>
<p><figure class="figure-image figure-image-fotolife" title="気づいたときの投稿"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m-hamashita/20231218/20231218163938.png" width="924" height="416" loading="lazy" title="" class="hatena-fotolife" style="width:360px;height:160px" itemprop="image"></span><figcaption>気づいたときの投稿</figcaption></figure></p>
<p>広告配信サーバからスコアリングサーバへのリクエストは、コストの観点で同一 AZ 内で行われるように設定しています。詳細はこちらの記事を参考にしてください。
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Fistio_locality_load_balancing" title="マイクロサービスにおけるAZ間通信のコスト大幅削減した話 with Istio Locality Load Balancing - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/istio_locality_load_balancing">tech.gunosy.io</a></cite></p>
<p>AZ ごとの Pod 数に偏りがある場合、ある AZ の Pod に負荷が集中するといった問題が発生していました。
その一例を次に示します。</p>
<ul>
<li>広告配信サーバ
<ul>
<li><code>ap-northeast-1a</code> に 4 Pod</li>
<li><code>ap-northeast-1c</code> に 4 Pod</li>
</ul>
</li>
<li>スコアリングサーバ
<ul>
<li><code>ap-northeast-1a</code> に 6 Pod</li>
<li><code>ap-northeast-1c</code> に 1 Pod</li>
</ul>
</li>
</ul>
<p><figure class="figure-image figure-image-fotolife" title="Pod 数に偏りが生じている"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m-hamashita/20231218/20231218172239.png" width="771" height="631" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Pod 数に偏りが生じている</figcaption></figure></p>
<p>広告配信サーバへのリクエストは、均等にリクエストされると仮定します。このとき、先述の通り、広告配信サーバからスコアリングサーバへのリクエストは同一 AZ 内でおこなわれます。</p>
<p>この場合、スコアリングサーバの <code>ap-northeast-1a</code> では 4 Pod 分のリクエストが 6 Pod におこなわれ、<code>ap-northeast-1c</code> では 4 Pod 分のリクエストが 1 Pod におこなわれます。
したがって、スコアリングサーバの <code>ap-northeast-1a</code> では 1 Pod あたり、 2/3 Pod 分のリクエストを捌けば良く、<code>ap-northeast-1c</code> では 1 Pod あたり 4 Pod 分のリクエストを捌かなければいけません。</p>
<p>そのため、<code>ap-northeast-1a</code> ではリクエストが少なく、<code>ap-northeast-1c</code> ではリクエストが多くなり、エラーが発生するといったことが起きていました。
逆に <code>ap-northeast-1a</code> では CPU 使用率が非常に低く、無駄な Pod が起動している状態となっていました。</p>
<p>このように AZ ごとの Pod 数に偏りがあると、同一 AZ 間通信による負荷集中が起きてしまいます。今回はスコアリングサーバの Pod が偏っていましたが、広告配信サーバ側の Pod が偏ることも多々ありました。</p>
<p><figure class="figure-image figure-image-fotolife" title="エラーが増えている様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m-hamashita/20231218/20231218174758.png" width="1936" height="812" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>エラーが増えている様子</figcaption></figure>
<figure class="figure-image figure-image-fotolife" title="レイテンシの悪化"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m-hamashita/20231218/20231218174814.png" width="2326" height="988" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>レイテンシの悪化</figcaption></figure></p>
<p>また、エラーがしばらく継続し、Pod がスケーリングしていないこともわかりました。つまり HPA(Horizontal Pod Autoscaler)がうまく機能していないということです。</p>
<p>HPA は平均 CPU 使用率で Pod 数をスケーリングするように設定しています。したがって、平均 CPU 使用率が閾値を超えていませんでした。</p>
<p>平均 CPU 使用率の計算は、Pod の CPU 使用量を Pod 全体のリクエスト量で割ることでおこなわれます<a href="#f-2e198f5d" id="fn-2e198f5d" name="fn-2e198f5d" title="参考: https://github.com/kubernetes/kubernetes/blob/0dc900cebe079efaf2087adc6fee6eb88d176020/pkg/controller/podautoscaler/metrics/utilization.go#L31-L49">*4</a>。つまり、Pod が少ない AZ で CPU 使用率が高くなったとしても、他の AZ で CPU 使用量が少ない場合、平均 CPU 使用率は低くなります。</p>
<p>そのため設定した閾値に達せず、スケーリングしませんでした<a href="#f-cc9f9866" id="fn-cc9f9866" name="fn-cc9f9866" title="前述のケースで、ap-northeast-1a の各 Pod のリクエスト量が 10, 使用量が 2(平均 CPU 使用率 20%)、ap-northeast-1c の Pod のリクエスト量が 10、使用量が 10 の場合(平均 CPU 使用率 100%)、平均 CPU 使用率は 22/70=0.314... となり平均 CPU 使用率の閾値をかなり下げないとスケーリングしない">*5</a>。</p>
<h1 id="やったこと">やったこと</h1>
<p>広告配信サーバ、スコアリングサーバの Argo Rollouts や HPA などを AZ ごとに設定することで解決しました<a href="#f-e74dc0ba" id="fn-e74dc0ba" name="fn-e74dc0ba" title="今回のサーバでは Canary Release を行うために Argo Rollouts を利用していますが、通常の Deployment でも同様の設定が可能です。 Argo Rollouts を導入したときの話についてはこちらを参照してください">*6</a>。
今までは設定を AZ ごとにわけておらず、 Pod がどの AZ に配置されるかわからない状態でした。
今回この設定にすることで、AZ ごとに Pod 数が均等になり、負荷が集中することがなくなりました。
その結果、エラー数が大幅に減少して、最高の日々を送ることができるようになりました<a href="#f-bd589e79" id="fn-bd589e79" name="fn-bd589e79" title="修正前はよく PagerDuty が鳴っていましたが、設定後鳴ることはなくなりました">*7</a>。</p>
<p><figure class="figure-image figure-image-fotolife" title="エラーがほとんどでなくなった"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m-hamashita/20231220/20231220094344.png" width="1830" height="758" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>エラーがほとんどでなくなった</figcaption></figure></p>
<h2 id="Helm-の設定">Helm の設定</h2>
<p>次に具体的な設定について説明します。
AZ ごとに Argo Rollouts や HPA を設定するために、Helm の設定を変更しました。
広告配信サーバとスコアリングサーバの両方で設定をおこないましたが、設定方法はほぼ同じなため、広告配信サーバの設定を例に説明します。</p>
<h3 id="valuesyaml-の設定">values.yaml の設定</h3>
<p>もともとの values.yaml の Node affinity には、以下のように AZ ごとの設定はありませんでした。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">affinity</span><span class="synSpecial">:</span>
<span class="synIdentifier">nodeAffinity</span><span class="synSpecial">:</span>
<span class="synIdentifier">requiredDuringSchedulingIgnoredDuringExecution</span><span class="synSpecial">:</span>
<span class="synIdentifier">nodeSelectorTerms</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">matchExpressions</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">key</span><span class="synSpecial">:</span> node_type
<span class="synIdentifier">operator</span><span class="synSpecial">:</span> In
<span class="synIdentifier">values</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>ad_server
</pre>
<p>これに対して、 AZ ごとに設定を追加します。今回は <code>ap-northeast-1a</code>, <code>ap-northeast-1c</code> の 2 つの AZ に Pod を配置するようにします。AZ ごとに設定が異なるため、<code>affinities</code> という配列に各 AZ の設定をおこなっており、 <code>topology.kubernetes.io/zone</code> で AZ を指定することで、AZ ごとに Pod を配置するようにしています<a href="#f-7efe065c" id="fn-7efe065c" name="fn-7efe065c" title="参考: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity">*8</a>。
<code>az_suffix</code> はラベルやリソース名に付与するために使われます。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synComment"># values.yaml</span>
<span class="synIdentifier">affinities</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">affinity</span><span class="synSpecial">:</span>
<span class="synIdentifier">nodeAffinity</span><span class="synSpecial">:</span>
<span class="synIdentifier">requiredDuringSchedulingIgnoredDuringExecution</span><span class="synSpecial">:</span>
<span class="synIdentifier">nodeSelectorTerms</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">matchExpressions</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">key</span><span class="synSpecial">:</span> node_type
<span class="synIdentifier">operator</span><span class="synSpecial">:</span> In
<span class="synIdentifier">values</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>ad_server
<span class="synStatement">- </span><span class="synIdentifier">key</span><span class="synSpecial">:</span> topology.kubernetes.io/zone
<span class="synIdentifier">operator</span><span class="synSpecial">:</span> In
<span class="synIdentifier">values</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>ap-northeast-1a
<span class="synIdentifier">az_suffix</span><span class="synSpecial">:</span> 1a
<span class="synStatement">- </span><span class="synIdentifier">affinity</span><span class="synSpecial">:</span>
<span class="synIdentifier">nodeAffinity</span><span class="synSpecial">:</span>
<span class="synIdentifier">requiredDuringSchedulingIgnoredDuringExecution</span><span class="synSpecial">:</span>
<span class="synIdentifier">nodeSelectorTerms</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">matchExpressions</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">key</span><span class="synSpecial">:</span> node_type
<span class="synIdentifier">operator</span><span class="synSpecial">:</span> In
<span class="synIdentifier">values</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>ad_server
<span class="synStatement">- </span><span class="synIdentifier">key</span><span class="synSpecial">:</span> topology.kubernetes.io/zone
<span class="synIdentifier">operator</span><span class="synSpecial">:</span> In
<span class="synIdentifier">values</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>ap-northeast-1c
<span class="synIdentifier">az_suffix</span><span class="synSpecial">:</span> 1c
</pre>
<h3 id="Argo-Rollouts-の設定">Argo Rollouts の設定</h3>
<p>先述の values.yaml を用いて、Argo Rollouts の設定をおこないます。
もともとの Argo Rollouts の設定と異なる点は、Node Affinity の設定とラベルに <code>az_suffix</code> を追加することで区別しているという点です。
ループを使うとスコープが変わってしまうため、 <code>.</code> ではなく、 <code>$</code> を使って値を参照する必要があることに注意してください。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synSpecial">{{</span> range $i, <span class="synIdentifier">$affinity</span><span class="synSpecial"> :</span>= .Values.affinities <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- if ne $i <span class="synConstant">0</span> <span class="synSpecial">}}</span>
<span class="synPreProc">---</span>
<span class="synSpecial">{{</span>- end <span class="synSpecial">}}</span>
<span class="synIdentifier">apiVersion</span><span class="synSpecial">:</span> argoproj.io/v1alpha1
<span class="synIdentifier">kind</span><span class="synSpecial">:</span> Rollout
<span class="synIdentifier">metadata</span><span class="synSpecial">:</span>
<span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> include <span class="synConstant">"ad-server.fullname"</span> $ <span class="synSpecial">}}</span>-{{ $affinity.az_suffix }}
<span class="synIdentifier">labels</span><span class="synSpecial">:</span>
<span class="synSpecial">{{</span>- include <span class="synConstant">"ad-server.labels"</span> $ | nindent <span class="synConstant">4</span> <span class="synSpecial">}}</span>
<span class="synIdentifier">az</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> $affinity.az_suffix <span class="synSpecial">}}</span>
<span class="synIdentifier">spec</span><span class="synSpecial">:</span>
<span class="synSpecial">{{</span>- with $.Values.argoStrategy <span class="synSpecial">}}</span>
<span class="synIdentifier">strategy</span><span class="synSpecial">:</span>
<span class="synSpecial">{{</span>- toYaml . | nindent <span class="synConstant">4</span> <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- end <span class="synSpecial">}}</span>
<span class="synIdentifier">selector</span><span class="synSpecial">:</span>
<span class="synIdentifier">matchLabels</span><span class="synSpecial">:</span>
<span class="synSpecial">{{</span>- include <span class="synConstant">"ad-server.labels"</span> $ | nindent <span class="synConstant">6</span> <span class="synSpecial">}}</span>
<span class="synIdentifier">az</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> $affinity.az_suffix <span class="synSpecial">}}</span>
<span class="synIdentifier">revisionHistoryLimit</span><span class="synSpecial">:</span> <span class="synConstant">3</span>
<span class="synIdentifier">template</span><span class="synSpecial">:</span>
<span class="synIdentifier">metadata</span><span class="synSpecial">:</span>
<span class="synIdentifier">labels</span><span class="synSpecial">:</span>
<span class="synSpecial">{{</span>- include <span class="synConstant">"ad-server.labels"</span> $ | nindent <span class="synConstant">8</span> <span class="synSpecial">}}</span>
<span class="synIdentifier">az</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> $affinity.az_suffix <span class="synSpecial">}}</span>
<span class="synIdentifier">annotations</span><span class="synSpecial">:</span>
<span class="synSpecial">{{</span>- toYaml $.Values.podAnnotations | nindent <span class="synConstant">8</span> <span class="synSpecial">}}</span>
<span class="synIdentifier">spec</span><span class="synSpecial">:</span>
<span class="synIdentifier">serviceAccountName</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> include <span class="synConstant">"ad-server.serviceAccountName"</span> $ <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- with $affinity.affinity <span class="synSpecial">}}</span>
<span class="synIdentifier">affinity</span><span class="synSpecial">:</span>
<span class="synSpecial">{{</span>- toYaml . | nindent <span class="synConstant">8</span> <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- end <span class="synSpecial">}}</span>
<span class="synIdentifier">containers</span><span class="synSpecial">:</span>
<span class="synComment"> # container の設定</span>
<span class="synSpecial">{{</span>- end <span class="synSpecial">}}</span>
</pre>
<h3 id="HPA-の設定">HPA の設定</h3>
<p>Argo Rollouts と同様に、HPA の設定も AZ ごとに設定します。
HPA のターゲットとして、(AZ ごとに作成された)Argo Rollouts のリソース名を指定しています。これによって AZ ごとにスケールすることができました。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synSpecial">{{</span>- if .Values.autoscaling.enabled <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span> range $i, <span class="synIdentifier">$affinity</span><span class="synSpecial"> :</span>= .Values.affinities <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- if ne $i <span class="synConstant">0</span> <span class="synSpecial">}}</span>
<span class="synPreProc">---</span>
<span class="synSpecial">{{</span>- end <span class="synSpecial">}}</span>
<span class="synIdentifier">apiVersion</span><span class="synSpecial">:</span> autoscaling/v2
<span class="synIdentifier">kind</span><span class="synSpecial">:</span> HorizontalPodAutoscaler
<span class="synIdentifier">metadata</span><span class="synSpecial">:</span>
<span class="synIdentifier">name</span><span class="synSpecial">:</span> ad-server-rollout-{{ $affinity.az_suffix }}
<span class="synIdentifier">labels</span><span class="synSpecial">:</span>
<span class="synSpecial">{{</span>- include <span class="synConstant">"ad-server.labels"</span> $ | nindent <span class="synConstant">4</span> <span class="synSpecial">}}</span>
<span class="synIdentifier">az</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> $affinity.az_suffix <span class="synSpecial">}}</span>
<span class="synIdentifier">spec</span><span class="synSpecial">:</span>
<span class="synIdentifier">scaleTargetRef</span><span class="synSpecial">:</span>
<span class="synIdentifier">apiVersion</span><span class="synSpecial">:</span> argoproj.io/v1alpha1
<span class="synIdentifier">kind</span><span class="synSpecial">:</span> Rollout
<span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> include <span class="synConstant">"ad-server.fullname"</span> $ <span class="synSpecial">}}</span>-{{ $affinity.az_suffix }}
<span class="synIdentifier">minReplicas</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> $.Values.autoscaling.minReplicas <span class="synSpecial">}}</span>
<span class="synIdentifier">maxReplicas</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> $.Values.autoscaling.maxReplicas <span class="synSpecial">}}</span>
<span class="synIdentifier">metrics</span><span class="synSpecial">:</span>
<span class="synSpecial">{{</span>- if $.Values.autoscaling.targetCPUUtilizationPercentage <span class="synSpecial">}}</span>
<span class="synStatement">- </span><span class="synIdentifier">type</span><span class="synSpecial">:</span> Resource
<span class="synIdentifier">resource</span><span class="synSpecial">:</span>
<span class="synIdentifier">name</span><span class="synSpecial">:</span> cpu
<span class="synIdentifier">target</span><span class="synSpecial">:</span>
<span class="synIdentifier">type</span><span class="synSpecial">:</span> Utilization
<span class="synIdentifier">averageUtilization</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> $.Values.autoscaling.targetCPUUtilizationPercentage <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- end <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- if $.Values.autoscaling.targetMemoryUtilizationPercentage <span class="synSpecial">}}</span>
<span class="synStatement">- </span><span class="synIdentifier">type</span><span class="synSpecial">:</span> Resource
<span class="synIdentifier">resource</span><span class="synSpecial">:</span>
<span class="synIdentifier">name</span><span class="synSpecial">:</span> memory
<span class="synIdentifier">targetAverageUtilization</span><span class="synSpecial">:</span> <span class="synSpecial">{{</span> $.Values.autoscaling.targetMemoryUtilizationPercentage <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- end <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- end <span class="synSpecial">}}</span>
<span class="synSpecial">{{</span>- end <span class="synSpecial">}}</span>
</pre>
<h1 id="さいごに">さいごに</h1>
<p>今回は広告配信サーバとスコアリングサーバの Argo Rollouts や HPA を AZ ごとに設定することで、同一 AZ 間通信による負荷集中を解消しました。
力技ではありますが、解消することができてよかったです。もしスマートな解決方法があれば教えてほしいです。</p>
<p>明日は TksYamaguchi さんが Snyk を導入してコンテナセキュリティ対策の運用を回している話について書くそうです!
Snyk 便利ですよね。とても楽しみです!</p>
<div class="footnote">
<p class="footnote"><a href="#fn-cb9d07ae" id="f-cb9d07ae" name="f-cb9d07ae" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">一方で難しさも感じています</span></p>
<p class="footnote"><a href="#fn-f62741b5" id="f-f62741b5" name="f-f62741b5" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">本当は色々試したことがあり、紹介したかったのですが時間がありませんでした...</span></p>
<p class="footnote"><a href="#fn-525b1e49" id="f-525b1e49" name="f-525b1e49" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">この時からさらにレイテンシ改善をおこなっています</span></p>
<p class="footnote"><a href="#fn-2e198f5d" id="f-2e198f5d" name="f-2e198f5d" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">参考: <a href="https://github.com/kubernetes/kubernetes/blob/0dc900cebe079efaf2087adc6fee6eb88d176020/pkg/controller/podautoscaler/metrics/utilization.go#L31-L49">https://github.com/kubernetes/kubernetes/blob/0dc900cebe079efaf2087adc6fee6eb88d176020/pkg/controller/podautoscaler/metrics/utilization.go#L31-L49</a></span></p>
<p class="footnote"><a href="#fn-cc9f9866" id="f-cc9f9866" name="f-cc9f9866" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text">前述のケースで、ap-northeast-1a の各 Pod のリクエスト量が 10, 使用量が 2(平均 CPU 使用率 20%)、ap-northeast-1c の Pod のリクエスト量が 10、使用量が 10 の場合(平均 CPU 使用率 100%)、平均 CPU 使用率は 22/70=0.314... となり平均 CPU 使用率の閾値をかなり下げないとスケーリングしない</span></p>
<p class="footnote"><a href="#fn-e74dc0ba" id="f-e74dc0ba" name="f-e74dc0ba" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text">今回のサーバでは Canary Release を行うために Argo Rollouts を利用していますが、通常の Deployment でも同様の設定が可能です。 Argo Rollouts を導入したときの話については<a href="https://tech.gunosy.io/entry/argo-rollouts">こちら</a>を参照してください</span></p>
<p class="footnote"><a href="#fn-bd589e79" id="f-bd589e79" name="f-bd589e79" class="footnote-number">*7</a><span class="footnote-delimiter">:</span><span class="footnote-text">修正前はよく PagerDuty が鳴っていましたが、設定後鳴ることはなくなりました</span></p>
<p class="footnote"><a href="#fn-7efe065c" id="f-7efe065c" name="f-7efe065c" class="footnote-number">*8</a><span class="footnote-delimiter">:</span><span class="footnote-text">参考: <a href="https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity">https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity</a></span></p>
</div>
m-hamashita
コスト削減できる箇所をひねり出す
hatenablog://entry/6801883189065960292
2023-12-18T09:30:00+09:00
2023-12-18T10:55:01+09:00 こんにちはUTです。 この記事は Gunosy Advent Calendar 2023 の 18 日目の記事です。 17 日目の記事は k.oshiro さんの 社内勉強会での発表内容の紹介 〜MessagePackの仕様を読む〜 - Gunosyデータ分析ブログ でした。 最近広告チームからLLMのチームに異動したのですが、今回紹介する話は広告チームでの話になります。 今年は円安だったのでクラウドインフラコストを削減するというネタをいくつかアドベントカレンダーで流れてくるのを見ました。 例にもれず、自分のチームでもコスト削減をしようという話があがりました。 しかし、以下で記事を記載した通り…
<p>こんにちは<a href="https://x.com/mocyuto">UT</a>です。
この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 18 日目の記事です。
17 日目の記事は k.oshiro さんの <a href="https://data.gunosy.io/entry/read_messagepack_specification">社内勉強会での発表内容の紹介 〜MessagePackの仕様を読む〜 - Gunosyデータ分析ブログ</a> でした。</p>
<p>最近広告チームからLLMのチームに異動したのですが、今回紹介する話は広告チームでの話になります。</p>
<p>今年は円安だったのでクラウドインフラコストを削減するというネタをいくつかアドベントカレンダーで流れてくるのを見ました。
例にもれず、自分のチームでもコスト削減をしようという話があがりました。</p>
<p>しかし、以下で記事を記載した通り大きなコスト削減はすでに実施しており、大幅な削減は見込めない状況でした。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Fs3_costcut" title="S3のコストを大幅に削減した話 - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/s3_costcut">tech.gunosy.io</a></cite></p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Fistio_locality_load_balancing" title="マイクロサービスにおけるAZ間通信のコスト大幅削減した話 with Istio Locality Load Balancing - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/istio_locality_load_balancing">tech.gunosy.io</a></cite></p>
<p>そこでコスト削減をする箇所をひねり出す必要があり、今回はその際に実行したことを紹介しようと思います。</p>
<ul class="table-of-contents">
<li><a href="#やったこと">やったこと</a><ul>
<li><a href="#コスト削減のフォーマット化">コスト削減のフォーマット化</a></li>
<li><a href="#コストエクスプローラ">コストエクスプローラ</a></li>
<li><a href="#SREチームとの相談">SREチームとの相談</a></li>
<li><a href="#削減例">削減例</a><ul>
<li><a href="#1-S3の不要なログの削除">1. S3の不要なログの削除</a></li>
<li><a href="#2-k8sのpod数の削減">2. k8sのpod数の削減</a></li>
<li><a href="#3-各種スナップショットを削除">3. 各種スナップショットを削除</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h2 id="やったこと">やったこと</h2>
<p>コスト削減は現在進行系で進めていますが、今回は何を実施したかではなくどのように進めているかに焦点を当てて紹介していきたいと思います。</p>
<h3 id="コスト削減のフォーマット化">コスト削減のフォーマット化</h3>
<p>大きな削減対象があれば、それ一つに取り掛かることで削減が期待でき成果もしっかり出るので、そのタスクに集中すればよいかなと思います。
しかし、ある程度大きなコスト削減をしてしまうと、あとは残った小規模なものを実施していかなければなりません。</p>
<p>そこで、以下のようにスプレッドシートを作成し、チーム全員で思いつく限り削減案を書いてもらいました。</p>
<p><figure class="figure-image figure-image-fotolife" title="スプレッドシートの例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yuutookun/20231214/20231214115913.png" width="1200" height="452" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>スプレッドシートの例</figcaption></figure></p>
<p>スプレッドシートを図にあるようなカラム構成にしました。</p>
<ul>
<li>タイトル</li>
<li>起票者</li>
<li>概要</li>
<li>削減見込み:実施することによる削減費用の見込み</li>
<li>工数:細かい工数を出すのは大変なので、ざっくり</li>
<li>実際に発生する作業:ざっくりどんなことをするか想像ができる程度に</li>
<li>優先度:実際の優先度</li>
<li>担当者:担当者</li>
<li>削減結果:削減結果</li>
</ul>
<p>これらを書き出す理由は、どれに取り組めば一番コスパよく削減できるかを可視化するためです。
細かい簡単な改善のタスクはたくさん出すことはできますが、実際時間は有限なので何を実施して何を実施しないかを決める必要があります。</p>
<h3 id="コストエクスプローラ">コストエクスプローラ</h3>
<p>AWSであれば、コストエクスプローラとにらめっこするのが大きな削減を見つける方法の一つになります。</p>
<p><figure class="figure-image figure-image-fotolife" title="コストエクスプローラとにらめっこ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yuutookun/20231214/20231214120340.png" width="971" height="498" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>コストエクスプローラとにらめっこ</figcaption></figure></p>
<p>AWSのサービスごと、タグごとなど色々なグルーピングからコストが大きくかかっているところを見て削減幅は無いかをドリルダウンしていきます。</p>
<p>担当していたチームは広告だったので、特にS3のコストが依然として大きく、ここから探していくことになりました。</p>
<h3 id="SREチームとの相談">SREチームとの相談</h3>
<p>弊社では基本的にプロダクトチームでインフラからアプリケーションまで面倒を見ているので、コスト削減に関してもチーム主導で動いています。</p>
<p>弊社のSREチームは、セキュリティやクラウドインフラのベストプラクティスなどを検討しているチームとなります。
いろんなチームのクラウドインフラを見ているので、細かいプロダクトのドメインはわからなくても、アイディアは色々持っています。</p>
<p>そこで、プロダクトチーム内のブレストとは別に、SREチームともコスト削減のブレストを行いました。
結果、自分たちが完全に見落としていた削減案が提案され、別視点での提案の重要性を感じることができました。</p>
<h3 id="削減例">削減例</h3>
<h4 id="1-S3の不要なログの削除">1. S3の不要なログの削除</h4>
<p>とりあえずで取っていたログなど、特に使っていなかったものを削除しました。
また全期間保存する必要が無いログに関して、lifecycleを設定しました。</p>
<h4 id="2-k8sのpod数の削減">2. k8sのpod数の削減</h4>
<p>直近以下のようなAPIサーバーの安定性に対して修正を行い、少ないpod数でさばけるようになっていたのですが、
pod数を減らしていなかったので、段階的に削減しました。
さらにノードのサイズも小さくしていき、クラスタ全体をスリム化しました。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Fads-scoring-server-performance-tuning" title="広告スコアリングサーバのレイテンシ改善 - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/ads-scoring-server-performance-tuning">tech.gunosy.io</a></cite></p>
<h4 id="3-各種スナップショットを削除">3. 各種スナップショットを削除</h4>
<p>RDS,EBS,Redshiftなどなどスナップショットは意外に使い終わったあとも消さずに放置しがちです。
特にRDSやRedshiftなどデータ容量が多いもののスナップショットはそれだけで結構コストがかさみます。</p>
<p>今回そこまで削減幅はありませんでしたが、時間対比では重要な削減でした。</p>
<h2 id="まとめ">まとめ</h2>
<p>今回はすでに大幅なコスト削減を実施済みの状態から、更なるコスト削減をどうひねり出したかを紹介しました。
削減例で記載しているものは当たり前のものですが、どうやって削減案を出していくかのところに焦点をおいて記事を作成しました。</p>
<p>うまく複数人で考えられるよう仕組み化して効率的に仕事を回す方法を引き続き模索していこうと思います。</p>
<p>読者のみなさんの参考になれば幸いです。</p>
yuutookun
サードパーティ Cookie を使わない広告効果計測 ~Privacy Sandbox の Attribution Reporting API について~
hatenablog://entry/6801883189063192464
2023-12-16T10:00:00+09:00
2024-01-11T21:23:06+09:00 Gunosy Ads では、サードパーティ Cookie に依存しない広告効果の計測への対応を進めています。
その一環として、Google の Privacy Sandbox についても検証を行なっているので、その話について書ければと思います。
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 16 日目の記事です。
昨日の記事は吉岡さんの「<a href="https://tech.gunosy.io/entry/swift_strict_concurreycy">iOSアプリのSWIFT_STRICT_CONCURRENCYをcompleteにした</a>」でした。</p>
<p>プロダクト開発部 Ads チームの yamayu です。
最近は専ら蛮カラな街でインクを塗り塗りしています。
持ち武器のブラスター種に数年ぶりの強化が入り熱いです。</p>
<p>本題ですが、Gunosy Ads では、サードパーティ Cookie に依存しない広告効果の計測への対応を進めています。
その一環として、Google の Privacy Sandbox についても検証を行なっているので、その話について書ければと思います。</p>
<center><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamaYu/20231204/20231204175224.png" width="1200" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:400px" itemprop="image"></span></center>
<ul class="table-of-contents">
<li><a href="#サードパーティ-Cookie-が廃止されるとどうなるのか">サードパーティ Cookie が廃止されるとどうなるのか</a></li>
<li><a href="#サードパーティ-Cookie-を使わずに広告効果を計測する">サードパーティ Cookie を使わずに広告効果を計測する</a><ul>
<li><a href="#Privacy-Sandbox-とは">Privacy Sandbox とは</a></li>
<li><a href="#Attribution-Reporting-API-とは">Attribution Reporting API とは</a></li>
<li><a href="#Attribution-Reporting-API-による広告効果の計測">Attribution Reporting API による広告効果の計測</a><ul>
<li><a href="#1-アトリビューションソースの登録">(1) アトリビューションソースの登録</a></li>
<li><a href="#2-アトリビューショントリガーの登録">(2) アトリビューショントリガーの登録</a></li>
<li><a href="#3-レポートの送信">(3) レポートの送信</a></li>
</ul>
</li>
<li><a href="#参考文献">参考文献</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h2 id="サードパーティ-Cookie-が廃止されるとどうなるのか">サードパーティ Cookie が廃止されるとどうなるのか</h2>
<p>サードパーティ Cookie とは、ユーザーが訪問している Web サイトとは異なるドメインから発行される Cookie のことを指します。
近年はトラッキングに対するプライバシー保護の観点からサードパーティ Cookie の利用を制限する動きがあります。
すでに Safari や Firefox ではデフォルトでサードパーティ Cookie はブロックされており、<a href="https://developer.chrome.com/ja/docs/privacy-sandbox/third-party-cookie-phase-out/">Chrome でも 2024 年の中頃から段階的に廃止する</a>と発表されています。</p>
<p>広告主やアドテク事業者の視点では、出稿中の広告が商品の購入や会員登録など(コンバージョン)にどの程度貢献しているかがわかることが重要です。
もっと具体的に言うと、広告をクリックしたユーザーが広告主のサイトのコンバージョンのページに到達したかどうかを計測できる必要があります。
その計測にサードパーティ Cookie を用いている場合、廃止により計測ができなくなる可能性があります。</p>
<p>サードパーティ Cookie を用いて広告のクリックとコンバージョンを関連付けるケースを下図に示します。</p>
<p><figure class="figure-image figure-image-fotolife" title="広告効果計測の流れ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamaYu/20231204/20231204212631.png" width="1200" height="925" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>広告効果計測のフロー</figcaption></figure></p>
<p>ユーザーが閲覧しているメディアに広告が掲載されている状況を考えます。
ユーザーが広告をクリックすると、アドテク事業者を経由して広告主のランディングページにリダイレクトします。
このときアドテク事業者は adtech.com の Cookie として、ユーザーのブラウザに広告のクリックを識別するための ID(クリック ID)を保存します。</p>
<p>ユーザーが商品を購入(コンバージョン)すると、広告主の購入完了ページに設置してあるコンバージョンタグ(img タグ)が発火します。
このとき adtech.com の Cookie に保存しておいたクリック ID がアドテク事業者に送信されるので、アドテク事業者は広告のクリックとコンバージョンを紐付けることができます。
ここで参照する adtech.com の Cookie は advertiser.com 視点でサードパーティ Cookie となります。</p>
<p>このケースにおいては、サードパーティ Cookie が廃止されると広告のクリックとコンバージョンを紐づけ付けられなくなるため広告効果の計測が難しくなります。</p>
<h2 id="サードパーティ-Cookie-を使わずに広告効果を計測する">サードパーティ Cookie を使わずに広告効果を計測する</h2>
<p>前述の通り、サードパーティ Cookie が廃止されると広告効果の計測ができなくなるケースがあるので、サードパーティ Cookie を使わずに広告効果を計測する手段が必要になります。</p>
<p>ファーストパーティ Cookie やクエリパラメータなど用いた計測手法もありますが、ここでは Privacy Sandbox の Attribution Reporting API を用いた計測手法について説明します。
なお、Privacy Sandbox は現在開発中の技術であるため今後も仕様が変更される可能性があります。
この記事の説明は執筆時点(2023/12 時点)での内容になります。</p>
<h3 id="Privacy-Sandbox-とは">Privacy Sandbox とは</h3>
<p><a href="https://developers.google.com/privacy-sandbox/overview">Privacy Sandbox</a> は Google が推進しているサードパーティ Cookie やその他のトラッキングメカニズムを使用せずにクロスサイトのユースケースを満たすための一連の提案です。
その提案の一つである <a href="https://developers.google.com/privacy-sandbox/relevance/attribution-reporting">Attribution Reporting API</a> を使えば、サードパーティ Cookie を使わずに広告効果を計測することできます。</p>
<h3 id="Attribution-Reporting-API-とは">Attribution Reporting API とは</h3>
<p>Attribution Reporting API を使用すると、ユーザーのブラウザ(Chrome)を利用して広告の表示やクリックと広告主サイトでのコンバージョンを関連付けることができます。
アドテク事業者が広告効果について取得できる内容として、イベントレベルレポートと要約レポートの 2 種類のレポートが提供されています。</p>
<p>イベントレベルレポートでは、広告の表示やクリックに対し、個々のコンバージョンの発生単位でレポートを取得できます。
但し、レポートに含めることのできる情報量 (購入金額など) は制限されており、また一定の確率でノイズを含みます。</p>
<p>要約レポートでは、一定期間内の広告の表示やクリックに対し、コンバージョンの発生数や購入金額などを取得できます。
イベントレポートよりも詳細な情報を含むことができますが、得られるのは集計値のみになります。</p>
<h3 id="Attribution-Reporting-API-による広告効果の計測">Attribution Reporting API による広告効果の計測</h3>
<p>Attribution Reporting API を用いた広告効果の計測方法をイベントレベルレポートを例に説明します。
要約レポートについては今回の記事では割愛しますが、大まかにはイベントレベルレポートを拡張したような仕様になっています。
まず全体の流れを下図に示します。</p>
<p><figure class="figure-image figure-image-fotolife" title="Attribution Reporting API のフロー"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamaYu/20231210/20231210205301.png" width="1160" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Attribution Reporting API のフロー</figcaption></figure></p>
<p>基本的なフローは同じですが、サードパーティ Cookie を利用してクリック ID を送信する代わりに次の 3 つのステップが追加されています。</p>
<ul>
<li>(1) アトリビューションソースの登録</li>
<li>(2) アトリビューショントリガーの登録</li>
<li>(3) レポート送信</li>
</ul>
<p>アドテク事業者は、これらのリクエストに対応するエンドポイントを実装する必要があります。
以下でそれぞれみていきます。</p>
<h4 id="1-アトリビューションソースの登録">(1) アトリビューションソースの登録</h4>
<p>アトリビューションソースの登録では、コンバージョンに紐付ける対象となる広告の表示やクリックといったイベントをアトリビューションソースとして登録するようにブラウザに指示します。
広告のクリックをアトリビューションソースとして登録する場合は、次のように広告の a タグに attributionsrc 属性としてアトリビューションソース登録用のエンドポイントを指定します。</p>
<pre class="code lang-html" data-lang="html" data-unlink><span class="synIdentifier"><</span><span class="synStatement">a</span><span class="synIdentifier"> </span><span class="synType">href</span><span class="synIdentifier">=</span><span class="synConstant">"https://advertiser.com/landing_page"</span><span class="synIdentifier"> attributionsrc=</span><span class="synConstant">"https://adtech.com/attribution_source?my_ad_id=1234567890"</span><span class="synIdentifier">></span>
</pre>
<p>広告がクリックされると <code>attributionsrc</code> 属性に指定したエンドポイントにリクエストが飛びます。
このエンドポイントでは、アトリビューションソースの登録に必要な情報を HTTP ヘッダーに付与してレスポンスを返します。</p>
<pre class="code" data-lang="" data-unlink># response
HTTP/1.1 200 OK
Attribution-Reporing-Register-Source: {"destination": "https://advertiser.com", "source_event_id": "1234567890", ...}</pre>
<p><code>destination</code> にはコンバージョンが発生するサイトのオリジンを指定します。
<code>source_event_id</code> は広告の表示やクリックなどのイベントを識別するための ID です。
前述のサードパーティ Cookie を利用するケースにおけるクリック ID に相当します。</p>
<h4 id="2-アトリビューショントリガーの登録">(2) アトリビューショントリガーの登録</h4>
<p>アトリビューショントリガーの登録では、コンバージョンの発生をアトリビューショントリガーとして登録するようにブラウザに指示します。
コンバージョン地点に img タグを設置して、attributionsrc 属性にアトリビューショントリガー登録用のエンドポイントを指定します。</p>
<pre class="code lang-html" data-lang="html" data-unlink><span class="synIdentifier"><</span><span class="synStatement">img</span><span class="synIdentifier"> </span><span class="synType">src</span><span class="synIdentifier">=</span><span class="synConstant">"https://adtech.com/conversion_pixel"</span><span class="synIdentifier"> attributionsrc=</span><span class="synConstant">"https://adtech.com/attribution_trigger?purchase=1"</span><span class="synIdentifier">></span>
</pre>
<p>コンバージョンが発生すると、<code>attributionsrc</code> 属性に指定したエンドポイントにリクエストが飛びます。
このエンドポイントでは、アトリビューショントリガーの登録に必要な情報を HTTP ヘッダーに付与してレスポンスを返します。</p>
<pre class="code" data-lang="" data-unlink># response
HTTP/1.1 200 OK
Attribution-Reporing-Register-Trigger: {"event_trigger_data": [{"trigger_data": "1", ...}]}</pre>
<p><code>trigger_data</code> は、商品の購入や会員登録などのコンバージョンの種類を識別するための値です。
既定の bit 数の範囲内で自由に定義できるため、ここでは仮に商品の購入を表す番号として <code>1</code> を指定しています。</p>
<h4 id="3-レポートの送信">(3) レポートの送信</h4>
<p>プライバシー保護のため、コンバージョンが発生しても直ちにアドテク事業者にレポートが送信されるわけではありません。
ランダムな遅延の後にレポートが送信されます。</p>
<pre class="code" data-lang="" data-unlink># request
POST https://adtech.com/.well-known/attribution-reporting/report-event-attribution HTTP/1.1
Content-Type: application/json
{
"source_event_id": "1234567890",
"trigger_data": "1"
︙
}</pre>
<p>レポートの内容には、アトリビューションソースの登録時にブラウザに保存された <code>source_event_id</code> とアトリビューショントリガーの登録時に保存された <code>trigger_data</code> を含みます。
これにより、アドテク事業者は広告のクリックとコンバージョンを紐付けることができました。</p>
<h3 id="参考文献">参考文献</h3>
<ul>
<li><a href="https://developers.google.com/privacy-sandbox/overview">Privacy Sandbox</a></li>
<li><a href="https://developers.google.com/privacy-sandbox/relevance/attribution-reporting">Attribution Reporting</a></li>
<li><a href="https://github.com/WICG/attribution-reporting-api">WICG/attribution-reporting-api</a></li>
</ul>
<h2 id="まとめ">まとめ</h2>
<p>この記事では、サードパーティ Cookie の廃止による広告効果の計測への影響と、Privacy Sandbox の Attribution Reporting API を用いたサードパーティ Cookie に依存にしない広告効果の計測について書きました。</p>
<p>今後も Attribution Reporting API や Privacy Sandbox の他の API についての検証を進めていく予定です。
いずれ機会がありましたらまた記事にできればと思います。</p>
<p>次回は、k.oshiro さんの「社内勉強会での発表内容の紹介 〜MessagePackの仕様を読む〜」です。お楽しみに!</p>
yamaYu
iOSアプリのSWIFT_STRICT_CONCURRENCYをcompleteにした
hatenablog://entry/6801883189064903032
2023-12-15T11:45:00+09:00
2024-03-19T17:22:28+09:00 iOSアプリでSwift Concurrencyを全面的に導入しSWIFT_STRICT_CONCURRENCYをcompleteにした話
<p>こんにちは。iOSアプリを開発している吉岡(rikusouda)です。</p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 15 日目の記事です。昨日の記事はfujishiroさんの「<a href="https://tech.gunosy.io/entry/tfaction_ci_cd">tfaction を導入したら便利だった話</a>」でした。</p>
<p>業務で開発している「auサービスToday」のiOSアプリでSwift Concurrencyを全面的に導入し、<a href="https://developer.apple.com/documentation/xcode/build-settings-reference#Strict-Concurrency-Checking">SWIFT_STRICT_CONCURRENCY</a>をcompleteにすることができたのでその内容について紹介します。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rikusouda/20231208/20231208174435.jpg" width="640" height="427" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<ul class="table-of-contents">
<li><a href="#概要">概要</a></li>
<li><a href="#いわゆるUseCaseやRepositoryをactor化">いわゆるUseCaseやRepositoryをactor化</a></li>
<li><a href="#DispatchQueueasyncによる非同期処理を廃止">DispatchQueue.asyncによる非同期処理を廃止</a><ul>
<li><a href="#単発処理で完了待ちが必要なケース">単発処理で完了待ちが必要なケース</a></li>
<li><a href="#asyncAfterで遅延実行するケース">asyncAfterで遅延実行するケース</a></li>
<li><a href="#複数の処理を同じスレッドで処理させたいとき">複数の処理を同じスレッドで処理させたいとき</a></li>
</ul>
</li>
<li><a href="#nonisolated-が要求されるDelegateメソッドの対応">nonisolated が要求されるDelegateメソッドの対応</a></li>
<li><a href="#Sendable対応">Sendable対応</a></li>
<li><a href="#Sendableにできないデータを受け渡したいケース">Sendableにできないデータを受け渡したいケース</a></li>
<li><a href="#AnyPublisherを受け渡したいケース">AnyPublisherを受け渡したいケース</a><ul>
<li><a href="#AsyncPublisherを使わなかった理由">AsyncPublisherを使わなかった理由</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h3 id="概要">概要</h3>
<p>以前の記事でSwift Concurrencyを部分導入したことを紹介しました。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Fios_async_await" title="Swift Concurrencyのasync/awaitを既存iOSアプリに部分的に導入した - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/ios_async_await">tech.gunosy.io</a></cite></p>
<p>この時点ではAPI呼び出し部分に限って導入していました。</p>
<p>前回から今までに下記のようなことをしました。</p>
<ul>
<li>いわゆるUseCaseやRepositoryをactor化</li>
<li><a href="https://developer.apple.com/documentation/dispatch/dispatchqueue/2016103-async">DispatchQueue.async</a>による非同期処理を廃止</li>
<li>nonisolated が要求されるDelegateメソッドの対応</li>
<li>Sendable対応</li>
</ul>
<h3 id="いわゆるUseCaseやRepositoryをactor化">いわゆるUseCaseやRepositoryをactor化</h3>
<p>auサービスTodayは、クリーンアーキテクチャで言われるようなUseCaseやRepositoryを使う設計をしています。これらのモジュールは画面表示に依存しない(UIKitやSwiftUIをimportしない)ので、MainActorにせずにactorにすることで手軽にメインスレッド以外で処理ができると考えました。</p>
<p>その結果下記のような問題が発生しました。</p>
<ul>
<li>ViewModel、UseCase、Repository間ではSendableなデータ以外は受け渡しができない
<ul>
<li>すべてのデータをSendableに対応する必要がある</li>
</ul>
</li>
<li>ViewModelからUseCaseを呼び出す部分をすべて非同期処理にする必要がある
<ul>
<li>該当の処理をTaskにいれるか、asyncメソッドに変更する必要があり設計の調整が必要なこともある</li>
</ul>
</li>
</ul>
<p>この修正はかなり広範囲に渡るので、いったんはUseCaseやRepositoryはactorではなくMainActorにして、別スレッドの処理が必要なところだけ特別対応をする方法も効果的だと感じました。</p>
<h3 id="DispatchQueueasyncによる非同期処理を廃止">DispatchQueue.asyncによる非同期処理を廃止</h3>
<p>Swift Concurrencyが出るまでは、Main Thread以外で処理をする場合にはDispatchQueue.asyncを使う方法を主に用いていました。この方法はSwift ConcurrencyのActorの仕組みと相性が良くないように思ったので、全て置き換えました。</p>
<h4 id="単発処理で完了待ちが必要なケース">単発処理で完了待ちが必要なケース</h4>
<p>別スレッドで時間のかかる処理を行ったあと、その完了を待ってから次の処理を行いたいケースです。</p>
<p>元の処理</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">saveData</span>(_ data<span class="synSpecial">:</span> <span class="synType">Int</span>, completion<span class="synSpecial">: (</span><span class="synType">Void</span><span class="synSpecial">)</span> <span class="synSpecial">-> ()</span>) {
DispatchQueue.global().async {
save(data)
<span class="synComment">// 処理の完了を知らせるためにcompletionを呼び出す</span>
completion()
}
}
</pre>
<p>対応後</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">saveData</span>(_ data<span class="synSpecial">:</span> <span class="synType">Int</span>) async {
<span class="synComment">// Task.valueをawaitすることで処理の完了を待つことができる</span>
await Task.detached {
save(data)
}.value
}
</pre>
<p>DispatchQueue.global.asyncをTask.detachedに置き換えることでSwift Concurrencyの仕組みで別スレッドでの実行をさせます。
また、Taskはawaitで完了待ちができるので、メソッド自体をasyncメソッドにすることでクロージャーで完了後の処理を書く必要もなくなりました。</p>
<h4 id="asyncAfterで遅延実行するケース">asyncAfterで遅延実行するケース</h4>
<p>対応前</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">doSomething</span>() {
<span class="synComment">// asyncAfterで時間指定することで開始タイミングを遅らせる</span>
DispatchQueue.main.asyncAfter(deadline<span class="synSpecial">:</span> .now() <span class="synIdentifier">+</span> <span class="synConstant">0.1</span>) {
something()
}
}
</pre>
<p>対応後</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">doSomething</span>(_ data<span class="synSpecial">:</span> <span class="synType">Int</span>) {
Task { <span class="synType">@MainActor</span> <span class="synType">in</span>
<span class="synComment">// Task.sleepを使うことでスレッドをブロックせずに一定時間待つことができる</span>
<span class="synStatement">try</span>? await Task.sleep(nanoseconds<span class="synSpecial">:</span> <span class="synConstant">100</span> <span class="synIdentifier">*</span> NSEC_PER_MSEC)
something()
}
}
</pre>
<p><a href="https://developer.apple.com/documentation/swift/task/sleep%28nanoseconds%3A%29">Task.sleep(nanoseconds:)</a> を使うことでスレッドをブロックせずに一定時間待つことができます。
またこの例だとメインスレッドで実行することを意図していたので、Taskの処理の先頭で <code>@MainActor</code> を書くことでメインスレッドで実行させています。</p>
<h4 id="複数の処理を同じスレッドで処理させたいとき">複数の処理を同じスレッドで処理させたいとき</h4>
<p>同じDispatchQueueを使うことで、複数の処理を同じスレッドで実行したい場合があります</p>
<p>対応前</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">let</span> <span class="synIdentifier">dispatchQueue</span> <span class="synIdentifier">=</span> DispatchQueue(label<span class="synSpecial">:</span> <span class="synConstant">"label"</span>)
<span class="synPreProc">func</span> <span class="synIdentifier">hoge</span>() {
dispatchQueue.async {
<span class="synComment">// ここでなにかする</span>
}
}
<span class="synPreProc">func</span> <span class="synIdentifier">fuga</span>() {
dispatchQueue.async {
<span class="synComment">// ここでなにかする</span>
}
}
<span class="synPreProc">func</span> <span class="synIdentifier">example</span>() {
hoge()
fuga()
}
</pre>
<p><a href="https://developer.apple.com/documentation/swift/globalactor">GlobalActor</a>を定義し、そのActorを指定することでそれらは同じスレッドで動作するようになります。</p>
<p>対応後</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// 独自のGlobalActorを定義</span>
<span class="synType">@globalActor</span>
<span class="synType">internal</span> <span class="synPreProc">enum</span> <span class="synIdentifier">SampleActor</span> {
<span class="synStatement">internal</span> actor SetupActor {}
<span class="synStatement">internal</span> <span class="synStatement">static</span> <span class="synPreProc">let</span> <span class="synIdentifier">shared</span> <span class="synIdentifier">=</span> SetupActor()
}
<span class="synType">@SampleActor</span>
<span class="synType">func</span> hoge() {
<span class="synComment">// ここでなにかする</span>
}
<span class="synType">@SampleActor</span>
<span class="synType">func</span> fuga() {
<span class="synComment">// ここでなにかする</span>
}
<span class="synPreProc">func</span> <span class="synIdentifier">example</span>() {
<span class="synComment">// SampleActorを指定するとawaitなし(スレッド切り替えなし)で呼び出せる</span>
Task { <span class="synType">@SampleActor</span> <span class="synType">in</span>
hoge()
fuga()
}
<span class="synComment">// 通常のTaskからの呼び出しはawait(スレッド切り替え)が必要</span>
Task {
await hoge()
await fuga()
}
}
</pre>
<h3 id="nonisolated-が要求されるDelegateメソッドの対応">nonisolated が要求されるDelegateメソッドの対応</h3>
<p><strong>2024/02/28追記: ハンドラ呼び出し系のDelegateメソッドをasync版のメソッドに置き換えるとiOS 15以下の環境でクラッシュが発生していたため切り戻しをしました。詳細はわかりませんがもし置き換えをする場合は念の為クラッシュを注視したほうが良いかもしれません。</strong></p>
<p><a href="https://developer.apple.com/documentation/webkit/wknavigationdelegate">WKNavigationDelegate</a>のように、通常はViewControllerなどのMainActor部分に実装する事が多いけど、Delegate側でMainActor指定がなくnonisolatedでメソッドを実装する必要がある場合があります。</p>
<p>対応前</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">internal</span> <span class="synPreProc">func</span> <span class="synIdentifier">webView</span>(_ webView<span class="synSpecial">:</span> <span class="synType">WKWebView</span>, didFinish navigation<span class="synSpecial">:</span> <span class="synType">WKNavigation!</span>) {
<span class="synIdentifier">self</span>.isFinished <span class="synIdentifier">=</span> <span class="synConstant">true</span>
}
<span class="synStatement">internal</span> <span class="synPreProc">func</span> <span class="synIdentifier">webView</span>(_ webView<span class="synSpecial">:</span> <span class="synType">WKWebView</span>, decidePolicyFor navigationAction<span class="synSpecial">:</span> <span class="synType">WKNavigationAction</span>, decisionHandler<span class="synSpecial">:</span> <span class="synType">@escaping</span><span class="synSpecial"> (</span><span class="synType">WKNavigationActionPolicy</span><span class="synSpecial">)</span> <span class="synSpecial">-></span> <span class="synType">Void</span>) {
<span class="synStatement">if</span> <span class="synIdentifier">self</span>.isHoge {
decisionHandler(.allow)
} <span class="synStatement">else</span> {
decisionHandler(.cancel)
}
}
</pre>
<p>対応後</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// メソッドにnonisolatedをつける</span>
nonisolated <span class="synStatement">internal</span> <span class="synPreProc">func</span> <span class="synIdentifier">webView</span>(_ webView<span class="synSpecial">:</span> <span class="synType">WKWebView</span>, didFinish navigation<span class="synSpecial">:</span> <span class="synType">WKNavigation!</span>) {
<span class="synComment">// selfがMainActorやactorの場合、nonisolatedメソッドからはselfへのアクセスが制限される</span>
<span class="synComment">// このメソッドはViewControllerに実装することが多く、内部のプロパティアクセスにはMainActorである必要があるので</span>
<span class="synComment">// Task { @MainActor in で内部の処理を実行する</span>
Task { <span class="synType">@MainActor</span> <span class="synType">in</span>
<span class="synIdentifier">self</span>.isFinished <span class="synIdentifier">=</span> <span class="synConstant">true</span>
}
}
<span class="synComment">// ハンドラ呼び出し系のメソッドはasync版が用意されているのでそれに置き換える</span>
nonisolated <span class="synStatement">internal</span> <span class="synPreProc">func</span> <span class="synIdentifier">webView</span>(_ webView<span class="synSpecial">:</span> <span class="synType">WKWebView</span>, decidePolicyFor navigationAction<span class="synSpecial">:</span> <span class="synType">WKNavigationAction</span>) async <span class="synSpecial">-></span> <span class="synType">WKNavigationActionPolicy</span>
<span class="synComment">// selfがViewControllerでMainActorのためTask経由で値を取り出す</span>
<span class="synPreProc">let</span> <span class="synIdentifier">isHoge</span> <span class="synIdentifier">=</span> await Task { <span class="synType">@MainActor</span> <span class="synType">in</span> <span class="synIdentifier">self</span>.isHoge }.value
<span class="synStatement">if</span> isHoge {
<span class="synStatement">return</span> .allow
} <span class="synStatement">else</span> {
<span class="synStatement">return</span> .cancel
}
}
</pre>
<h3 id="Sendable対応">Sendable対応</h3>
<p>actor指定のあるモジュールのメソッドをまたいで渡されるデータはすべてSendableでなければなりません。</p>
<h3 id="Sendableにできないデータを受け渡したいケース">Sendableにできないデータを受け渡したいケース</h3>
<p>例として「Objective-Cのメソッドに、AnyのようなSendable対応出来ないデータを受け渡したい」ケースを考えてみます。
実際にはAnyをSendable扱いに出来ないのでコンパイルエラーとなってしまいますが、単にメソッドへパラメータを渡すだけで値の書き換えが無い(データ競合が発生しない)場合は、Sendableとして扱ってしまっても動作に問題ないはずです。</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">callLegacyMethod</span>() async {
<span class="synPreProc">let</span> <span class="synIdentifier">params</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">String</span><span class="synSpecial">:</span><span class="synType"> Any</span><span class="synSpecial">]</span> <span class="synIdentifier">=</span> [
<span class="synConstant">"size"</span><span class="synSpecial">:</span> <span class="synConstant">48</span>,
<span class="synConstant">"width"</span><span class="synSpecial">:</span> <span class="synConstant">2</span>
]
<span class="synComment">// ここでparamsがSendableではないのでコンパイラに怒られる</span>
await useCase.setParams(params)
}
</pre>
<p>そのようなケースでは@unchecked Sendableなstructに入れて受け渡しすることで、コンパイラにはSendableとして見せることができます。</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// このような箱に入れることでコンパイラにはSendableとして扱わせることができます</span>
<span class="synStatement">internal</span> <span class="synPreProc">struct</span> <span class="synIdentifier">UncheckedSendableContainer</span><span class="synSpecial"><</span><span class="synIdentifier">T</span><span class="synSpecial">>:</span> <span class="synType">@unchecked</span> <span class="synType">Sendable</span> {
<span class="synStatement">internal</span> <span class="synPreProc">let</span> <span class="synIdentifier">value</span><span class="synSpecial">:</span> <span class="synType">T</span>
}
<span class="synPreProc">func</span> <span class="synIdentifier">callLegacyMethod</span>() async {
<span class="synPreProc">let</span> <span class="synIdentifier">params</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">String</span><span class="synSpecial">:</span><span class="synType"> Any</span><span class="synSpecial">]</span> <span class="synIdentifier">=</span> [
<span class="synConstant">"size"</span><span class="synSpecial">:</span> <span class="synConstant">48</span>,
<span class="synConstant">"text"</span><span class="synSpecial">:</span> <span class="synConstant">"This type is String."</span>
]
<span class="synPreProc">let</span> <span class="synIdentifier">paramsContainer</span> <span class="synIdentifier">=</span> UncheckedSendableContainer(value<span class="synSpecial">:</span> <span class="synType">params</span>)
await useCase.setParams(paramsContainer)
}
</pre>
<p>@unchecked Sendableは、動作が正しいことを<strong>プログラマが責任を持つ(コンパイラがチェックしてくれなくなる)</strong>ので使う場合には問題がないことをよく確認してください。</p>
<h3 id="AnyPublisherを受け渡したいケース">AnyPublisherを受け渡したいケース</h3>
<p>アプリの中でなにかの状態をAnyPublisherとして監視したいケースがあります。
例えばauサービスTodayでは「既読記事のID一覧」を監視可能にしておき、それが更新されたときに記事リストのタイトルをグレー色で表示するようになっています。</p>
<p>そのためにRepositoryがAnyPublisherを公開して、各画面でそれを監視できるような仕組みにしていました。</p>
<p>しかしAnyPublisherがSendableではないのでそのままだとコンパイラに怒られてしまいます。</p>
<p>対応前</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">///////// Repository</span>
<span class="synStatement">internal</span> <span class="synPreProc">protocol</span> <span class="synIdentifier">ArticleHistoryRepository</span><span class="synSpecial">:</span> <span class="synType">Actor</span> {
<span class="synPreProc">var</span> <span class="synIdentifier">readArticleIDs</span><span class="synSpecial">:</span> <span class="synType">AnyPublisher</span><span class="synSpecial"><</span><span class="synIdentifier">Int64</span><span class="synSpecial">, </span><span class="synIdentifier">Never</span><span class="synSpecial">></span> { <span class="synStatement">get</span> }
}
<span class="synStatement">internal</span> actor ArticleHistoryRepositoryImpl<span class="synSpecial">:</span> <span class="synType">ArticleHistoryRepository</span> {
<span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">readArticleIDsSubject</span> <span class="synIdentifier">=</span> PassthroughSubject<span class="synIdentifier"><</span>Int64, Never<span class="synIdentifier">></span>()
<span class="synStatement">internal</span> <span class="synPreProc">var</span> <span class="synIdentifier">addedArticleID</span><span class="synSpecial">:</span> <span class="synType">AnyPublisher</span><span class="synSpecial"><</span><span class="synIdentifier">Int64</span><span class="synSpecial">, </span><span class="synIdentifier">Never</span><span class="synSpecial">></span> {
readArticleIDsSubject.eraseToAnyPublisher()
}
}
<span class="synComment">///////// UseCase</span>
<span class="synStatement">internal</span> <span class="synPreProc">protocol</span> <span class="synIdentifier">ArticlesUseCase</span><span class="synSpecial">:</span> <span class="synType">Actor</span> {
<span class="synPreProc">var</span> <span class="synIdentifier">readArticleIDs</span><span class="synSpecial">:</span> <span class="synType">AnyPublisher</span><span class="synSpecial"><</span><span class="synIdentifier">Int64</span><span class="synSpecial">, </span><span class="synIdentifier">Never</span><span class="synSpecial">></span> { <span class="synStatement">get</span> async }
}
<span class="synStatement">internal</span> actor ArticleSearchArticlesUseCaseImpl<span class="synSpecial">:</span> <span class="synType">ArticleSearchArticlesUseCase</span> {
<span class="synStatement">internal</span> <span class="synPreProc">var</span> <span class="synIdentifier">addedArticleID</span><span class="synSpecial">:</span> <span class="synType">AnyPublisher</span><span class="synSpecial"><</span><span class="synIdentifier">Int64</span><span class="synSpecial">, </span><span class="synIdentifier">Never</span><span class="synSpecial">></span> {
<span class="synStatement">get</span> async {
<span class="synComment">// Actor境界をまたいでAnyPublisherにアクセスしているので怒られる</span>
await articleHistoryRepository.addedArticleID
}
}
}
<span class="synComment">///////// ViewModel</span>
<span class="synStatement">internal</span> <span class="synPreProc">func</span> <span class="synIdentifier">subscribe</span>() {
Task {
await useCase.readArticleIDs
.sink { [<span class="synStatement">weak</span> <span class="synIdentifier">self</span>] readArticleIDs <span class="synStatement">in</span>
<span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">self</span> <span class="synStatement">else</span> { <span class="synStatement">return</span> }
Task {
<span class="synComment">// 必要な処理</span>
}
}
.store(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synIdentifier">&</span>subscriptions)
}
}
</pre>
<p>readArticleIDsのプロパティはViewModel(MainActor)で監視するのでプロパティをMainActorとすることでActor境界をまたがないようにして対応しました。</p>
<p>対応後</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">///////// Repository</span>
<span class="synStatement">internal</span> <span class="synPreProc">protocol</span> <span class="synIdentifier">ArticleHistoryRepository</span><span class="synSpecial">:</span> <span class="synType">Actor</span> {
<span class="synType">@MainActor</span> <span class="synType">var</span> readArticleIDs<span class="synSpecial">:</span> <span class="synType">AnyPublisher</span><span class="synSpecial"><</span><span class="synIdentifier">Int64</span><span class="synSpecial">, </span><span class="synIdentifier">Never</span><span class="synSpecial">></span> { <span class="synStatement">get</span> }
}
<span class="synStatement">internal</span> actor ArticleHistoryRepositoryImpl<span class="synSpecial">:</span> <span class="synType">ArticleHistoryRepository</span> {
<span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">readArticleIDsSubject</span> <span class="synIdentifier">=</span> PassthroughSubject<span class="synIdentifier"><</span>Int64, Never<span class="synIdentifier">></span>()
<span class="synType">@MainActor</span> <span class="synType">internal</span> <span class="synPreProc">var</span> <span class="synIdentifier">addedArticleID</span><span class="synSpecial">:</span> <span class="synType">AnyPublisher</span><span class="synSpecial"><</span><span class="synIdentifier">Int64</span><span class="synSpecial">, </span><span class="synIdentifier">Never</span><span class="synSpecial">></span> {
readArticleIDsSubject.eraseToAnyPublisher()
}
}
<span class="synComment">///////// UseCase</span>
<span class="synStatement">internal</span> <span class="synPreProc">protocol</span> <span class="synIdentifier">ArticlesUseCase</span><span class="synSpecial">:</span> <span class="synType">Actor</span> {
<span class="synType">@MainActor</span> <span class="synType">var</span> readArticleIDs<span class="synSpecial">:</span> <span class="synType">AnyPublisher</span><span class="synSpecial"><</span><span class="synIdentifier">Int64</span><span class="synSpecial">, </span><span class="synIdentifier">Never</span><span class="synSpecial">></span> { <span class="synStatement">get</span> }
}
<span class="synStatement">internal</span> actor ArticleSearchArticlesUseCaseImpl<span class="synSpecial">:</span> <span class="synType">ArticleSearchArticlesUseCase</span> {
<span class="synType">@MainActor</span> <span class="synType">internal</span> <span class="synPreProc">var</span> <span class="synIdentifier">readArticleIDs</span><span class="synSpecial">:</span> <span class="synType">AnyPublisher</span><span class="synSpecial"><</span><span class="synIdentifier">Int64</span><span class="synSpecial">, </span><span class="synIdentifier">Never</span><span class="synSpecial">></span> {
articleHistoryRepository.readArticleIDs
}
}
<span class="synComment">///////// ViewModel</span>
<span class="synStatement">internal</span> <span class="synPreProc">func</span> <span class="synIdentifier">subscribe</span>() {
useCase.readArticleIDs
.sink { [<span class="synStatement">weak</span> <span class="synIdentifier">self</span>] readArticleIDs <span class="synStatement">in</span>
<span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">self</span> <span class="synStatement">else</span> { <span class="synStatement">return</span> }
Task {
<span class="synComment">// 必要な処理</span>
}
}
.store(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synIdentifier">&</span>subscriptions)
}
</pre>
<h4 id="AsyncPublisherを使わなかった理由">AsyncPublisherを使わなかった理由</h4>
<p>iOS 15以降では<a href="https://developer.apple.com/documentation/combine/publisher/values-v7nz">Publisher.values</a>を使うことでawait可能な形式に変換ができます。</p>
<pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">class</span> <span class="synIdentifier">Sample</span> {
<span class="synPreProc">var</span> <span class="synIdentifier">currentValues</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">Int</span><span class="synSpecial">]</span> <span class="synIdentifier">=</span> []
<span class="synPreProc">var</span> <span class="synIdentifier">task</span><span class="synSpecial">:</span> <span class="synType">Task?</span>
<span class="synIdentifier">deinit</span> {
<span class="synComment">// for await in を使うTaskは基本的に明示的なキャンセルが必要(忘れやすい)</span>
task?.cancel()
}
<span class="synPreProc">func</span> <span class="synIdentifier">subscribe</span>() {
task <span class="synIdentifier">=</span> Task {
<span class="synStatement">for</span> await value <span class="synStatement">in</span> publisher.values {
<span class="synComment">// selfが循環参照になるのでこのままだとキャンセルされない(ついついやりがち)</span>
<span class="synIdentifier">self</span>.currentValue.append(value)
}
}
}
}
</pre>
<p>この形式だと下記の注意点があり、プログラマが注意を払わなければ簡単にselfの開放漏れが起こります。</p>
<ul>
<li>for await inを使うTask内でselfにアクセスしない</li>
<li>deinitなどで明示的にTaskをcancelする必要がある</li>
</ul>
<p>これを守るように強制するのはなかなか難しいと思いましたので、AnyPublisherを監視する方式としました。</p>
<h3 id="まとめ">まとめ</h3>
<p>auサービスTodayのiOSアプリはSwift Concurrency対応を全面的に行い、SWIFT_STRICT_CONCURRENCYをcompleteにすることができました。
その過程で行ってきた変更について紹介しました。</p>
<p>この対応から下記の教訓が得られました。</p>
<ul>
<li>UseCaseなどでMainActor以外のactorを使うと全面的にSendable対応が必要となり対応項目が多くなる
<ul>
<li>まずはすべてのモジュールをMainActorにするようにして、UseCaseやRepositoryはSendable対応しながら段階的にactor化するような対応が良いと思いました</li>
</ul>
</li>
<li>AsyncSequenceをfor await inする場合はselfの開放漏れが発生しないようにプログラマに注意力が求められる</li>
</ul>
<p>対応は大変でしたが、マルチスレッドにまつわる問題の多くをコンパイラが検出してくれるようになったのはかなり心強く思います。</p>
<p>明日はyamayuさんの「サードパーティ Cookie を使わない広告効果計測 ~Privacy Sandbox の Attribution Reporting API について~」についてです!お楽しみに!</p>
rikusouda
tfaction を導入したら便利だった話
hatenablog://entry/6801883189065899525
2023-12-14T12:00:00+09:00
2023-12-14T13:07:35+09:00 この記事は Gunosy Advent Calendar 2023 の 14 日目の記事です。昨日の記事は上村さんの「ChatGPTを活用した業務支援ツール「ウデキキ」のチャット実装」でした。 こんにちは、プロダクト開発部 Ads チームの fujishiro です。最近は年末に行われる RIZIN.45 が楽しみで YouTube で試合予想動画を見ていることが多いです。 さて、今回は弊チームが管理している Terraform のリポジトリの CI/CD 環境を CircleCI から GitHub Actions に移行した際に tfaction という Action を導入したのですが、…
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 14 日目の記事です。昨日の記事は上村さんの「<a href="https://data.gunosy.io/entry/udekiki_langchain_chat_callback">ChatGPTを活用した業務支援ツール「ウデキキ」のチャット実装</a>」でした。</p>
<p>こんにちは、プロダクト開発部 Ads チームの fujishiro です。最近は年末に行われる RIZIN.45 が楽しみで YouTube で試合予想動画を見ていることが多いです。</p>
<p>さて、今回は弊チームが管理している Terraform のリポジトリの CI/CD 環境を CircleCI から GitHub Actions に移行した際に tfaction という Action を導入したのですが、これが便利だったので紹介したいと思います。</p>
<ul class="table-of-contents">
<li><a href="#tfaction-とは">tfaction とは</a><ul>
<li><a href="#実現したいワークフロー">実現したいワークフロー</a></li>
<li><a href="#必要な設定1">必要な設定*1</a><ul>
<li><a href="#1-tfaction-rootyaml-を追加する">1. tfaction-root.yaml を追加する</a></li>
<li><a href="#2-tfactionyaml-を作業ディレクトリごとに追加する">2. tfaction.yaml を作業ディレクトリごとに追加する</a></li>
<li><a href="#3-aqua-を使って必要な-CLI-ツールをインストールできるように-aquayaml-を追加する">3. aqua を使って必要な CLI ツールをインストールできるように aqua.yaml を追加する</a></li>
<li><a href="#4-terraform-plan-を実行する-workflow-と-terraform-apply-を実行する-workflow-を記述する">4. terraform plan を実行する workflow と terraform apply を実行する workflow を記述する</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#tfaction-の便利な機能">tfaction の便利な機能</a><ul>
<li><a href="#複数の作業ディレクトリの並列処理">複数の作業ディレクトリの並列処理</a></li>
<li><a href="#plan-apply-の実行結果を-tfcmt-を使って-PR-にコメントしてくれる">plan, apply の実行結果を tfcmt を使って PR にコメントしてくれる</a></li>
<li><a href="#plan-の実行結果を元に-apply-を実行する">plan の実行結果を元に apply を実行する</a></li>
</ul>
</li>
<li><a href="#tfaction-の導入時の工夫">tfaction の導入時の工夫</a><ul>
<li><a href="#GitHub-Flow-以外の開発フローで利用するために-TF_WORKSPACE-の切り替え">GitHub Flow 以外の開発フローで利用するために (TF_WORKSPACE の切り替え)</a></li>
<li><a href="#apply-後の関連-PR-の自動更新の停止">apply 後の関連 PR の自動更新の停止</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h1 id="tfaction-とは">tfaction とは</h1>
<p><a href="https://suzuki-shunsuke.github.io/tfaction/docs/">tfaction</a> とは GitHub Actions で Terraform Workflow を実行するための Action で、中身は複数の Action の集合体になっています。tfaction に含まれる Action を組み合わせて使用することで簡単に Terraform Workflow を実現することができます。</p>
<p>例えば、下記のような AWS のリソースが記述されている作業ディレクトリが 2 つあるモノレポ構成の Terraform リポジトリを管理しているとします。</p>
<pre class="code" data-lang="" data-unlink>├── shared
│ ├── main.tf
│ └── .terraform.lock.hcl
└── components
├── main.tf
└── .terraform.lock.hcl</pre>
<p>この時に以下のようなワークフローを実現したい場合、tfaction を使って以下のような設定を行うことで実現することができます。</p>
<h3 id="実現したいワークフロー">実現したいワークフロー</h3>
<ol>
<li>main ブランチに向けた feature ブランチの PR の作成時に terraform plan を実行する</li>
<li>main ブランチに PR がマージされた際に terraform apply を実行する</li>
</ol>
<h3 id="必要な設定1">必要な設定<a href="#f-1bd8648e" id="fn-1bd8648e" name="fn-1bd8648e" title="GitHub Apps の設定や OIDC の設定などはここでは割愛しています。">*1</a></h3>
<ol>
<li>tfaction-root.yaml を追加する</li>
<li>tfaction.yaml を作業ディレクトリごとに追加する</li>
<li><a href="https://aquaproj.github.io/">aqua</a> を使って必要な CLI ツールをインストールできるように aqua.yaml を追加する</li>
<li>terraform plan を実行する workflow と terraform apply を実行する workflow を記述する</li>
</ol>
<p>ディレクトリ構成は以下のようになります。</p>
<pre class="code" data-lang="" data-unlink>├── .github
│ └── workflows
│ ├── plan.yaml # 追加
│ └── apply.yaml # 追加
├── shared
│ ├── main.tf
│ ├── .terraform.lock.hcl
│ └── tfaction.yaml # 追加
├── components
│ ├── main.tf
│ ├── .terraform.lock.hcl
│ └── tfaction.yaml # 追加
├── aqua.yaml # 追加
└── tfaction-root.yaml # 追加</pre>
<h4 id="1-tfaction-rootyaml-を追加する">1. tfaction-root.yaml を追加する</h4>
<p><a href="https://suzuki-shunsuke.github.io/tfaction/docs/config/tfaction-root-yaml">tfaction-root.yaml</a> は tfaction の全体の設定ファイルになるので、ベースとなる tfaction の設定をここで記述していきます。</p>
<p>tfaction-root.yaml</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">plan_workflow_name</span><span class="synSpecial">:</span> terraform-plan
<span class="synIdentifier">target_groups</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">working_directory</span><span class="synSpecial">:</span> shared
<span class="synIdentifier">target</span><span class="synSpecial">:</span> shared
<span class="synIdentifier">aws_region</span><span class="synSpecial">:</span> ap-northeast-1
<span class="synIdentifier">terraform_plan_config</span><span class="synSpecial">:</span>
<span class="synIdentifier">aws_assume_role_arn</span><span class="synSpecial">:</span> arn:aws:iam::xxxxx:role/xxxxx
<span class="synIdentifier">terraform_apply_config</span><span class="synSpecial">:</span>
<span class="synIdentifier">aws_assume_role_arn</span><span class="synSpecial">:</span> arn:aws:iam::xxxxx:role/xxxxx
<span class="synComment"> # ... その他の設定</span>
<span class="synStatement">- </span><span class="synIdentifier">working_directory</span><span class="synSpecial">:</span> components
<span class="synIdentifier">target</span><span class="synSpecial">:</span> components
<span class="synIdentifier">aws_region</span><span class="synSpecial">:</span> ap-northeast-1
<span class="synIdentifier">terraform_plan_config</span><span class="synSpecial">:</span>
<span class="synIdentifier">aws_assume_role_arn</span><span class="synSpecial">:</span> arn:aws:iam::xxxxx:role/xxxxx
<span class="synIdentifier">terraform_apply_config</span><span class="synSpecial">:</span>
<span class="synIdentifier">aws_assume_role_arn</span><span class="synSpecial">:</span> arn:aws:iam::xxxxx:role/xxxxx
<span class="synComment"> # ... その他の設定</span>
</pre>
<h4 id="2-tfactionyaml-を作業ディレクトリごとに追加する">2. tfaction.yaml を作業ディレクトリごとに追加する</h4>
<p><a href="https://suzuki-shunsuke.github.io/tfaction/docs/config/tfaction-yaml">tfaction.yaml</a> は各作業ディレクトリごとに配置する tfaction の設定ファイルで、tfaction-root.yaml で記述した設定を上書きすることができます。</p>
<p>tfaction は tfaction.yaml を見て、作業ディレクトリを検出するので上書きする必要がない場合でも配置する必要があります。</p>
<p>tfaction.yaml</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synSpecial">{}</span>
</pre>
<h4 id="3-aqua-を使って必要な-CLI-ツールをインストールできるように-aquayaml-を追加する">3. aqua を使って必要な CLI ツールをインストールできるように aqua.yaml を追加する</h4>
<p><a href="https://aquaproj.github.io/">aqua</a> は宣言的に CLI ツールを管理することができる CLI ツールです。 tfaction は CLI ツールの管理に aqua を利用しているため、設定ファイルである <a href="https://suzuki-shunsuke.github.io/tfaction/docs/config/aqua">aqua.yaml</a> を追加する必要があります。</p>
<p>GitHub Actions では <a href="https://aquaproj.github.io/docs/products/aqua-installer/#github-actions">aquaproj/aqua-installer</a> を使って aqua 経由で必要な CLI ツールをインストールします。</p>
<p>aqua.yaml</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synPreProc">---</span>
<span class="synComment"># aqua - Declarative CLI Version Manager</span>
<span class="synComment"># https://aquaproj.github.io/</span>
<span class="synIdentifier">registries</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">type</span><span class="synSpecial">:</span> standard
<span class="synIdentifier">ref</span><span class="synSpecial">:</span> v4.100.0<span class="synComment"> # renovate: depName=aquaproj/aqua-registry</span>
<span class="synIdentifier">packages</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> suzuki-shunsuke/github-comment@v6.0.3
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> suzuki-shunsuke/ci-info@v2.1.3
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> int128/ghcp@v1.13.2
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> suzuki-shunsuke/tfcmt@v4.7.2
<span class="synComment"> # ... その他のツール</span>
</pre>
<h4 id="4-terraform-plan-を実行する-workflow-と-terraform-apply-を実行する-workflow-を記述する">4. terraform plan を実行する workflow と terraform apply を実行する workflow を記述する</h4>
<p>workflow の中身は基本的に setup と plan もしくは apply の 2 段階に分かれており、setup で変更のあった作業ディレクトリを取得し、plan や apply で取得した作業ディレクトリを並列に実行するという流れになっています。</p>
<p>plan.yaml</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">name</span><span class="synSpecial">:</span> terraform-plan
<span class="synIdentifier">on</span><span class="synSpecial">:</span>
<span class="synIdentifier">pull_request</span><span class="synSpecial">:</span>
<span class="synIdentifier">branches</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>main
<span class="synIdentifier">permissions</span><span class="synSpecial">:</span>
<span class="synIdentifier">id-token</span><span class="synSpecial">:</span> write
<span class="synIdentifier">contents</span><span class="synSpecial">:</span> read
<span class="synIdentifier">pull-requests</span><span class="synSpecial">:</span> write
<span class="synIdentifier">jobs</span><span class="synSpecial">:</span>
<span class="synComment"> # setup で変更のあった作業ディレクトリを取得する</span>
<span class="synIdentifier">setup</span><span class="synSpecial">:</span>
<span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest
<span class="synIdentifier">outputs</span><span class="synSpecial">:</span>
<span class="synIdentifier">targets</span><span class="synSpecial">:</span> ${{ steps.list-targets.outputs.targets }}
<span class="synIdentifier">steps</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v4
<span class="synComment"> # aqua を使って必要なツールをインストールする</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> aquaproj/aqua-installer@v2.2.0
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">aqua_version</span><span class="synSpecial">:</span> v2.21.0
<span class="synComment"> # 変更のあった作業ディレクトリを取得する Action</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> suzuki-shunsuke/tfaction/list-targets@v0.7.3
<span class="synIdentifier">id</span><span class="synSpecial">:</span> list-targets
<span class="synComment"> # plan で setup で取得した変更のある作業ディレクトリを並列に実行する</span>
<span class="synIdentifier">plan</span><span class="synSpecial">:</span>
<span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synConstant">"terraform plan (${{ matrix.target.target }})"</span>
<span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ${{ matrix.target.runs_on }}
<span class="synIdentifier">needs</span><span class="synSpecial">:</span> setup
<span class="synComment"> # setup で取得した変更のある作業ディレクトリが空の場合は実行しない</span>
<span class="synIdentifier">if</span><span class="synSpecial">:</span> join(fromJSON(needs.setup.outputs.targets), <span class="synConstant">''</span>) <span class="synType">!=</span> <span class="synConstant">''</span>
<span class="synIdentifier">strategy</span><span class="synSpecial">:</span>
<span class="synIdentifier">fail-fast</span><span class="synSpecial">:</span> <span class="synConstant">false</span>
<span class="synIdentifier">matrix</span><span class="synSpecial">:</span>
<span class="synIdentifier">target</span><span class="synSpecial">:</span> ${{ fromJSON(needs.setup.outputs.targets) }}
<span class="synIdentifier">env</span><span class="synSpecial">:</span>
<span class="synIdentifier">TFACTION_TARGET</span><span class="synSpecial">:</span> ${{ matrix.target.target }}
<span class="synIdentifier">TFACTION_JOB_TYPE</span><span class="synSpecial">:</span> terraform
<span class="synIdentifier">steps</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v4
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> aquaproj/aqua-installer@v2.2.0
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">aqua_version</span><span class="synSpecial">:</span> v2.21.0
<span class="synStatement">- </span><span class="synIdentifier">id</span><span class="synSpecial">:</span> github_app_token
<span class="synIdentifier">uses</span><span class="synSpecial">:</span> tibdex/github-app-token@v2
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">app_id</span><span class="synSpecial">:</span> ${{ secrets.APP_ID }}
<span class="synIdentifier">private_key</span><span class="synSpecial">:</span> ${{ secrets.PRIVATE_KEY }}
<span class="synComment"> # terraform init などの準備を行う Action</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> suzuki-shunsuke/tfaction/setup@v0.7.3
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">github_app_token</span><span class="synSpecial">:</span> ${{ steps.github_app_token.outputs.token }}
<span class="synComment"> # terraform plan を実行する Action</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> suzuki-shunsuke/tfaction/plan@v0.7.3
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">github_app_token</span><span class="synSpecial">:</span> ${{ steps.github_app_token.outputs.token }}
</pre>
<p>apply.yaml</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">name</span><span class="synSpecial">:</span> terraform-apply
<span class="synIdentifier">on</span><span class="synSpecial">:</span>
<span class="synIdentifier">push</span><span class="synSpecial">:</span>
<span class="synIdentifier">branches</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>main
<span class="synIdentifier">permissions</span><span class="synSpecial">:</span>
<span class="synIdentifier">id-token</span><span class="synSpecial">:</span> write
<span class="synIdentifier">contents</span><span class="synSpecial">:</span> read
<span class="synIdentifier">pull-requests</span><span class="synSpecial">:</span> write
<span class="synIdentifier">actions</span><span class="synSpecial">:</span> read<span class="synComment"> # artifact を取得するために必要</span>
<span class="synIdentifier">jobs</span><span class="synSpecial">:</span>
<span class="synComment"> # plan.yaml と同様に setup で変更のあった作業ディレクトリを取得し、outputs で出力する</span>
<span class="synIdentifier">setup</span><span class="synSpecial">:</span>
<span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest
<span class="synIdentifier">outputs</span><span class="synSpecial">:</span>
<span class="synIdentifier">targets</span><span class="synSpecial">:</span> ${{ steps.list-targets.outputs.targets }}
<span class="synIdentifier">steps</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v4
<span class="synComment"> # aqua を使って必要なツールをインストールする</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> aquaproj/aqua-installer@v2.2.0
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">aqua_version</span><span class="synSpecial">:</span> v2.21.0
<span class="synComment"> # 変更のあった作業ディレクトリを取得する Action</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> suzuki-shunsuke/tfaction/list-targets@v0.7.3
<span class="synIdentifier">id</span><span class="synSpecial">:</span> list-targets
<span class="synComment"> # apply で setup で取得した変更のある作業ディレクトリを並列に実行する</span>
<span class="synIdentifier">apply</span><span class="synSpecial">:</span>
<span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synConstant">"terraform apply (${{ matrix.target.target }})"</span>
<span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ${{ matrix.target.runs_on }}
<span class="synIdentifier">needs</span><span class="synSpecial">:</span> setup
<span class="synComment"> # setup で取得した変更のある作業ディレクトリが空の場合は実行しない</span>
<span class="synIdentifier">if</span><span class="synSpecial">:</span> join(fromJSON(needs.setup.outputs.targets), <span class="synConstant">''</span>) <span class="synType">!=</span> <span class="synConstant">''</span>
<span class="synIdentifier">strategy</span><span class="synSpecial">:</span>
<span class="synIdentifier">fail-fast</span><span class="synSpecial">:</span> <span class="synConstant">false</span>
<span class="synIdentifier">matrix</span><span class="synSpecial">:</span>
<span class="synIdentifier">target</span><span class="synSpecial">:</span> ${{ fromJSON(needs.setup.outputs.targets) }}
<span class="synIdentifier">env</span><span class="synSpecial">:</span>
<span class="synIdentifier">TFACTION_IS_APPLY</span><span class="synSpecial">:</span> <span class="synConstant">"true"</span><span class="synComment"> # apply する場合は TFACTION_IS_APPLY を "true" に指定</span>
<span class="synIdentifier">TFACTION_TARGET</span><span class="synSpecial">:</span> ${{ matrix.target.target }}
<span class="synIdentifier">TFACTION_JOB_TYPE</span><span class="synSpecial">:</span> terraform
<span class="synIdentifier">steps</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v4
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> aquaproj/aqua-installer@v2.2.0
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">aqua_version</span><span class="synSpecial">:</span> v2.21.0
<span class="synStatement">- </span><span class="synIdentifier">id</span><span class="synSpecial">:</span> github_app_token
<span class="synIdentifier">uses</span><span class="synSpecial">:</span> tibdex/github-app-token@v2
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">app_id</span><span class="synSpecial">:</span> ${{ secrets.APP_ID }}
<span class="synIdentifier">private_key</span><span class="synSpecial">:</span> ${{ secrets.PRIVATE_KEY }}
<span class="synComment"> # terraform init などの準備を行う Action</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> suzuki-shunsuke/tfaction/setup@v0.7.3
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">github_app_token</span><span class="synSpecial">:</span> ${{ steps.github_app_token.outputs.token }}
<span class="synComment"> # terraform apply を実行する Action</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> suzuki-shunsuke/tfaction/apply@v0.7.3
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">github_app_token</span><span class="synSpecial">:</span> ${{ steps.github_app_token.outputs.token }}
</pre>
<p>詳しくは<a href="https://suzuki-shunsuke.github.io/tfaction/docs/">ドキュメント</a>を参照していただくのが良いかと思いますが、上記のように tfaction を使うことで Terraform の CI/CD を GitHub Actions で実現することができます。</p>
<p>設定ファイルの配置など多少のルールはありますが、Terraform のコマンドの実行部分をすべて tfaction 側に任せることができるようになり、全体の workflow を簡潔に記述することができるようになっています。</p>
<p>以前は Terraform のコマンドを直接記述していたので、このように tfaction に任せてしまえるのは管理する上でとても楽になりました。</p>
<p>さらに tfaction は Terraform のコマンドの実行に加えて、以下のような機能もサポートされています。
ここからは tfaction がサポートしている機能のうち、便利に感じているいくつかの機能を紹介したいと思います。</p>
<h1 id="tfaction-の便利な機能">tfaction の便利な機能</h1>
<h3 id="複数の作業ディレクトリの並列処理">複数の作業ディレクトリの並列処理</h3>
<p>これは先ほどの話にも少しありましたが、tfaction は GitHub Actions の機能である <a href="https://docs.github.com/en/actions/using-workflows/about-workflows#using-a-build-matrix">build matrix</a> を使うことで複数の作業ディレクトリの plan や apply を並列に実行することに対応しています<a href="#f-993849a8" id="fn-993849a8" name="fn-993849a8" title="https://suzuki-shunsuke.github.io/tfaction/docs/feature/build-matrix">*2</a>。</p>
<p>弊チームで管理している Terraform のリポジトリは AWS で 2 つと k8s で 1 つの合計 3 つの作業ディレクトリでそれぞれ構成されているため、この機能によって CI/CD の実行時間を短縮することができています。</p>
<p>また、先ほどのサンプルコードでも使っている <a href="https://github.com/suzuki-shunsuke/tfaction/blob/main/list-targets/action.yaml">suzuki-shunsuke/tfaction/list-targets</a> という Action は変更された作業ディレクトリを取得し outputs として吐き出すための Action なので、これを使うことで変更された作業ディレクトリのみを並列で処理することができます。</p>
<p><figure class="figure-image figure-image-fotolife" title="変更のあった作業ディレクトリのみを並列に処理させることができる"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosuke-fujishiro/20231211/20231211152447.png" width="1200" height="409" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>変更のあった作業ディレクトリのみを並列に処理させることができる</figcaption></figure></p>
<h3 id="plan-apply-の実行結果を-tfcmt-を使って-PR-にコメントしてくれる">plan, apply の実行結果を tfcmt を使って PR にコメントしてくれる</h3>
<p><a href="https://suzuki-shunsuke.github.io/tfcmt/">tfcmt</a> とは Terraform の plan や apply の実行結果を PR にコメントしてくれる CLI ツールです。このコマンドを使うことで、以下のように PR に plan や apply の実行結果をコメントしてくれます。</p>
<p><figure class="figure-image figure-image-fotolife" title="tfcmt でのコメント"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosuke-fujishiro/20231213/20231213172253.png" width="1200" height="473" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>tfcmt でのコメント</figcaption></figure></p>
<p>これにより、毎回 CI/CD のログを確認をしに行かなくとも PR のコメントから実行結果を確認することができるようになります。
また、tfcmt は aqua 経由でインストールできるので、導入も比較的簡単です。</p>
<p>弊チームでは CircleCI を使っていた時から、上記のようなメリットもあり tfcmt を導入し利用していたのですが GitHub Actions に移行時に tfaction を導入したことで tfcmt のコマンドを直接記述する必要がなくなり aqua で tfcmt をインストールするだけで済むようになりました。</p>
<p>このあたりもまとめて tfaction 側に任せることができるのは非常に便利な点です。</p>
<h3 id="plan-の実行結果を元に-apply-を実行する">plan の実行結果を元に apply を実行する</h3>
<p>これは tfaction の大きな特徴の 1 つだと思っていますが、tfaction では plan の実行結果を S3 や GCS、v0.7.0 からは GitHub Actions の artifact に保存し、apply の実行時には plan の実行結果を取得して apply を実行するようになっています<a href="#f-e40119bc" id="fn-e40119bc" name="fn-e40119bc" title="https://suzuki-shunsuke.github.io/tfaction/docs/feature/plan-file">*3</a>。</p>
<p>これにより plan から apply までの間に何かしらの変更があった場合には apply が失敗するようになるので意図しない変更が反映されてしまうことを防ぎ、安心して運用することができます。</p>
<p>一度 apply が失敗すると再度 plan を実行して最新の実行結果を保存をしないと apply が実行できないということは運用者側が把握しておく必要がありますが、そこだけ気をつければとても便利な機能だと思っています。</p>
<p>他にも tfaction では Terraform のコマンドの実行に関する様々な機能を提供してくれているので、詳しくは以下のリポジトリやドキュメントを参照してください。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fsuzuki-shunsuke%2Ftfaction" title="GitHub - suzuki-shunsuke/tfaction: Framework for Monorepo to build high level Terraform Workflows by GitHub Actions" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/suzuki-shunsuke/tfaction">github.com</a></cite></p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fsuzuki-shunsuke.github.io%2Ftfaction%2Fdocs%2F" title="tfaction | tfaction" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://suzuki-shunsuke.github.io/tfaction/docs/">suzuki-shunsuke.github.io</a></cite></p>
<h1 id="tfaction-の導入時の工夫">tfaction の導入時の工夫</h1>
<p>最後に tfaction を導入する際に弊チームが行なったことを簡単に紹介したいと思います。</p>
<h3 id="GitHub-Flow-以外の開発フローで利用するために-TF_WORKSPACE-の切り替え">GitHub Flow 以外の開発フローで利用するために (TF_WORKSPACE の切り替え)</h3>
<p>tfaction は GitHub Flow を前提にしており<a href="#f-e2f573e7" id="fn-e2f573e7" name="fn-e2f573e7" title="https://zenn.dev/shunsuke_suzuki/articles/tfaction-introduction#tfaction-%E3%81%AE%E8%A6%81%E4%BB%B6">*4</a>、基本的に main ブランチと feature ブランチのみを想定しています。
そのため GitHub Flow を前提にしていないリポジトリの場合は tfaction を導入する際には注意が必要です。</p>
<p>弊チームで管理している Terraform のリポジトリは GitHub Flow を採用しておらず master, staging, feature ブランチの 3 つのブランチを想定しており、master と staging で terraform workspace が異なるため、tfaction を導入する際には以下のように plan や apply の実行時に workspace を切り替える必要がありました。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synComment"># こちらは plan の workflow での例です。plan の場合、base ブランチを見て workspace を切り替えています。</span>
<span class="synIdentifier">env</span><span class="synSpecial">:</span>
<span class="synIdentifier">TF_WORKSPACE</span><span class="synSpecial">:</span> ${{ fromJSON('{"master":"prd","staging":"stg"}')[github.base_ref] }}
</pre>
<p>これにより、master ブランチでの plan や apply の実行時には prd workspace が選択され、staging ブランチでの plan や apply の実行時には stg workspace が選択されるようになり、正しい workspace で plan や apply が実行されるようになりました。</p>
<h3 id="apply-後の関連-PR-の自動更新の停止">apply 後の関連 PR の自動更新の停止</h3>
<p>tfaction では PR がマージされ apply によって変更があった場合には関連するそのほかの PR のブランチを更新し plan を実行することで常に最新の状態を保つことができるようになっています<a href="#f-43561297" id="fn-43561297" name="fn-43561297" title="https://suzuki-shunsuke.github.io/tfaction/docs/feature/auto-update-related-prs">*5</a>。
そのため、他の PR が古い plan の実行結果を保持し続け、マージ時に apply が失敗するということを事前に防ぐことができます。</p>
<p>この機能は便利ではありつつ、弊チームでは現在使っておりません。というのも関連する PR の中にすぐにマージできないような PR あった場合についても plan の workflow が他の PR がマージされるごとに都度走ってしまうため GitHub Actions の利用時間を余計に消費してしまうことがあります。</p>
<p>では plan の実行結果を最新にするために弊チームではどうしているかというと GitHub の branch protection rule で代替しています。
branch protection rule で常に最新の base ブランチの変更を取り込んでいないとマージできないようにしているため、plan の実行結果が古い状態のまま apply して失敗する問題を防ぐことができています。</p>
<p>また先ほどのような、すぐにマージできない PR が他の PR のマージにより都度 plan の workflow が走ってしまい GitHub Actions の利用時間が余計に消費されてしまう問題についても、 branch protection rule を使うことでその PR のマージの手前のみ最新の base ブランチの変更を取り込んでからマージすることができるので極力 plan の workflow が走る回数を減らすことができています。</p>
<p>tfaction による関連 PR の自動更新機能を使わないようにするには tfaction の設定ファイルである tfaction-root.yaml に以下のような設定を追加する必要があります。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">update_related_pull_requests</span><span class="synSpecial">:</span>
<span class="synIdentifier">enabled</span><span class="synSpecial">:</span> <span class="synConstant">false</span>
</pre>
<p>ただ、tfaction ではこの機能を停止することを推奨していません<a href="#f-da126c3c" id="fn-da126c3c" name="fn-da126c3c" title="https://suzuki-shunsuke.github.io/tfaction/docs/feature/auto-update-related-prs#disable-this-feature">*6</a>。そのため、この機能を停止する場合は代替手段を検討した上で問題なく運用できれば停止するという形が良いかと思います。</p>
<h1 id="まとめ">まとめ</h1>
<p>今回は tfaction について紹介しました。tfaction は Terraform のコマンドの実行に関する様々な機能を提供しており、Terraform の CI/CD を GitHub Actions で実現する際にとても便利な Action だと思っています。</p>
<p>また、細かく機能を Action として分割しているため必要な機能のみを組み合わせて使って自分のチームの構成に合わせてカスタマイズすることもできます。</p>
<p>そのため弊チームでは今後も tfaction を使ってより良い CI/CD 環境を構築していきたいと思っています。</p>
<p>明日は吉岡さんの「iOSアプリのSWIFT_STRICT_CONCURRENCYをcompleteにした」 です。お楽しみに!</p>
<div class="footnote">
<p class="footnote"><a href="#fn-1bd8648e" id="f-1bd8648e" name="f-1bd8648e" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">GitHub Apps の設定や OIDC の設定などはここでは割愛しています。</span></p>
<p class="footnote"><a href="#fn-993849a8" id="f-993849a8" name="f-993849a8" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://suzuki-shunsuke.github.io/tfaction/docs/feature/build-matrix">https://suzuki-shunsuke.github.io/tfaction/docs/feature/build-matrix</a></span></p>
<p class="footnote"><a href="#fn-e40119bc" id="f-e40119bc" name="f-e40119bc" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://suzuki-shunsuke.github.io/tfaction/docs/feature/plan-file">https://suzuki-shunsuke.github.io/tfaction/docs/feature/plan-file</a></span></p>
<p class="footnote"><a href="#fn-e2f573e7" id="f-e2f573e7" name="f-e2f573e7" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://zenn.dev/shunsuke_suzuki/articles/tfaction-introduction#tfaction-%E3%81%AE%E8%A6%81%E4%BB%B6">https://zenn.dev/shunsuke_suzuki/articles/tfaction-introduction#tfaction-%E3%81%AE%E8%A6%81%E4%BB%B6</a></span></p>
<p class="footnote"><a href="#fn-43561297" id="f-43561297" name="f-43561297" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://suzuki-shunsuke.github.io/tfaction/docs/feature/auto-update-related-prs">https://suzuki-shunsuke.github.io/tfaction/docs/feature/auto-update-related-prs</a></span></p>
<p class="footnote"><a href="#fn-da126c3c" id="f-da126c3c" name="f-da126c3c" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://suzuki-shunsuke.github.io/tfaction/docs/feature/auto-update-related-prs#disable-this-feature">https://suzuki-shunsuke.github.io/tfaction/docs/feature/auto-update-related-prs#disable-this-feature</a></span></p>
</div>
kosuke-fujishiro
LLM を使って自分のおさいふ事情を把握してみる
hatenablog://entry/6801883189064117218
2023-12-11T11:40:00+09:00
2024-01-11T21:22:21+09:00 はじめに どんなものを作ったのか 解説 Gmail API を使ったメール取得について LangChain を使ったデータ分析について まとめ はじめに こんにちは takuji です。 この記事は Gunosy Advent Calendar 2023 の 11 日目の記事です。 10 日目の記事は村田さんの広告レコメンドの NN モデルワークフローに関する記事でした。 今回は takuji のクレカ利用の傾向を LLM で分析してもらう記事になります。 個人的にクレカのアプリの決済履歴の反映が遅いことで利用金額の把握が上手くできなくて困っていたこと、流行っている LLM を使った記事にした…
<ul class="table-of-contents">
<li><a href="#はじめに">はじめに</a></li>
<li><a href="#どんなものを作ったのか">どんなものを作ったのか</a></li>
<li><a href="#解説">解説</a><ul>
<li><a href="#Gmail-API-を使ったメール取得について">Gmail API を使ったメール取得について</a></li>
<li><a href="#LangChain-を使ったデータ分析について">LangChain を使ったデータ分析について</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h1 id="はじめに">はじめに</h1>
<p>こんにちは takuji です。</p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 11 日目の記事です。
10 日目の記事は村田さんの広告レコメンドの NN モデルワークフローに関する記事でした。</p>
<p>今回は takuji のクレカ利用の傾向を LLM で分析してもらう記事になります。</p>
<p>個人的にクレカのアプリの決済履歴の反映が遅いことで利用金額の把握が上手くできなくて困っていたこと、流行っている LLM を使った記事にしたいということから、それらを組み合わせた記事にしました笑。</p>
<h1 id="どんなものを作ったのか">どんなものを作ったのか</h1>
<p>Python で Gmail API を叩いて、<strong>クレカの即時利用通知メール</strong><a href="#f-5017a5da" id="fn-5017a5da" name="fn-5017a5da" title="クレカの即時利用通知メール は、三井住友カードやJCBカードなどであればセキュリティ対策の一環として利用可能な通知サービスになっています。">*1</a>を取得し、クレカの利用金額や利用先を SQLite に保存できるようにしました。
そして、LangChain から SQLite を操作して、おさいふ事情を把握できるようにしました。</p>
<p>具体的には、以下のように Python コードを実行すると、11月の利用金額の合計と利用金額の割合が大きい上位3件の利用先を出力してくれます。</p>
<pre class="code lang-sh" data-lang="sh" data-unlink>$ python cash_llm.py
The total expenditure <span class="synError">in</span> November is <span class="synConstant">128455</span>.
The top <span class="synConstant">10</span> usage locations and their proportions <span class="synError">in</span> the total expenditure are as follows:
<span class="synConstant">1</span>. SEVEN-ELEVEN: <span class="synConstant">23471</span> <span class="synPreProc">(</span><span class="synConstant">18</span><span class="synSpecial">.</span><span class="synConstant">27</span><span class="synSpecial">%</span><span class="synPreProc">)</span>
<span class="synConstant">2</span>. JR EAST MOBILE SUICA: <span class="synConstant">19432</span> <span class="synPreProc">(</span><span class="synConstant">15</span><span class="synSpecial">.</span><span class="synConstant">13</span><span class="synSpecial">%</span><span class="synPreProc">)</span>
<span class="synConstant">3</span>. AMAZON CO JP: <span class="synConstant">13400</span> <span class="synPreProc">(</span><span class="synConstant">10</span><span class="synSpecial">.</span><span class="synConstant">43</span><span class="synSpecial">%</span><span class="synPreProc">)</span>
...
</pre>
<p>現状英語で出力されていますが、LangChain の初期化の際に渡せるプロンプトを調整すれば日本語に直すこともできると思います。</p>
<p>今回の実装コードは、以下に公開しています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Ftakupista%2Fcash-llm-sample" title="GitHub - takupista/cash-llm-sample" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/takupista/cash-llm-sample">github.com</a></cite></p>
<h1 id="解説">解説</h1>
<h2 id="Gmail-API-を使ったメール取得について">Gmail API を使ったメール取得について</h2>
<p>Python で Gmail API を叩いて、クレカの即時利用通知メールを取得する手順について説明します。</p>
<ol>
<li>Google Cloud コンソールから Google Cloud プロジェクトを作成します。</li>
<li>作成したプロジェクトを開いて、Gmail API を有効にします。</li>
<li><a href="https://developers.google.com/identity/protocols/oauth2?hl=ja">OAuth 2.0</a> を使用して Google API にアクセスできるようにします。
<ul>
<li>OAuth 2.0 クライアント認証情報を取得してから、クライアントライブラリを使用して Google 承認サーバーにアクセストークンをリクエストして、レスポンスからトークンを抽出します。</li>
<li>そのトークンを使うと、Gmail API を call できるようになります。</li>
</ul>
</li>
<li><p>Python 用 Google クライアントライブラリをインストールします。</p>
<pre><code class="`sh"> pip install --upgrade google-api-python-client google-auth-httplib2 google-auth-oauthlib
</code></pre></li>
<li><p>Python で Gmail API メソッドの users.messages.list を呼び出し、message の ID 一覧を取得します。取得した message ID を使い、users.messages.get を呼び出し、メールの内容を取得します。</p>
<ul>
<li><a href="https://support.google.com/mail/answer/7190?hl=ja">検索演算子</a>と呼ばれる単語や記号を使用することで Gmail の検索結果を絞り込むことができます。</li>
<li>今回はクレカメールが欲しいので、送信者と期間、件名を指定しています。</li>
</ul>
</li>
</ol>
<p>最後に、取得したクレカメール本文から「利用日時・利用金額・利用先」を抽出して、SQLite に保存します。
実際の Python コードは分量が多くなっているため、Github 上の <a href="https://github.com/takupista/cash-llm-sample/blob/main/src/sqldb.py">Python コード <code>sqldb.py</code></a> を参照してください。</p>
<h2 id="LangChain-を使ったデータ分析について">LangChain を使ったデータ分析について</h2>
<p>Python ライブラリの LangChain の SQL Agents を使って、SQLite を操作してデータ分析する方法について説明します。</p>
<p>LangChain では、自然言語のプロンプトに基づいて SQL クエリを構築し実行するための SQL Chains と SQL Agents を提供しています。
後者の SQL Agents を使うことで、SQLite のスキーマやデータ中身の観測を経て自然言語のプロンプトに対する行動を実行し、回答を出力するまでを一貫しておこなってくれます。</p>
<p>SQL Agents を使うための手順は以下となります。</p>
<ol>
<li>OpenAI の API を使うために、API key を取得します。
<ul>
<li><a href="https://platform.openai.com/docs/quickstart?context=python">公式ページ</a>から取得することができますので、コピーして保存しておきます。</li>
</ul>
</li>
<li><p>LangChain や OpenAI の Python ライブラリをインストールします。</p>
<pre><code class="`sh"> pip install langchain langchain-experimental openai
</code></pre></li>
<li>SQL Agents は <code>create_sql_agent(..)</code> に LLM オブジェクトと <code>SQLDatabaseToolkit(..)</code>、AgentType を渡すことで作成します。</li>
</ol>
<p>実際の Python コードは以下のようになります。</p>
<pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">import</span> os
<span class="synPreProc">from</span> dotenv <span class="synPreProc">import</span> dotenv_values
<span class="synPreProc">from</span> langchain.agents <span class="synPreProc">import</span> create_sql_agent
<span class="synPreProc">from</span> langchain.agents.agent_toolkits <span class="synPreProc">import</span> SQLDatabaseToolkit
<span class="synPreProc">from</span> langchain.sql_database <span class="synPreProc">import</span> SQLDatabase
<span class="synPreProc">from</span> langchain.agents.agent_types <span class="synPreProc">import</span> AgentType
<span class="synPreProc">from</span> langchain.chat_models <span class="synPreProc">import</span> ChatOpenAI
<span class="synComment"># Get a dictionary of .env variables</span>
config = dotenv_values()
os.environ[<span class="synConstant">"OPENAI_API_KEY"</span>] = config[<span class="synConstant">"OPENAI_API_KEY"</span>]
<span class="synComment"># connect to test database</span>
db = SQLDatabase.from_uri(f<span class="synConstant">"sqlite:////{config['DB_PATH']}"</span>)
<span class="synComment"># Create a SQL agent using ‘gpt-4’ model with ZERO_SHOT_REACT_DESCRIPTION</span>
toolkit = SQLDatabaseToolkit(db=db, llm=ChatOpenAI(temperature=<span class="synConstant">0</span>, model=<span class="synConstant">"gpt-4"</span>))
agent_executor = create_sql_agent(
llm=ChatOpenAI(temperature=<span class="synConstant">0</span>, model=<span class="synConstant">"gpt-4"</span>),
toolkit=toolkit,
verbose=<span class="synIdentifier">True</span>,
agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
)
agent_executor.run(<span class="synConstant">"11月中の利用履歴をもとに支出合計を説明してから、利用先ごとの割合(支出合計に対する割合)で支出傾向について補足してください。"</span>)
</pre>
<p>上記の Python コードを実行すると、次のような出力が得られます。</p>
<pre class="code lang-sh" data-lang="sh" data-unlink>The total expenditure <span class="synError">in</span> November is <span class="synConstant">128455</span>.
The top <span class="synConstant">10</span> usage locations and their proportions <span class="synError">in</span> the total expenditure are as follows:
<span class="synConstant">1</span>. SEVEN-ELEVEN: <span class="synConstant">23471</span> <span class="synPreProc">(</span><span class="synConstant">18</span><span class="synSpecial">.</span><span class="synConstant">27</span><span class="synSpecial">%</span><span class="synPreProc">)</span>
<span class="synConstant">2</span>. JR EAST MOBILE SUICA: <span class="synConstant">19432</span> <span class="synPreProc">(</span><span class="synConstant">15</span><span class="synSpecial">.</span><span class="synConstant">13</span><span class="synSpecial">%</span><span class="synPreProc">)</span>
<span class="synConstant">3</span>. AMAZON CO JP: <span class="synConstant">13400</span> <span class="synPreProc">(</span><span class="synConstant">10</span><span class="synSpecial">.</span><span class="synConstant">43</span><span class="synSpecial">%</span><span class="synPreProc">)</span>
...
</pre>
<p>先月の私のお金の使い道がわかりやすく書かれているので、満足のいく出来になったかなと思います。
個人的に、セブンイレブンに 2 万円も使っていることに気付けてよかったです(節約します)。</p>
<p>この実装に関して参考にした資料は以下となります。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fpython.langchain.com%2Fdocs%2Fuse_cases%2Fqa_structured%2Fsql%23case-3-sql-agents" title="SQL | 🦜️🔗 Langchain" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://python.langchain.com/docs/use_cases/qa_structured/sql#case-3-sql-agents">python.langchain.com</a></cite></p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fmedium.com%2F%40keven1894%2Fchat-with-your-database-using-langchain-openai-llms-chatgpt-4-as-an-agent-to-query-ms-sql-47d0d6cfcf1e" title="Chat with your Database — Using Langchain + OpenAI LLMs ChatGPT 4 as an Agent to Query MS SQL…" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://medium.com/@keven1894/chat-with-your-database-using-langchain-openai-llms-chatgpt-4-as-an-agent-to-query-ms-sql-47d0d6cfcf1e">medium.com</a></cite></p>
<h1 id="まとめ">まとめ</h1>
<p>LangChain で遊んでみて、意図した通りの SQL クエリ を LLM に生成させるにはプロンプトを頑張らないといけないと感じました。
簡単な SQL クエリなら生成できることはわかったので、さらに複雑な分析も出来るようにしたいなと欲が出てきました。</p>
<p>明日は田辺さんが A/B の指標の見方について書くそうです!どうぞお楽しみに!</p>
<div class="footnote">
<p class="footnote"><a href="#fn-5017a5da" id="f-5017a5da" name="f-5017a5da" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><strong>クレカの即時利用通知メール</strong> は、三井住友カードやJCBカードなどであればセキュリティ対策の一環として利用可能な通知サービスになっています。</span></p>
</div>
tkjikun
インシデント発生時における初動対応の自動化
hatenablog://entry/6801883189062879242
2023-12-08T09:00:00+09:00
2024-01-11T21:22:04+09:00 これまでの課題感 どのように実現したか Slackワークフローとは Google App Scriptとは 実装内容 Slackワークフローの実装 トリガーの設定 ステップの追加 Google App Scriptの実装 Slack Appの設定 Slack Appの作成 OAuth Tokensの設定 Google App Scriptのスクリプトプロパティの設定 今後の課題 GASのバージョン管理 レガシーワークフローからの移行 まとめ こんにちは、テクノロジー本部 プロダクト開発部 SRE の koizumi です。 この記事は Gunosy Advent Calendar 2023 の…
<ul class="table-of-contents">
<li><a href="#これまでの課題感">これまでの課題感</a></li>
<li><a href="#どのように実現したか">どのように実現したか</a><ul>
<li><a href="#Slackワークフローとは">Slackワークフローとは</a></li>
<li><a href="#Google-App-Scriptとは">Google App Scriptとは</a></li>
<li><a href="#実装内容">実装内容</a><ul>
<li><a href="#Slackワークフローの実装">Slackワークフローの実装</a><ul>
<li><a href="#トリガーの設定">トリガーの設定</a></li>
<li><a href="#ステップの追加">ステップの追加</a></li>
</ul>
</li>
<li><a href="#Google-App-Scriptの実装">Google App Scriptの実装</a></li>
<li><a href="#Slack-Appの設定">Slack Appの設定</a><ul>
<li><a href="#Slack-Appの作成">Slack Appの作成</a></li>
<li><a href="#OAuth-Tokensの設定">OAuth Tokensの設定</a></li>
</ul>
</li>
<li><a href="#Google-App-Scriptのスクリプトプロパティの設定">Google App Scriptのスクリプトプロパティの設定</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#今後の課題">今後の課題</a><ul>
<li><a href="#GASのバージョン管理">GASのバージョン管理</a></li>
<li><a href="#レガシーワークフローからの移行">レガシーワークフローからの移行</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<p>こんにちは、テクノロジー本部 プロダクト開発部 SRE の koizumi です。</p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 8 日目の記事です。昨日の記事は 石川 さんの「<a href="https://tech.gunosy.io/entry/building-system-for-quick-online-experiment">オンライン実験を速く試すための基盤構築</a>」でした。</p>
<p>本記事では、インシデントフローの改善として、インシデント発生時における初動対応の自動化を行なった実例についてご紹介します。</p>
<p><figure class="figure-image figure-image-fotolife" title="実際に動いている例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204180732.png" width="737" height="753" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>実際に動いている例</figcaption></figure></p>
<h1 id="これまでの課題感">これまでの課題感</h1>
<p>弊社では、障害発生時における初動対応として以下のようなアクションを行なっており、それら全てを手動で行なっていました。</p>
<ul>
<li>障害発生報チャンネル<a href="#f-17cd59a5" id="fn-17cd59a5" name="fn-17cd59a5" title="障害発生時に社内に全体共有するためのSlackチャンネル">*1</a>にて障害内容および影響範囲等を共有</li>
<li>障害対応用のSlackチャンネルを作成</li>
<li>作成したSlackチャンネルにステークホルダーを招集</li>
<li>障害対応用のGoogleドキュメントを作成</li>
<li>障害対応を行う場所(Slack Huddle)の共有
<ul>
<li>Huddleを行うSlackチャネルが統一されていないため、対応している場所を共有する必要がある</li>
</ul>
</li>
</ul>
<p>障害時には障害対応に注力したいですし、これらの初動対応は毎回発生する定期作業のため、効率化できればと思いました。
また、初動対応のフローを標準化する意図でも自動化したい機運が高まりました。</p>
<h1 id="どのように実現したか">どのように実現したか</h1>
<p>前述した手動で行なっていた初動対応をSlackワークフローとGoogle App Scriptを用いて自動化した実例をご紹介します。</p>
<h2 id="Slackワークフローとは">Slackワークフローとは</h2>
<blockquote><p>2023 年 6 月 28 日、<a href="https://slack.com/intl/ja-jp/help/articles/16787711643411-%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%83%93%E3%83%AB%E3%83%80%E3%83%BC%E3%81%AE%E6%96%B0%E3%81%97%E3%81%84%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%B8%E3%81%AE%E3%83%AC%E3%82%AC%E3%82%B7%E3%83%BC%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%81%AE%E7%A7%BB%E8%A1%8C">ワークフロービルダーの新しいバージョンの提供</a>を開始しました。本記事でご紹介するものはレガシーワークフローになる点をご注意ください。</p></blockquote>
<p>Slackワークフローを作成すると、定型的な業務の手順を自動化することができます。
例えば、チームに新メンバーが入った際のウェルカムコメントなどの定型文の作成や毎朝定期実行されるSlackアクションなどを自動化できます。</p>
<p>このSlackワークフローを作成するには、ワークフロービルダーというSlackの機能を使います。
以下の公式ドキュメントに記載があるように、ワークフローにはトリガー、ステップ、変数、コラボレーターという機能があります。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fslack.com%2Fintl%2Fja-jp%2Fhelp%2Farticles%2F16962850225939-%25E3%2583%25AF%25E3%2583%25BC%25E3%2582%25AF%25E3%2583%2595%25E3%2583%25AD%25E3%2583%25BC%25E3%2582%2592%25E4%25BD%259C%25E6%2588%2590%25E3%2581%2599%25E3%2582%258B" title="ワークフローを作成する" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://slack.com/intl/ja-jp/help/articles/16962850225939-%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B">slack.com</a></cite></p>
<h2 id="Google-App-Scriptとは">Google App Scriptとは</h2>
<p>Googleが提供する各種サービス(Gmail、カレンダー、スプレッドシート、ドキュメントなど)の自動化や連携を行うためのローコード開発ツールです。
イメージとしては、ExcelのマクロなどのGoogle版といったものになるかと思います。</p>
<p>詳細は後述しますが、スプレッドシートに入力した値をもとにドキュメントを更新したり、Slackなどの別サービスとの連携による操作の自動化(Slackチャンネルの作成など)が可能になります。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fworkspace.google.co.jp%2Fintl%2Fja%2Fproducts%2Fapps-script%2F" title="Google Apps Script: Google Workspace を自動化、統合、拡張。" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://workspace.google.co.jp/intl/ja/products/apps-script/">workspace.google.co.jp</a></cite></p>
<h2 id="実装内容">実装内容</h2>
<p>それでは、前述したSlackワークフローとGoogle App Script(以下、GAS)を利用した実装内容をご紹介します。
具体的には以下のように実装しました。</p>
<ul>
<li>Slackのショートカットの開始をトリガーにワークフローが開始</li>
<li>ワークフローを開始したチャンネルにフォームを送信</li>
<li>担当者がフォームに回答</li>
<li>回答内容をGASが実装されたスプレッドシートに反映</li>
<li>反映された値をもとに各初動対応をGASが実行</li>
</ul>
<p>大きく分けてSlackワークフロー側とGAS側の実装に分けられます。
それぞれの実装内容についてご紹介します。</p>
<h3 id="Slackワークフローの実装">Slackワークフローの実装</h3>
<h4 id="トリガーの設定">トリガーの設定</h4>
<p>トリガーには様々なアクションを設定できますが、今回は指定のSlackチャンネルでショートカットを開始したことをトリガーに設定します。
これにより、チャンネルに参加している誰かが設定したショートカットを開始すると、ワークフローが開始されるようになります。
<figure class="figure-image figure-image-fotolife" title="トリガーの設定内容"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204110547.png" width="506" height="549" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>トリガーの設定内容</figcaption></figure></p>
<h4 id="ステップの追加">ステップの追加</h4>
<p>続いて、Slackワークフローにステップを追加していきます。
今回はショートカットをクリックしたチャンネルにフォームを送信し、その回答内容をもとにスプレッドシートを更新します。</p>
<p>まずはフォームを送信できるようにするために、ワークフロービルダーの画面の<code>ステップを追加</code>から<code>フォームを送信する</code>のステップを追加します。
このステップでは、以下のようにタイトルと質問/答えを設定しています。
回答形式には、1行テキスト/段落/ドロップダウン/メンバーを選択/チャンネルまたはDMを選択 の5つから選択できます。
この時点で、指揮と記録担当を明示するためにメンバーを選択して回答できるようにしています。
<figure class="figure-image figure-image-fotolife" title="フォームを送信するステップの設定内容"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204131315.png" width="501" height="454" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>フォームを送信するステップの設定内容</figcaption></figure></p>
<p>次に、フォームの回答内容をスプレッドシートに反映させます。
同じく<code>ステップを追加</code>から、<code>Update a spreadsheet row</code>を選択します。このステップは指定したスプレッドシートを自動更新することができます。</p>
<p>そして、連携するスプレッドシートを新規に作成しておきます。記入内容は以下のようにしています。
<figure class="figure-image figure-image-fotolife" title="スプレッドシートの内容"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204132938.png" width="553" height="68" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>スプレッドシートの内容</figcaption></figure>
作成したステップの設定画面にて、自身のGoogleアカウントを接続して、作成したスプレッドシートと更新したいシートを選択します。</p>
<p>次に、ステップで更新する行を識別させるためにスプレッドシートのカラム名とその値を入力します。
識別することができれば問題ないので、先ほどのスプレッドシートの画像の一番左のカラムとセル値(columnとhoge)のようにする必要はありません。
ステップ側で指定のカラムとそのセル値を選択すれば、更新する行をステップが識別してくれます。</p>
<p>そして、更新するカラム(<code>Column name</code>)とその値(<code>Value</code>)を設定します。
<code>Value</code>には前のステップに設定したフォームの回答内容を設定することができます。<code>変数を挿入する</code>から指定の回答内容を選択できます。
<figure class="figure-image figure-image-fotolife" title="Update these columnsの設定内容"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204134022.png" width="486" height="243" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Update these columnsの設定内容</figcaption></figure></p>
<p>スプレッドシートの各カラムに入力する値を同じように設定します。
これで指定のチャンネルでショートカットを開始すると、フォームが送信され、その回答内容がスプレッドシートに反映されるようになりました。</p>
<p>次は、GASを利用して、更新されたスプレッドシートをもとにそれぞれの初動対応を自動化していきます。</p>
<h3 id="Google-App-Scriptの実装">Google App Scriptの実装</h3>
<p>作成したスプレッドシートの上部タブの<code>拡張機能</code>から<code>Apps Script</code>をクリックすると、GASの画面に遷移します。
ここで、自動化するスクリプトを記述していきます。</p>
<p>GASはJavaScriptをベースにした言語であるため、エンジニアの方でも、そうでない方でも書きやすいと思います。
GASの文法などはインターネットに様々な優良な記事がありますので、そちらを参照してください。</p>
<p>GASではそれぞれの関数で一つの機能を実装していきます。
具体的には以下の通りです。</p>
<ul>
<li>テンプレートドキュメントをもとに障害対応ドキュメントを作成</li>
<li>障害対応Slackチャンネルの作成</li>
<li>召集したいユーザグループのメンバーを取得</li>
<li>取得したメンバーを作成したSlackチャンネルのメンバーに追加</li>
<li>障害対応Google Meetの作成</li>
<li>作成したGoogle MeetのURLをSlackに投稿</li>
<li>作成したSlackチャンネルをSlackに投稿</li>
<li>作成した障害対応ドキュメントのURLをSlackに投稿</li>
</ul>
<p>コードの詳細は以下のGithub Gistで公開しています。
Google DriveのファイルIDやSlackチャンネルのIDなどは適宜変更してください。</p>
<p><a href="https://gist.github.com/koizumi7010/87d9191f16839ef43c996e60576d442a">Automated initial incident response · GitHub</a></p>
<h3 id="Slack-Appの設定">Slack Appの設定</h3>
<h4 id="Slack-Appの作成">Slack Appの作成</h4>
<p>Slack投稿などを行うSlack Appを作成します。<a href="https://api.slack.com/apps">Your Apps</a> から <code>Create New App</code> をクリックして、Slack Appを作成します。
特にこだわりがなければ <code>From scratch</code> を選択してください。
<figure class="figure-image figure-image-fotolife" title="Slack Appの新規作成1"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204163124.png" width="974" height="535" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Slack Appの新規作成1</figcaption></figure>
<figure class="figure-image figure-image-fotolife" title="Slack Appの新規作成2"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204163243.png" width="508" height="410" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Slack Appの新規作成2</figcaption></figure></p>
<h4 id="OAuth-Tokensの設定">OAuth Tokensの設定</h4>
<p>次に、作成したSlack Appで<code>OAuth Tokens</code>を設定します。
<code>Add features and functionality</code>内の<code>Permissions</code>をクリックします。
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204163925.png" width="1200" height="1035" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p><code>Scopes</code>セクションに移動し、<code>Add an OAuth Scope</code>をクリックし、適当なScopeを設定します。(<code>chat:write</code>など)
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204164253.png" width="1200" height="1141" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>OAuth Tokenを取得するには、<code>OAuth & Permissions</code>の<code>OAuth Tokens for Your Workspace</code>項目の<code>Install to Workspace</code>をクリックし、権限の許可を行うと取得できます。
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204164902.png" width="660" height="522" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>加えて、後述するGASのスクリプトプロパティに設定する<code>Verification_Token</code>の値を確認します。
<code>Basic Information</code>の<code>App Credentials</code>項目で確認できます。
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204165249.png" width="667" height="647" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="Google-App-Scriptのスクリプトプロパティの設定">Google App Scriptのスクリプトプロパティの設定</h3>
<p>GASのスクリプトプロパティにこれらの<code>OAuth Tokens</code>と<code>Verification_Token</code>の値を、それぞれ<code>SLACK_TOKEN</code>と<code>SLACK_VERIFICATION_TOKEN</code>というプロパティ名で設定します。
スクリプトプロパティは左サイドバーの<code>プロジェクトの設定</code>画面で設定することができます。
<figure class="figure-image figure-image-fotolife" title="スクリプトプロパティの設定"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231204/20231204161622.png" width="787" height="384" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>スクリプトプロパティの設定</figcaption></figure></p>
<p>以上が主な実装および設定内容となります。</p>
<h1 id="今後の課題">今後の課題</h1>
<p>今後解決すべき課題としては以下のようなものが挙げられます。</p>
<h4 id="GASのバージョン管理">GASのバージョン管理</h4>
<p>実装したGASのバージョン管理は一定GAS側で標準装備されていますが、他のコードと同じようにGithubリポジトリで管理したい思いがあります。
現状、共有フォルダに置くことによって組織内のメンバーであれば誰でも編集することは可能ですが、バージョン管理はGithubに統一した方が認知負荷は軽減できると思います。
また、CIなどで一定のテストを行い、GASに変更内容を反映できればベストだと思います。</p>
<h4 id="レガシーワークフローからの移行">レガシーワークフローからの移行</h4>
<p>少し前述しましたが、作成を行っていた時期がSlackの大幅な刷新時期と重なり、作成したワークフローがレガシーワークフローという名称へ変更になりました。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fslack.com%2Fintl%2Fja-jp%2Fhelp%2Farticles%2F16787711643411-%25E3%2583%25AF%25E3%2583%25BC%25E3%2582%25AF%25E3%2583%2595%25E3%2583%25AD%25E3%2583%25BC%25E3%2583%2593%25E3%2583%25AB%25E3%2583%2580%25E3%2583%25BC%25E3%2581%25AE%25E6%2596%25B0%25E3%2581%2597%25E3%2581%2584%25E3%2583%2590%25E3%2583%25BC%25E3%2582%25B8%25E3%2583%25A7%25E3%2583%25B3%25E3%2581%25B8%25E3%2581%25AE%25E3%2583%25AC%25E3%2582%25AC%25E3%2582%25B7%25E3%2583%25BC%25E3%2583%25AF%25E3%2583%25BC%25E3%2582%25AF%25E3%2583%2595%25E3%2583%25AD%25E3%2583%25BC%25E3%2581%25AE%25E7%25A7%25BB%25E8%25A1%258C" title="ワークフロービルダーの新しいバージョンへのレガシーワークフローの移行" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://slack.com/intl/ja-jp/help/articles/16787711643411-%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%83%93%E3%83%AB%E3%83%80%E3%83%BC%E3%81%AE%E6%96%B0%E3%81%97%E3%81%84%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%B8%E3%81%AE%E3%83%AC%E3%82%AC%E3%82%B7%E3%83%BC%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%81%AE%E7%A7%BB%E8%A1%8C">slack.com</a></cite></p>
<p>2024 年 9 月 12 日以降、移行されていないレガシーワークフローは動作を停止し、アクセスできなくなるようなので、新しいバージョンのワークフローへの移行が必要になっています。
今回作成したワークフローもレガシーワークフローであるため、新たに作成する場合は新しいバージョンのワークフローでの実装をお勧めします。</p>
<h1 id="まとめ">まとめ</h1>
<p>本記事では、インシデント発生時における初動対応をSlackワークフローとGoogle App Scriptを用いて自動化した実例についてご紹介いたしました。
比較的簡単な実装内容で、手軽に日々の作業を自動化できるので、気になった方は是非使ってみてください。</p>
<p>明日は takaki otake さんの「パーソナライズド動画推薦システムをつくる」についてです!お楽しみに!</p>
<div class="footnote">
<p class="footnote"><a href="#fn-17cd59a5" id="f-17cd59a5" name="f-17cd59a5" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">障害発生時に社内に全体共有するためのSlackチャンネル</span></p>
</div>
kozumikun
オンライン実験を速く試すための基盤構築
hatenablog://entry/6801883189060639125
2023-12-07T12:00:00+09:00
2024-01-11T21:22:19+09:00 この記事は [Gunosy Advent Calendar 2023](https://adventar.org/calendars/9182) の 7 日目の記事です。昨日の記事は johnmanjiro さんの『tokio ベースの Rust 向け fluentd クライアントを作りました』でした。
先日、オンライン実験を速く試すための基盤を構築し、実際に A/B テストを開始しました。この記事では初めに弊社独自の A/B 管理基盤やサービス間の依存関係に由来する課題を整理したうえで、どのような解決策を実装したかについて紹介します。
<p>こんにちは。データサイエンス部の石川です。</p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 7 日目の記事です。昨日の記事は johnmanjiro さんの『<a href="https://tech.gunosy.io/entry/tokio_fluent">tokio ベースの Rust 向け fluentd クライアントを作りました</a>』でした。</p>
<p>先日、オンライン実験を速く試すための基盤を構築し、実際に A/B テストを開始しました。この記事では初めに弊社独自の A/B 管理基盤やサービス間の依存関係に由来する課題を整理したうえで、どのような解決策を実装したかについて紹介します。</p>
<ul class="table-of-contents">
<li><a href="#背景">背景</a><ul>
<li><a href="#広告関連サービスの概要と依存関係">広告関連サービスの概要と依存関係</a></li>
<li><a href="#施策の検証への要求">施策の検証への要求</a></li>
</ul>
</li>
<li><a href="#課題">課題</a></li>
<li><a href="#解決策">解決策</a></li>
<li><a href="#ユーザーリスト広告リストの詳細">ユーザーリスト・広告リストの詳細</a></li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h2 id="背景">背景</h2>
<p>はじめに広告関連のサービスの概要と、それぞれのサービスの依存関係について説明します。
次に、施策の検証において求められていることについて整理します。</p>
<h3 id="広告関連サービスの概要と依存関係">広告関連サービスの概要と依存関係</h3>
<p>広告配信に関連するサービスはマイクロサービス化されており、中でも A/B テスト管理基盤、広告配信サーバ、および広告スコアリングサーバが広告配信の最適化の A/B テストに大きく関わっています。</p>
<p><figure class="figure-image figure-image-fotolife" title="広告関連リポジトリの依存関係"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/takaishikawa42/20231124/20231124173522.png" width="1200" height="679" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>広告関連リポジトリの依存関係</figcaption></figure></p>
<p><strong><u>A/B テスト管理基盤</u></strong></p>
<p>A/B テスト管理基盤は、広告関連のサービスにおいて実施される A/B テストを効果的に管理するための基盤です。</p>
<p>基本的な機能としては yaml ファイルを使用して A/B テストを設定できるものがあります。バリデーション機能や共通部分が多い A/B テストの定義コードの自動生成のために、Go 言語を用いています。</p>
<p>このため、同じく Go 言語で書かれている、A/B テストを利用するサービス(広告配信サーバ)に対してプライベートパッケージとして A/B テストの定義コードを提供することができます。このアプローチにより、サービス側では yaml の読み込みに必要な構造体を再定義する必要がなく、コードを共通化することが可能となります。</p>
<p>詳細は以下のブログ記事で紹介されています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Fabtest-management-tool" title="A/B テストの管理基盤刷新とレポートの自動化(基盤編) - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/abtest-management-tool">tech.gunosy.io</a></cite></p>
<p><strong><u>広告配信サーバと広告スコアリングサーバ</u></strong></p>
<p>広告配信サーバは、クライアントからの広告リクエストに対して広告を配信することに責務を持ったサービスです。</p>
<p>一方、広告スコアリングサーバは、広告配信を最適化するため、 ML モデルを活用して広告のクリック率 (CTR) やコンバージョン率 (CVR) の予測を行い、広告の入札単価やスコアの計算を行います。</p>
<p>これらのサーバ間の通信は gRPC が使用され、データのフォーマットは protobuf ファイルに定義されたスキーマに従います。</p>
<p>詳細については以下のブログ記事で詳しく解説されています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Freplace_ads_scoring_server" title="広告のスコアリングサーバをフルリプレイスしました - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/replace_ads_scoring_server">tech.gunosy.io</a></cite></p>
<p>つまり、広告配信サーバは A/B テスト管理基盤で定義されたパッケージと、広告スコアリングサーバの protobuf ファイルから生成されたコードに依存していることになります。</p>
<h3 id="施策の検証への要求">施策の検証への要求</h3>
<p>施策の検証において、オフライン実験とオンライン用のコード開発工数が同程度である状況が多く見受けられます。具体的な施策の効果を確認するためには、実質プロダクションリリースに必要な工数がかかることが多く、そのためには手軽かつ迅速に情報を得る手段が求められます。ROI を最大化するためには、工数のかかる施策の効果の確度を向上させる必要があります。</p>
<p>こうした課題に対処するために、長期的な運用を考慮した工数が大きい実装に取り組む前に、手軽に情報を増やすための実験が有効です。オンラインでの実験を通じて、施策の効果を確かめることは、実際の運用において重要な意味を持ちます。また、実験の立案者による理想像の相違からくる課題にも、実施前に手軽な実験を行うことで迅速かつ効果的に対応できます。</p>
<h2 id="課題">課題</h2>
<p>A/B テストの実施において、A/B テスト管理基盤、広告配信サーバ、広告スコアリングサーバがそれぞれ異なるリポジトリに分かれていることから、一つのロジックを適用する A/B テストを試す場合に、変更が必要なリポジトリが 3 つにも及び、手順が複雑かつ時間がかかるという問題が生じています。</p>
<p><strong><u>具体的な手順</u></strong></p>
<ol>
<li>A/B テスト管理基盤で A/B テストのパラメータの構造体を定義する</li>
<li>広告配信サーバで A/B テスト管理基盤のパッケージを読み込む</li>
<li>広告スコアリングサーバで広告配信サーバから受け取る A/B パラメータを protobuf ファイルに追加する</li>
<li>広告配信サーバで広告スコアリングサーバへのリクエストに A/B パラメータを追加する</li>
<li>広告スコアリングサーバでロジックを実装する</li>
<li>A/B テスト管理基盤で A/B 設定に関する yaml を追加する</li>
</ol>
<p>上記のような煩雑な手順を踏む必要があり、また仕組みが整備されてないために、実際に以下のような A/B テストを試す際にもいくつかの課題が生じました。</p>
<p>実施した A/B テストは以下の通りです。</p>
<ul>
<li>特定のユーザー群に対して、特定のロジック(ある変数に対して単純な関数を適用する)を適用する A/B テストを実施した</li>
<li>デプロイの実装コストを避けつつオンラインで検証するため、ユーザーリストをアプリケーションに内包させる形でデプロイすることにした</li>
</ul>
<p>これによって、以下のような課題が生じました。</p>
<ul>
<li>施策のリターンが実際にどれくらい見込めるか不明瞭だった</li>
<li>データとコードが分離されていないため、ユーザーリストの更新にはアプリケーションのデプロイが必要だった
<ul>
<li>1 回の CI に結構な時間がかかる事情のため、ステージング環境とプロダクション環境への 2 回の CI が走ることで、ユーザーリストの更新をするだけの作業にも時間がかかった</li>
</ul>
</li>
</ul>
<h2 id="解決策">解決策</h2>
<p>上記のような課題に対して、以下のような解決策を考えました。</p>
<ul>
<li>A/B テストの「枠」をあらかじめ用意しておく</li>
<li>データは S3 にアップロードされれば、広告スコアリングサーバが利用できるようにしておく</li>
</ul>
<p><figure class="figure-image figure-image-fotolife" title="オンライン実験を速く試すための基盤構成図"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/takaishikawa42/20231124/20231124235119.png" width="1200" height="459" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>オンライン実験を速く試すための基盤構成図</figcaption></figure></p>
<p>上図について説明します。</p>
<p>まず図の左側では、あらかじめ A/B テスト管理基盤で適当にいくつかのロジックを持たない空の A/B テストを用意しておきます。
合わせて A/B テストの定義コードを広告配信サーバにプライベートパッケージとして利用可能にしておきます。</p>
<p>また広告配信サーバでは、広告スコアリングサーバへの gRPC リクエストに A/B パラメータを追加する処理を実装しておきます。
このときに、パラメータの一つとしてロジックのオンオフを制御するフィールドを追加しておきます。
これによって A/B テストの実施における準備の工数を削減することができます。</p>
<p>続いて図の右側では、まず最初にユーザーリスト・広告リストを生成する SQL クエリをレビューしてもらいます。
SQL クエリでリストを生成するのは、広告ロジックチームだけでなく、SQL を主に利用する BI チームも容易にリストを生成できるためです。</p>
<p>問題なければ、Digdag を使ったバッチ処理で S3 の特定のパスに CSV 形式でユーザーリスト・広告リストがアップロードされます。
必要なユーザーリスト・広告リストが揃ったら、別の Digdag のワークフローで CSV ファイルを SQLite のデータベースファイルに書き込む処理を行います。</p>
<p>あとは 広告スコアリングサーバが書き込み済みの SQLite のデータベースファイルを定期的に読み込むようにするだけです。</p>
<p>これによって、以下のようなメリットを得ることができました。</p>
<ul>
<li>デプロイの実装コストを避けつつオンラインで検証することができた</li>
<li>データとコードが分離されたため、ユーザーリスト・広告リストの更新にアプリケーションのデプロイが不要になり、更新にかかる時間が短縮された</li>
</ul>
<h2 id="ユーザーリスト広告リストの詳細">ユーザーリスト・広告リストの詳細</h2>
<p>ユーザーリスト・広告リストの詳細について、具体的にいくつかの議論事項を取り上げて、どのように実装することに決定したかを整理します。</p>
<p><strong><u>全ユーザー・全広告を表現したいときどうするか?</u></strong></p>
<p>ロジックを適用させたいユーザー・広告 ID を列挙する必要があるのですが、全てのユーザー・広告を表現したい場合にはメモリやファイルの容量が足りなくなる可能性があります。</p>
<p>そこで以下のような選択肢が考えました。</p>
<ol>
<li>全 ID を列挙する</li>
<li>特殊な割り当て(e.g. ID 列に -1 を入れる)を作る</li>
<li>ID を指定するファイルを作成しない(指定しないことで全件を対象にする)</li>
</ol>
<p>それぞれの選択肢について、メリット・デメリットを整理します。</p>
<ol>
<li><p>全 ID を列挙する</p>
<ul>
<li>Pros
<ul>
<li>シンプル</li>
</ul>
</li>
<li>Cons
<ul>
<li>ユーザーリストのメモリ消費が大きすぎる</li>
<li>時間経過で漏れが出てくる</li>
</ul>
</li>
</ul>
</li>
<li><p>特殊な割り当て(e.g. ID 列に -1 を入れる)を作る</p>
<ul>
<li>Pros
<ul>
<li>複数のユーザー・広告の組み合わせを用意する場合、特定の組み合わせに対してのみ全件を対象にするといった表現が可能</li>
</ul>
</li>
<li>Cons
<ul>
<li>特殊な割り当てに関する知識を必要とする</li>
<li>実装が複雑になる</li>
</ul>
</li>
</ul>
</li>
<li><p>ID を指定するファイルを作成しない(指定しないことで全件を対象にする)</p>
<ul>
<li>Pros
<ul>
<li>シンプル</li>
</ul>
</li>
<li>Cons
<ul>
<li>ファイルが置かれてないのが意図的なのかミスなのか判定できない</li>
<li>ミスの場合想定外にロジック適用対象が増えてしまう</li>
<li>複数のユーザー・広告の組み合わせを用意する場合、特定の組み合わせに対してのみ全件を対象にするといった表現ができない</li>
</ul>
</li>
</ul>
</li>
</ol>
<p>以上から、複数ユーザー・広告の組み合わせを表現できる 2 番の特殊な割り当てを作ることにしました。</p>
<p><strong><u>API はどのようにしてユーザーリスト・広告リストを持つのか?</u></strong></p>
<p>API はユーザーリスト・広告リストを持つ必要があります。これは、ユーザーリスト・広告リストを持つことで、特定のユーザー・広告に対してのみロジックを適用することができるためです。</p>
<p>ユーザーリスト・広告リストを持つ方法としては以下のような選択肢が考えられます。</p>
<ol>
<li>全部メモリに乗せる</li>
<li>ファイル DB (SQLite etc.) を使う</li>
<li>Redis Cluster に突っ込む</li>
</ol>
<p>それぞれの選択肢について、メリット・デメリットを整理します。</p>
<ol>
<li><p>全部メモリに乗せる</p>
<ul>
<li>Pros
<ul>
<li>実装はシンプルになる</li>
</ul>
</li>
<li>Cons
<ul>
<li>レコード数は減らないのでカラム数による</li>
<li>ランタイムのメモリリソースが多くかかる</li>
<li>組み合わせが増えた場合に対応が難しくなる</li>
</ul>
</li>
</ul>
</li>
<li><p>ファイル DB (SQLite etc.) を使う</p>
<ul>
<li>Pros
<ul>
<li>メモリの消費量は少ない</li>
<li>全件メモリに乗せるわけではない</li>
<li>クエリで対象を簡単にフィルタリング出来る</li>
</ul>
</li>
<li>Cons
<ul>
<li>スピードが問題になる可能性はある</li>
<li>メモリ上の cache が必要になる可能性がある</li>
</ul>
</li>
</ul>
</li>
<li><p>Redis Cluster に突っ込む</p>
<ul>
<li>Pros
<ul>
<li>メモリの消費量は少ない</li>
<li>全件メモリに乗せるわけではない</li>
</ul>
</li>
<li>Cons
<ul>
<li>ランニングコストが掛かる</li>
<li>I/O がボトルネックになる可能性がある</li>
</ul>
</li>
</ul>
</li>
</ol>
<p>以上から、手軽だがメモリ消費量の少ない 2 番のファイル DB を使うことにしました。</p>
<h2 id="まとめ">まとめ</h2>
<p>この記事では、オンライン実験を速く試すための基盤構築について紹介しました。</p>
<p>具体的にはオンライン実験を速く試すための基盤構築について、弊社独自の A/B 管理基盤やサービス間の依存関係に由来する課題を整理したうえで、どのような解決策を実装したかについて書きました。</p>
<p>今後は、実際に A/B テストを開始した結果や、その後の課題の対応について紹介できればと思います。</p>
<p>次回は koizumi さんの『インシデント発生時における初動対応の自動化』です。お楽しみに!</p>
takaishikawa42
tokio ベースの Rust 向け fluentd クライアントを作りました
hatenablog://entry/6801883189063108726
2023-12-06T11:30:00+09:00
2024-01-11T21:23:28+09:00 この記事は Gunosy Advent Calendar 2023 の6日目の記事です。昨日の記事は hyamamoto さんの稼働中データ基盤を安全に dbt 移行する仕組みでした。 こんにちは。プロダクト開発部 Ads チームの johnmanjiro です。普段は広告配信の API や管理画面を作っています。 先日、趣味で Rust 向けの fluentd クライアントを作ったのでご紹介します*1。 Rust での fluentd クライアントの状況と作成の経緯 使い方 初期化 ログの送信 サポートしているデータ型 構成 Client Worker forward プロトコル 構成のまと…
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の6日目の記事です。昨日の記事は hyamamoto さんの<a href="https://tech.gunosy.io/entry/migrate-to-dbt">稼働中データ基盤を安全に dbt 移行する仕組み</a>でした。</p>
<p>こんにちは。プロダクト開発部 Ads チームの <a href="https://github.com/johnmanjiro13">johnmanjiro</a> です。普段は広告配信の API や管理画面を作っています。</p>
<p>先日、趣味で Rust 向けの <a href="https://www.fluentd.org/">fluentd</a> クライアントを作ったのでご紹介します<a href="#f-7261661b" id="fn-7261661b" name="fn-7261661b" title="趣味なので社内導入などはされていません">*1</a>。</p>
<ul class="table-of-contents">
<li><a href="#Rust-での-fluentd-クライアントの状況と作成の経緯">Rust での fluentd クライアントの状況と作成の経緯</a></li>
<li><a href="#使い方">使い方</a><ul>
<li><a href="#初期化">初期化</a></li>
<li><a href="#ログの送信">ログの送信</a></li>
<li><a href="#サポートしているデータ型">サポートしているデータ型</a></li>
</ul>
</li>
<li><a href="#構成">構成</a><ul>
<li><a href="#Client">Client</a></li>
<li><a href="#Worker">Worker</a></li>
<li><a href="#forward-プロトコル">forward プロトコル</a></li>
<li><a href="#構成のまとめ">構成のまとめ</a></li>
</ul>
</li>
<li><a href="#終わりに">終わりに</a></li>
</ul>
<h1 id="Rust-での-fluentd-クライアントの状況と作成の経緯">Rust での fluentd クライアントの状況と作成の経緯</h1>
<p>本題に入る前に、Rust における fluentd クライアントの状況を簡単にご紹介します。</p>
<p>2023年12月現在、Rust には公式による fluentd クライアントは存在しません<a href="#f-61cfe423" id="fn-61cfe423" name="fn-61cfe423" title="https://github.com/fluent">*2</a>。
また、<a href="https://crates.io/">Crates.io</a> で <a href="https://crates.io/search?q=fluent">fluent と検索すると</a>多くのクレートがヒットしますが、そのほとんどが <a href="https://projectfluent.org/">Fluent</a> という別プロジェクトのものであり、fluentd とは関係がありません。</p>
<p><a href="https://crates.io/search?q=fluentd">fluentd</a> や <a href="https://crates.io/search?q=fluent%20log">fluent log</a> で検索することでいくつかの fluentd クライアントを見つけることができますが、いずれも下記のような懸念があります。</p>
<ul>
<li>多言語に存在する公式の fluentd クライアントと使用感が異なる</li>
<li>非同期処理に対応していない</li>
</ul>
<p>このような状況のため、非同期処理に対応していて公式の fluentd クライアントと似た感覚で使えるものが欲しいと思い、非同期処理で広く使われている <a href="https://tokio.rs/">tokio</a> をベースとした fluentd クライアントを作成することにしました。</p>
<p>そうして作成したのが tokio-fluent です。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fjohnmanjiro13%2Ftokio-fluent" title="GitHub - johnmanjiro13/tokio-fluent: A fluentd client using tokio" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/johnmanjiro13/tokio-fluent">github.com</a></cite></p>
<h1 id="使い方">使い方</h1>
<h2 id="初期化">初期化</h2>
<p>tokio-fluent では現在 TCP と Unix Domain Socket をサポートしており、それぞれ下記のように使用することができます。</p>
<pre class="code lang-rust" data-lang="rust" data-unlink><span class="synComment">// TCP</span>
<span class="synStatement">let</span> client <span class="synStatement">=</span> <span class="synPreProc">Client</span><span class="synSpecial">::</span><span class="synIdentifier">new_tcp</span>(
<span class="synConstant">"127.0.0.1:24224"</span>.<span class="synIdentifier">parse</span>().<span class="synIdentifier">unwrap</span>(),
<span class="synType">&</span>Config {..<span class="synType">Default</span><span class="synSpecial">::</span><span class="synIdentifier">default</span>()}
)
.await
.<span class="synIdentifier">unwrap</span>();
<span class="synComment">// Unix Domain Socket</span>
<span class="synStatement">let</span> client_unix <span class="synStatement">=</span> <span class="synPreProc">Client</span><span class="synSpecial">::</span><span class="synIdentifier">new_unix</span>(
<span class="synConstant">"/path/to/fluentd.sock"</span>,
<span class="synType">&</span>Config {..<span class="synType">Default</span><span class="synSpecial">::</span><span class="synIdentifier">default</span>()}
)
.await
.<span class="synIdentifier">unwrap</span>();
</pre>
<p>公式と違い TCP と Unix Domain Socket で関数が異なるのは、Config で利用する値を共通で利用するものに限定するためです。</p>
<p>たとえば Go 向けの <a href="https://github.com/fluent/fluent-logger-golang">fluent-logger-golang</a> では Config に <code>FluentNetwork</code> というフィールドがあり、設定値によって TCP と Unix Domain Socket を切り替えています。
しかし、Config で設定すると TCP と Unix Domain Socket で利用する設定値が混在し複雑さが増すため<a href="#f-734ab2be" id="fn-734ab2be" name="fn-734ab2be" title="たとえば TCP でしか利用せず Unix Domain Socketでは無視されるフィールドなどが生まれます">*3</a>、採用していません。</p>
<h2 id="ログの送信">ログの送信</h2>
<p>ログを送信する際は、クライアントの <code>send</code> メソッドを利用します。
第一引数がタグ、第二引数がログの内容を表すレコードです。</p>
<p>ここで利用している <code>Map</code> と <code>Value</code> は <code>tokio_fluent::record</code> モジュールに定義されているものです。</p>
<pre class="code lang-rust" data-lang="rust" data-unlink><span class="synStatement">use</span> <span class="synPreProc">std</span><span class="synSpecial">::</span><span class="synPreProc">collections</span><span class="synSpecial">::</span>HashMap;
<span class="synStatement">use</span> <span class="synPreProc">tokio_fluent</span><span class="synSpecial">::</span><span class="synPreProc">record</span><span class="synSpecial">::</span>{Map, Value};
<span class="synStatement">let</span> <span class="synType">mut</span> map <span class="synStatement">=</span> <span class="synPreProc">Map</span><span class="synSpecial">::</span><span class="synIdentifier">new</span>();
map.<span class="synIdentifier">insert</span>(<span class="synConstant">"age"</span>.<span class="synIdentifier">to_string</span>(), <span class="synConstant">22</span>.<span class="synIdentifier">into</span>());
map.<span class="synIdentifier">insert</span>(
<span class="synConstant">"scores"</span>.<span class="synIdentifier">to_string</span>(),
<span class="synPreProc">vec!</span>[<span class="synConstant">80</span>, <span class="synConstant">90</span>]
.<span class="synIdentifier">into_iter</span>()
.<span class="synIdentifier">map</span>(<span class="synStatement">|</span>e<span class="synStatement">|</span> e.<span class="synIdentifier">into</span>())
.<span class="synIdentifier">collect</span><span class="synSpecial">::</span><span class="synStatement"><</span><span class="synType">Vec</span><span class="synStatement"><</span>_<span class="synStatement">>></span>()
.<span class="synIdentifier">into</span>(),
);
client.<span class="synIdentifier">send</span>(<span class="synConstant">"fluent.test"</span>, map).<span class="synIdentifier">unwrap</span>();
</pre>
<p>上記の例では新しく Map を定義していますが、<code>record_map!</code> マクロを利用することでより簡単に記述することもできます。</p>
<pre class="code lang-rust" data-lang="rust" data-unlink><span class="synStatement">use</span> <span class="synPreProc">std</span><span class="synSpecial">::</span><span class="synPreProc">collections</span><span class="synSpecial">::</span>HashMap;
<span class="synStatement">use</span> <span class="synPreProc">tokio_fluent</span><span class="synSpecial">::</span>record_map;
<span class="synStatement">let</span> map_from_macro <span class="synStatement">=</span> <span class="synPreProc">record_map!</span>(
<span class="synConstant">"age"</span>.<span class="synIdentifier">to_string</span>() <span class="synStatement">=></span> <span class="synConstant">22</span>.<span class="synIdentifier">into</span>(),
<span class="synConstant">"scores"</span>.<span class="synIdentifier">to_string</span>() <span class="synStatement">=></span> [<span class="synConstant">80</span>, <span class="synConstant">90</span>].<span class="synIdentifier">into_iter</span>().<span class="synIdentifier">map</span>(<span class="synStatement">|</span>e<span class="synStatement">|</span> e.<span class="synIdentifier">into</span>()).<span class="synIdentifier">collect</span><span class="synSpecial">::</span><span class="synStatement"><</span><span class="synType">Vec</span><span class="synStatement"><</span>_<span class="synStatement">>></span>().<span class="synIdentifier">into</span>(),
);
client.<span class="synIdentifier">send</span>(<span class="synConstant">"fluent.test"</span>, map_from_macro).<span class="synIdentifier">unwrap</span>();
</pre>
<p>Map への変換が必要にはなりますが、送信用のメソッドにタグとレコードを渡すことでログを送信するというのは公式の fluentd クライアントと同じです。</p>
<h2 id="サポートしているデータ型">サポートしているデータ型</h2>
<p>tokio-fluent でサポートしているデータ型は下記の通りです。Value という enum で定義されています<a href="#f-0b68042b" id="fn-0b68042b" name="fn-0b68042b" title="https://docs.rs/tokio-fluent/latest/tokio_fluent/record/enum.Value.html">*4</a>。</p>
<ul>
<li>Bool</li>
<li>Int</li>
<li>UInt</li>
<li>Float</li>
<li>Str</li>
<li>Object</li>
<li>Array</li>
</ul>
<p>これらのデータ型は独自に定義されたものですが、それぞれが From トレイトを実装しているため、<code>into()</code> による標準のデータ型からの変換が可能です。</p>
<h1 id="構成">構成</h1>
<p>ここからは、tokio-fluent がどのように動作しているかをご紹介します。</p>
<p>大きく分けて二つの構成要素があります。</p>
<ul>
<li>Client
<ul>
<li>ユーザーが利用するクライアント</li>
<li>Worker にレコードを送信する</li>
</ul>
</li>
<li>Worker
<ul>
<li>クライアントの初期化時に生成される</li>
<li>レコードを受け取り、forward プロトコルに従い fluentd サーバーに送信する</li>
</ul>
</li>
</ul>
<p><figure class="figure-image figure-image-fotolife" title="レコードの流れ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/johnmanjiro13/20231201/20231201160448.png" width="1066" height="264" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>レコードの流れ</figcaption></figure></p>
<p>また、fluentd との通信のために <a href="https://github.com/fluent/fluentd/wiki/Forward-Protocol-Specification-v1">forward プロトコル</a>を実装しています。</p>
<h2 id="Client">Client</h2>
<p>Client は先ほどから登場している、ユーザーが利用するクライアントです。
send メソッドを利用することで Worker に対してレコードを送信します。</p>
<p>実際に fluentd にログを送信する処理は Worker が行うため、ユーザーは fluentd サーバーへのログの送信を待つ必要がありません。</p>
<h2 id="Worker">Worker</h2>
<p>Worker は Client の初期化時に <code>tokio::spawn</code> によって生成され、無限ループで Client からレコードを受け取り fluentd サーバーに送信します。
Client が破棄されると同時に Worker も破棄されます。</p>
<h2 id="forward-プロトコル">forward プロトコル</h2>
<p>fluentd にはいくつかのプラグインが存在しますが、特に fluentd サーバーに直接レコードを送信する場合には <a href="https://docs.fluentd.org/input/forward">forward プラグイン</a>が使われます。</p>
<p>forward プラグインでは <a href="https://github.com/fluent/fluentd/wiki/Forward-Protocol-Specification-v1">forward プロトコル</a>を利用しており、このプロトコルを実装することで、自作のクライアントからも fluentd サーバーにログを送信することができます。</p>
<p>tokio-fluent は forward プロトコルの中でも <a href="https://github.com/fluent/fluentd/wiki/Forward-Protocol-Specification-v1#message-modes">Message Modes</a> に対応しています。</p>
<p>Message Modes はレコードを一つずつ送信するモードであり、下記のようなデータを MessagePack でシリアライズしたものを送信します。</p>
<table>
<thead>
<tr>
<th> 名前 </th>
<th> 型 </th>
<th> 説明 </th>
</tr>
</thead>
<tbody>
<tr>
<td> tag </td>
<td> string </td>
<td> レコードのタグ </td>
</tr>
<tr>
<td> time </td>
<td> integer </td>
<td> レコードのタイムスタンプ(UNIX時間もしくはEventTime<a href="#f-924f7e3a" id="fn-924f7e3a" name="fn-924f7e3a" title="EventTime に関しては割愛します。詳しくは wiki をご確認ください。https://github.com/fluent/fluentd/wiki/Forward-Protocol-Specification-v1#eventtime-ext-format">*5</a>) </td>
</tr>
<tr>
<td> record </td>
<td> hash </td>
<td> レコードの内容 </td>
</tr>
<tr>
<td> option </td>
<td> hash </td>
<td> オプション </td>
</tr>
</tbody>
</table>
<p>たとえば、使い方の例で出てきたレコードは下記のようになります。</p>
<pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">[</span>
"<span class="synConstant">fluent.test</span>",
<span class="synConstant">1701412937</span>,
<span class="synSpecial">{</span>"<span class="synStatement">age</span>": <span class="synConstant">22</span>, "<span class="synStatement">scores</span>": <span class="synSpecial">[</span><span class="synConstant">80</span>,<span class="synConstant">90</span><span class="synSpecial">]}</span>,
<span class="synSpecial">{</span>"<span class="synStatement">chunk</span>": "<span class="synConstant">OTViZmY1NmItMjAzYi00N2I3LWJlNTctMTE4ODU1M2M0ODYyCg==</span>"<span class="synSpecial">}</span>
<span class="synSpecial">]</span>
</pre>
<p>上から順に tag, time, record, option に対応しています。</p>
<p>ここで option に対して chunk を指定していますが、これは128ビットのユニークな ID を base64 でエンコードしたものであり、 fluentd サーバーが正しくレコードを受け取ることができたかを判定するために必要になります。</p>
<p>forward プロトコルでは option に chunk を指定すると、fluentd サーバーから ack を受け取ることができます<a href="#f-57a5e78a" id="fn-57a5e78a" name="fn-57a5e78a" title="https://github.com/fluent/fluentd/wiki/Forward-Protocol-Specification-v1#response">*6</a>。この値は chunk で指定したものと同一であるため、レコードが正しく受け取られたかを判定することができます。</p>
<pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span>"<span class="synStatement">ack</span>": "<span class="synConstant">OTViZmY1NmItMjAzYi00N2I3LWJlNTctMTE4ODU1M2M0ODYyCg==</span>"<span class="synSpecial">}</span>
</pre>
<h2 id="構成のまとめ">構成のまとめ</h2>
<p>上記をまとめると、tokio-fluent での処理の流れは下記のようになります。</p>
<ol>
<li>Client の send メソッドが呼び出される</li>
<li>Client が受け取ったデータを Message Modes に対応する形式にして Worker へ送信する</li>
<li>Worker で MessagePack にシリアライズし、fluentd サーバーへ送信する</li>
<li>レスポンスに問題がないか確認し、問題があればリトライする</li>
</ol>
<h1 id="終わりに">終わりに</h1>
<p>この記事では、tokio ベースの Rust 向け fluentd クライアントである tokio-fluent についてご紹介しました。</p>
<p>自分でクライアントを実装してみて、プロトコルの仕様など普段 fluentd を使っているだけではなかなか意識しないところを知ることができ、とても勉強になりました。
機能的にはまだまだ不足している部分もありますが、今後も改善していきたいと思います。</p>
<p>明日は石川さんの「オンライン実験を速く試すための基盤構築」です。お楽しみに!</p>
<div class="footnote">
<p class="footnote"><a href="#fn-7261661b" id="f-7261661b" name="f-7261661b" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">趣味なので社内導入などはされていません</span></p>
<p class="footnote"><a href="#fn-61cfe423" id="f-61cfe423" name="f-61cfe423" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://github.com/fluent">https://github.com/fluent</a></span></p>
<p class="footnote"><a href="#fn-734ab2be" id="f-734ab2be" name="f-734ab2be" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">たとえば TCP でしか利用せず Unix Domain Socketでは無視されるフィールドなどが生まれます</span></p>
<p class="footnote"><a href="#fn-0b68042b" id="f-0b68042b" name="f-0b68042b" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://docs.rs/tokio-fluent/latest/tokio_fluent/record/enum.Value.html">https://docs.rs/tokio-fluent/latest/tokio_fluent/record/enum.Value.html</a></span></p>
<p class="footnote"><a href="#fn-924f7e3a" id="f-924f7e3a" name="f-924f7e3a" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text">EventTime に関しては割愛します。詳しくは wiki をご確認ください。<a href="https://github.com/fluent/fluentd/wiki/Forward-Protocol-Specification-v1#eventtime-ext-format">https://github.com/fluent/fluentd/wiki/Forward-Protocol-Specification-v1#eventtime-ext-format</a></span></p>
<p class="footnote"><a href="#fn-57a5e78a" id="f-57a5e78a" name="f-57a5e78a" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://github.com/fluent/fluentd/wiki/Forward-Protocol-Specification-v1#response">https://github.com/fluent/fluentd/wiki/Forward-Protocol-Specification-v1#response</a></span></p>
</div>
johnmanjiro13
稼働中データ基盤を安全に dbt 移行する仕組み
hatenablog://entry/6801883189063177332
2023-12-05T12:00:00+09:00
2024-01-11T21:22:51+09:00 はじめに はじめに 移行前のデータ変換処理の仕組み 移行時の課題 移行時デグレの検知の仕組み CLI の整備 変更内容の検知 パラメーターの生成 GitHub Actions の整備 Continuous Integration (CI) plan-catalogs audit-catalogs Continuous Delivery (CD) apply-catalogs audit-catalogs で実行されていることの詳細仕様 audit-catalogs で実行されている処理 dbt build の実行 dbt-audit-helper の実行 dbt-profiler の実行 テス…
<h1 id="はじめに">はじめに</h1>
<ul class="table-of-contents">
<li><a href="#はじめに">はじめに</a></li>
<li><a href="#移行前のデータ変換処理の仕組み">移行前のデータ変換処理の仕組み</a></li>
<li><a href="#移行時の課題">移行時の課題</a></li>
<li><a href="#移行時デグレの検知の仕組み">移行時デグレの検知の仕組み</a><ul>
<li><a href="#CLI-の整備">CLI の整備</a><ul>
<li><a href="#変更内容の検知">変更内容の検知</a></li>
<li><a href="#パラメーターの生成">パラメーターの生成</a></li>
</ul>
</li>
<li><a href="#GitHub-Actions-の整備">GitHub Actions の整備</a><ul>
<li><a href="#Continuous-Integration-CI">Continuous Integration (CI)</a><ul>
<li><a href="#plan-catalogs">plan-catalogs</a></li>
<li><a href="#audit-catalogs">audit-catalogs</a></li>
</ul>
</li>
<li><a href="#Continuous-Delivery-CD">Continuous Delivery (CD)</a><ul>
<li><a href="#apply-catalogs">apply-catalogs</a></li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li><a href="#audit-catalogs-で実行されていることの詳細仕様">audit-catalogs で実行されていることの詳細仕様</a><ul>
<li><a href="#audit-catalogs-で実行されている処理">audit-catalogs で実行されている処理</a><ul>
<li><a href="#dbt-build-の実行">dbt build の実行</a></li>
<li><a href="#dbt-audit-helper-の実行">dbt-audit-helper の実行</a></li>
<li><a href="#dbt-profiler-の実行">dbt-profiler の実行</a></li>
</ul>
</li>
<li><a href="#テスト向けの一時テーブルを作成するための機構">テスト向けの一時テーブルを作成するための機構</a><ul>
<li><a href="#schema-の差し替え">schema の差し替え</a></li>
<li><a href="#defer-機能の活用">defer 機能の活用</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<p>DRE&MLOps チームの hyamamoto です。</p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 5 日目の記事です。 4 日目の記事は m-hamashita さんの<a href="https://tech.gunosy.io/entry/techblog_review_system">企業テックブログのレビューを GitHub 上でおこなっている話</a>でした。</p>
<p>DRE&MLOps ではデータ基盤における変換処理を dbt に移行するプロジェクトを進めてきました。
移行作業に際して重要な項目として、以下の点が挙げられると思います。</p>
<ul>
<li>既存のデータ変換クエリを正確に dbt に移行できるか</li>
<li>属人化せずにスムーズに移行作業を進められるか</li>
</ul>
<p>この記事では、移行前の弊社におけるデータ変換処理の仕組みを紹介し、その上でどのようなアプローチによって dbt 移行作業を進めたかについて紹介します。
また、dbt については以前 dbt の仕組みに関する紹介記事を書いたのでよかったら参照してもらえると嬉しいです。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdata.gunosy.io%2Fentry%2Fdbt-deep-dive" title="根っこから理解を深める dbt - Gunosyデータ分析ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://data.gunosy.io/entry/dbt-deep-dive">data.gunosy.io</a></cite></p>
<h1 id="移行前のデータ変換処理の仕組み">移行前のデータ変換処理の仕組み</h1>
<p>弊社のデータ基盤 Baikal の説明は以前この記事で紹介させていただきました。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Fbaikal-introduction" title="Gunosy のデータ活用を支える統合データ基盤 Baikal の話 - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/baikal-introduction">tech.gunosy.io</a></cite></p>
<p>上記の内容から dbt 移行に際して関係する主要な内容としては以下の内容が挙げられます。</p>
<ul>
<li>各種バッチを <a href="https://www.digdag.io/">Digdag</a> によって管理している</li>
<li><a href="https://github.com/civitaspo/digdag-operator-athena">digdag-operator-athena plugin</a> を用いて Athena API を叩いてデータ変換を行なっている</li>
</ul>
<p>まとめると、移行前の状態でもいわゆる ELT アーキテクチャを採用しており、その Transform 部分の実行を Digdag のプラグインを用いて Athena API を叩いて行なっているという状態です。
よって、dbt 移行の目的は以下の 2 点です。</p>
<ul>
<li>Digdag workflow で記述していた SQL を dbt に移行する
<ul>
<li>Digdag workflow では workflow の変数を SQL に埋め込んでいた</li>
</ul>
</li>
<li>Digdag workflow から dbt を呼び出し、オーケストレーションする</li>
</ul>
<p>本記事では前者の Digdag workflow で記述していた SQL を dbt に移行する際の工夫について紹介します。
なお、ここで言及する SQL の移行とは、Digdag 特有の変数埋め込みの記法を dbt 向けに書き換えることや、モデル間の依存関係を <code>ref</code> や <code>source</code> によって表現することを指します。</p>
<h1 id="移行時の課題">移行時の課題</h1>
<p>SQL の移行に際してもっとも注意しなければならない点は、移行前後でデグレが発生してしまい変換ロジックが変わってしまうことです。
一方で、移行後のクエリをコンパイルして Redash などで実行し、既存テーブルとの結果を逐一比較することは非常に手間がかかり、属人化したり移行作業のスピードを落としたりする原因になります。</p>
<p>よって、この問題を解決するために CLI ツールの作成と GitHub Actions を用いた自動テストの仕組みを導入しました。</p>
<h1 id="移行時デグレの検知の仕組み">移行時デグレの検知の仕組み</h1>
<h2 id="CLI-の整備">CLI の整備</h2>
<p>まず、dbt を CI で実行するにあたっての課題として、実行対象のモデルを指定する必要があるという点が挙げられます。
dbt の build 機能はデフォルトでは全てのモデルを実行するため、すでにデータ基盤自体は成熟していて大量にモデルを抱えたプロジェクトでは、CI の実行時間増加による開発者体験の悪化やコスト増加に繋がります。
そこで PR に含まれる変更を検知しその変更内容から dbt 実行時の引数に渡すパラメーターを生成する CLI ツールを作成しました。
実行内容の方向性は Terraform の plan と apply のようなものを想定しています。</p>
<h3 id="変更内容の検知">変更内容の検知</h3>
<p>変更内容の検知には dbt が出力する <code>manifest.json</code> を用いて行います。
GitHub Actions の全体像は後ほど示しますが、事前に S3 に <code>manifest.json</code> をアップロードしておき、その差分を検知することで変更内容を検知しています。
<code>manifest.json</code> のパースに関しては、以下のライブラリを用いています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fyu-iskw%2Fdbt-artifacts-parser" title="GitHub - yu-iskw/dbt-artifacts-parser: A dbt artifacts parser in python" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/yu-iskw/dbt-artifacts-parser">github.com</a></cite></p>
<p>検知された差分の結果と Glue の API と比較を行うことで、dbt 上の差分が最終的に Glue 上にどのような影響を与えるかを確認しています。
さらに、削除したモデルのテーブル削除にもこの機能をベースに対応しています<a href="#f-18865cce" id="fn-18865cce" name="fn-18865cce" title="テーブル削除の機能は dbt 自体はサポートしていません">*1</a>。</p>
<h3 id="パラメーターの生成">パラメーターの生成</h3>
<p>この CLI は最終的に json 形式で差分を出力します。
出力された json は後ほど dbt の CLI に渡しやすいように整形しており、<code>jq</code> を用いることで効率的に利用できるようにしています。
例えば、<code>dbt build</code> コマンドの <code>--select</code> option に渡すためのパラメーターをこの json に保持させています。</p>
<h2 id="GitHub-Actions-の整備">GitHub Actions の整備</h2>
<p>それでは次に整備した GitHub Actions の内容について説明していきます。
大きく以下の 3 つのジョブを用意しています。</p>
<ul>
<li>Continuous Integration (CI)
<ul>
<li>plan-catalogs: CLI による差分検知を実行するジョブ</li>
<li>audit-catalogs: stg 環境上<a href="#f-0000bb27" id="fn-0000bb27" name="fn-0000bb27" title="弊社の stg 環境では直近の本番同等相当のデータを用意しています">*2</a><a href="#f-ea4872b6" id="fn-ea4872b6" name="fn-ea4872b6" title="弊社では直近の本番同等相当のデータのある stg 環境を用意しているものの、本番環境だけの場合でも今回の方法で同様の仕組みを導入することは可能です">*3</a>にあるデータを用いて、PR 前後のモデルを比較するジョブ</li>
</ul>
</li>
<li>Continuous Delivery (CD)
<ul>
<li>apply-catalogs: plan-catalogs の結果を受けて変更を反映するジョブ</li>
</ul>
</li>
</ul>
<p>なお、以下において今回作成した CLI を Baikal dbt Tools と呼称します。
また、dbt artifacts とは <code>manifest.json</code> を含む dbt の target フォルダ配下のデータを指します。</p>
<h3 id="Continuous-Integration-CI">Continuous Integration (CI)</h3>
<h4 id="plan-catalogs">plan-catalogs</h4>
<p>plan-catalogs は行った変更がどのような影響を及ぼすかを確認するためのジョブです。
また、その変更内容の差分の結果を json 化して S3 に保存します。</p>
<p>このジョブは以下のような流れで実行されます。</p>
<pre class="code mermaid" data-lang="mermaid" data-unlink>sequenceDiagram
participant ga as GitHub Actions
participant dbt as dbt
participant tools as Baikal dbt Tools
participant s3 as S3
ga->>s3: dbt artifacts を取得
s3-->> ga: dbt artifacts を返す
ga->>dbt: dbt compile の実行
dbt-->> ga: dbt artifacts を返す
ga->>tools: S3 の artifacts と手元の artifacts を比較
tools-->> ga: 差分を plan.json として出力
ga->>ga: PR に変更差分をコメント
ga->>s3: plan.json を S3 に保存
s3-->> ga: plan.json を保存したことを返す</pre>
<p>なお、対象とする S3 のバケットは PR のマージ先の環境を選択しています。
例えば、feature ブランチの PR の場合は dev 環境のバケットを参照し dbt artifacts を取得します。
PR のコメント結果の例としては下記のようなものです。</p>
<p><figure class="figure-image figure-image-fotolife" title="plan の PR コメント"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/hiro-o918/20231204/20231204180530.png" width="1200" height="558" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>plan の PR コメント</figcaption></figure></p>
<h4 id="audit-catalogs">audit-catalogs</h4>
<p>audit-catalogs は feature branch に対して実行されるジョブで、stg 環境を用いて実際のデータを使ったテストを行います。</p>
<p>このジョブは以下のような流れで実行されます。</p>
<pre class="code mermaid" data-lang="mermaid" data-unlink>sequenceDiagram
participant ga as GitHub Actions
participant dbt as dbt
participant tools as Baikal dbt Tools
participant s3 as S3
ga->>s3: dbt artifacts を取得
s3-->> ga: dbt artifacts を返す
ga->>dbt: dbt compile の実行
dbt-->> ga: dbt artifacts を返す
ga->>tools: S3 の artifacts と手元の artifacts を比較
tools-->> ga: 差分を plan.json として出力
note over ga: 以下の内容はすべて plan.json の差分によって決定される
ga->>dbt: dbt build の実行<br/>(build には test も含まれる)
dbt-->>ga: dbt build の結果を返す
ga->>dbt: stg のテーブルと build の結果を比較
dbt-->>ga: 比較結果を返す
ga->>ga: PR に比較結果をコメント
ga->>dbt: build の結果から profile を出力
dbt-->>ga: profile を返す
ga->>ga: PR に profile の結果をコメント</pre>
<p>audit-catalogs は実質 plan-catalogs と後続の apply-catalogs の両方を実行しています。
これにより、実際のデータを使った動作確認や既存モデルとの比較、モデルの profile 情報の出力が可能になっています。
なお、差分検知や profile 情報の出力に関する詳しい内容については、<a href="#audit-catalogs-%E3%81%A7%E5%AE%9F%E8%A1%8C%E3%81%95%E3%82%8C%E3%81%A6%E3%81%84%E3%82%8B%E3%81%93%E3%81%A8%E3%81%AE%E8%A9%B3%E7%B4%B0%E4%BB%95%E6%A7%98">audit で実行されていることの詳細</a> に後述します</p>
<h3 id="Continuous-Delivery-CD">Continuous Delivery (CD)</h3>
<h4 id="apply-catalogs">apply-catalogs</h4>
<p>apply-catalogs では plan-catalogs で作成された plan.json を用いて、dbt コマンドを実行します。</p>
<p>このジョブは以下のような流れで実行されます。</p>
<pre class="code mermaid" data-lang="mermaid" data-unlink>sequenceDiagram
participant ga as GitHub Actions
participant dbt as dbt
participant tools as Baikal dbt Tools
participant s3 as S3
ga->>s3: plan.json を取得
s3-->> ga: plan.json を返す
note over ga: 以下の内容はすべて plan.json の差分によって決定される
ga->>tools: 削除されたモデル情報から Glue のテーブルを削除
tools-->> ga: 削除したテーブルの情報を返す
ga->>dbt: *View の変更に対してのみ* dbt build の実行
dbt-->> ga: dbt build の結果を返す
ga->>s3: S3 に artifacts を保存
s3-->> ga: 保存したことを返す</pre>
<p>このジョブによって以下のことが達成されます。</p>
<ul>
<li>削除検知によるテーブルの削除</li>
<li>View の変更を適用<a href="#f-979c774e" id="fn-979c774e" name="fn-979c774e" title="table や incremental モデルの変更は、別途 dbt をオーケストレーションしている Digdag 側で反映される">*4</a></li>
<li>S3 上の artifacts を更新</li>
</ul>
<p><figure class="figure-image figure-image-fotolife" title="apply の実行結果の PR コメント"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/hiro-o918/20231204/20231204181521.png" width="1200" height="472" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>apply の実行結果の PR コメント</figcaption></figure></p>
<h1 id="audit-catalogs-で実行されていることの詳細仕様">audit-catalogs で実行されていることの詳細仕様</h1>
<p>ここでは、<a href="#audit-catalogs">audit-catalogs</a> で実行されていることの詳細について説明します。</p>
<h2 id="audit-catalogs-で実行されている処理">audit-catalogs で実行されている処理</h2>
<p>audit-catalogs では大きく以下の 3 つの内容を実行しています。</p>
<ul>
<li><code>dbt build</code> の実行</li>
<li><code>dbt-audit-helper</code> の実行</li>
<li><code>dbt-profiler</code> の実行</li>
</ul>
<h3 id="dbt-build-の実行"><code>dbt build</code> の実行</h3>
<p><code>dbt build</code> は <code>dbt compile</code> と <code>dbt test</code> を実行するコマンドです。
これにより、実際のデータを用いて CI のタイミングでモデルのテストを実行することが可能になります。</p>
<h3 id="dbt-audit-helper-の実行"><a href="https://github.com/dbt-labs/dbt-audit-helper">dbt-audit-helper</a> の実行</h3>
<p>dbt-audit-helper は 2 つのテーブルを比較し、その差分を出力するツールです。
このツールを実際の stg 環境のテーブルと <code>dbt_sandbox</code> 以下に作成された CI 向けの一時テーブルを比較することで、テーブルの差分を検知しています。
移行時にはこの結果を PR のコメントとして出力することで、作業者やレビューワーが変更によるデグレが発生していないかを確認することが可能になります。</p>
<blockquote><p>⚠️
dbt-audit-helper は SQL を用いてレコードの差分を完全一致で比較を行います。</p>
<p>よって、浮動小数点誤差が起因で数値の差分が検知されることがあります。</p></blockquote>
<p><figure class="figure-image figure-image-fotolife" title="dbt-audit-helper の実行結果 PR コメント"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/hiro-o918/20231204/20231204180647.png" width="1200" height="966" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>dbt-audit-helper の実行結果の PR コメント</figcaption></figure></p>
<h3 id="dbt-profiler-の実行"><a href="https://github.com/data-mie/dbt-profiler">dbt-profiler</a> の実行</h3>
<p>dbt-profiler は dbt の profile を出力するツールです。
markdown のテーブル形式で各モデルのカラムの統計量を出力することが可能です。
この結果を PR のコメントや markdown のファイルとして書き出すことで、そのモデルの概要から掴むことが可能になります。
一方でカラムの多いモデルの場合は、統計量の出力が多くなってしまい情報としてノイズになっているのが現状の課題でもあります。</p>
<p><figure class="figure-image figure-image-fotolife" title="dbt-profiler の実行結果のコメント"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/h/hiro-o918/20231204/20231204180757.png" width="1200" height="655" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>dbt-profiler の実行結果のコメント</figcaption></figure></p>
<h2 id="テスト向けの一時テーブルを作成するための機構">テスト向けの一時テーブルを作成するための機構</h2>
<p>上記の実行内容を踏まえた上で重要な点として、audit-catalogs では実際のテーブルを上書きするのではなく、テスト向けの一時テーブルを作成する必要があるという点が挙げられます。
というのも、単純に <code>dbt build</code> を実行した場合、実際のテーブルが更新されてしまい、意図せず共有環境を壊してしまったり、既存テーブルとの比較を行うことができなくなってしまったりするからです。</p>
<p>この問題を解決するために、以下の 2 つのテクニックを採用しています。</p>
<h3 id="schema-の差し替え">schema の差し替え</h3>
<p>dbt には <a href="https://docs.getdbt.com/docs/build/custom-schemas#how-does-dbt-generate-a-models-schema-name"><code>generate_schema_name</code></a> というマクロが存在し、これに基づいて schema を差し替えることができます。
<code>audit-catalogs</code> を実行時には <code>stg_ci</code> という <code>target</code> を指定しています。</p>
<p>そこで、下記のように <code>target</code> に応じて schema を差し替えることで、出力先の schema を変更しています (dbt の schema は Glue の database に対応します)。</p>
<pre class="code jinja" data-lang="jinja" data-unlink>{% macro generate_schema_name(custom_schema_name, node) -%}
{%- set default_schema = target.schema -%}
{%- if target.name == "stg_ci" -%}
dbt_sandbox
{%- elif custom_schema_name is none -%}
{{ default_schema }}
{%- else -%}
{{ custom_schema_name | trim }}
{%- endif -%}
{%- endmacro %}</pre>
<p>これにより、<code>audit-catlogs</code> で実行される <code>dbt build</code> の結果は <code>dbt_sandbox</code> DB に主力され、実際のテーブルに対しての変更は行われません。</p>
<h3 id="defer-機能の活用"><code>defer</code> 機能の活用</h3>
<p>schema の差し替えによって生じる問題として、対象のモデルが参照しているテーブルの schema も変更されてしまうということです。
例えば、下記のように <code>bar</code> モデルが <code>foo</code> モデルに依存していた場合、schema の差し替えによって <code>foo</code> モデルの参照先も <code>dbt_sandbox</code> に変更されてしまいます。</p>
<pre class="code jinja" data-lang="jinja" data-unlink>SELECT * FROM {{ ref('foo') }}
-- dbt compile 後の想定結果
-- SELECT * FROM dbt_sandbox.foo</pre>
<p>しかしながら、元々存在していた <code>foo</code> モデルは <code>dbt_sandbox</code> に存在しないため、<code>bar</code> モデルのビルドに失敗してしまいます。</p>
<p>この問題を解決するために、<a href="https://docs.getdbt.com/reference/node-selection/defer"><code>defer</code> 機能</a>を活用しています。
詳細はリンク先に譲りますが、<code>defer</code> 機能を用い、<code>stg</code> 環境の artifacts を渡すことで、<code>bar</code> モデルのビルド時に依存先の <code>foo</code> モデルについては <code>stg</code> 環境に存在する実際のテーブルを参照するようになります。</p>
<pre class="code jinja" data-lang="jinja" data-unlink>SELECT * FROM {{ ref('foo') }}
-- `defer` を利用した際の dbt compile 後の想定結果
-- SELECT * FROM stg_db.foo</pre>
<h1 id="まとめ">まとめ</h1>
<p>本記事では、dbt 移行に際してどのような工夫を行なったかについて紹介しました。
すでにデータ基盤を作成していて、dbt に移行するのが難しいという方はぜひ本記事を参考にしてみてください。
結局のところ CLI の作成と CI/CD の整備を頑張るという方向性にはなってしまいますが、個人が手作業で行うよりも効率的に移行作業を進めることができると思います。
また、dbt の artifacts フォルダの中には dbt を実行する上で必要な情報が全て含まれているため、このフォルダを用いて様々なことができるということも覚えておいてもらえると嬉しいです。</p>
<p>明日は johnmanjiro さんの「tokio ベースの Rust 向け fluentd クライアントを作った話」です。
お楽しみに!</p>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true });
</script>
<div class="footnote">
<p class="footnote"><a href="#fn-18865cce" id="f-18865cce" name="f-18865cce" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">テーブル削除の機能は dbt 自体はサポートしていません</span></p>
<p class="footnote"><a href="#fn-0000bb27" id="f-0000bb27" name="f-0000bb27" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">弊社の stg 環境では直近の本番同等相当のデータを用意しています</span></p>
<p class="footnote"><a href="#fn-ea4872b6" id="f-ea4872b6" name="f-ea4872b6" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">弊社では直近の本番同等相当のデータのある stg 環境を用意しているものの、本番環境だけの場合でも今回の方法で同様の仕組みを導入することは可能です</span></p>
<p class="footnote"><a href="#fn-979c774e" id="f-979c774e" name="f-979c774e" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">table や incremental モデルの変更は、別途 dbt をオーケストレーションしている Digdag 側で反映される</span></p>
</div>
hiro-o918
企業テックブログのレビューを GitHub 上でおこなっている話
hatenablog://entry/820878482935188939
2023-12-04T11:40:00+09:00
2024-01-11T21:23:24+09:00 はじめに 今までのブログレビュー 現在のブログレビュー ブログの同期 ブログの取得 リポジトリと下書きの同期 textlint の導入 導入した結果 まとめ はじめに こんにちは m-hamashita です。 この記事は Gunosy Advent Calendar 2023 の 4 日目の記事です。 3 日目の記事は TksYamaguchi さんのいろんなやり方の読書会をやってみたら当日音読その場でまとめ方式が最高だった話でした。 今回はテックブログのレビューを GitHub 上でするようになって 1 年ほど経ったので、その経緯とリポジトリの紹介をしたいと思います。 個人的にかなり運用が…
<ul class="table-of-contents">
<li><a href="#はじめに">はじめに</a></li>
<li><a href="#今までのブログレビュー">今までのブログレビュー</a></li>
<li><a href="#現在のブログレビュー">現在のブログレビュー</a><ul>
<li><a href="#ブログの同期">ブログの同期</a><ul>
<li><a href="#ブログの取得">ブログの取得</a></li>
<li><a href="#リポジトリと下書きの同期">リポジトリと下書きの同期</a></li>
</ul>
</li>
<li><a href="#textlint-の導入">textlint の導入</a></li>
</ul>
</li>
<li><a href="#導入した結果">導入した結果</a></li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h1 id="はじめに">はじめに</h1>
<p>こんにちは m-hamashita です。</p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 4 日目の記事です。 3 日目の記事は TksYamaguchi さんの<a href="https://tech.gunosy.io/entry/reading-club-summary-method">いろんなやり方の読書会をやってみたら当日音読その場でまとめ方式が最高だった話</a>でした。</p>
<p>今回はテックブログのレビューを GitHub 上でするようになって 1 年ほど経ったので、その経緯とリポジトリの紹介をしたいと思います。
個人的にかなり運用が楽になったので、ぜひ見てもらえると嬉しいです。</p>
<h1 id="今までのブログレビュー">今までのブログレビュー</h1>
<p>今まではブログ用の Slack チャンネルで有志にレビューをお願いし、スレッドでレビューをおこなっていました。
<figure class="figure-image figure-image-fotolife" title="Slack でのレビューの様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m-hamashita/20231129/20231129013045.png" width="2076" height="1970" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Slack でのレビューの様子</figcaption></figure></p>
<p>そこでは以下のような問題を感じていました。</p>
<ul>
<li>レビュー箇所をコピー&ペーストしてレビューするのが面倒</li>
<li>レビュー内容が反映されないことがあった</li>
<li>レビューされないまま公開される場合があった</li>
<li>毎回同じようなレビュー(例:スペースの使い方の統一)をおこなっていた</li>
</ul>
<h1 id="現在のブログレビュー">現在のブログレビュー</h1>
<p>GitHub リポジトリでブログを管理することで、先述した問題の解決を試みました。
最近公式でテンプレートリポジトリが公開されたので、これからレビューリポジトリを作成する場合はこちらを使用し、この記事で使えそうな部分があればそれを取り入れるという形が良さそうです。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fhatena%2FHatena-Blog-Workflows-Boilerplate%2F" title="GitHub - hatena/Hatena-Blog-Workflows-Boilerplate" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/hatena/Hatena-Blog-Workflows-Boilerplate/">github.com</a></cite></p>
<p><figure class="figure-image figure-image-fotolife" title="GitHub でのレビューの様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m-hamashita/20231202/20231202144220.png" width="1308" height="1294" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>GitHub でのレビューの様子</figcaption></figure></p>
<p>レビュー箇所を指定してレビューすることができるようになり、レビュー箇所をコピー&ペーストする必要がなくなりました。
また、commit suggestion を使うことで、レビューされた側の修正も楽になりました。</p>
<p>現在のレビューの流れは以下のようになっています。</p>
<ul>
<li><code>main</code> ブランチからブランチを切る</li>
<li>はてなブログ上で下書きを作成し、カスタム URL を設定する</li>
<li><code>./pull</code> (後述)を実行して、下書き(markdown)を取得する</li>
<li>リモートにプッシュして、(完成していなければ draft で)Pull Request を出す</li>
<li>markdown を編集して記事を書く
<ul>
<li>プッシュすると CI によって下書きが同期される</li>
<li>(画像アップロード等)はてなブログ上で編集した場合は、<code>./pull</code>(or <code>./fetch</code>) を実行することで同期する</li>
</ul>
</li>
<li>書ききったら Ready for Review にして、レビューを依頼する</li>
<li>レビューが完了したら、記事を公開(予約投稿)する</li>
</ul>
<h2 id="ブログの同期">ブログの同期</h2>
<p>はてなブログとリポジトリの同期には blogsync を用いました。
blogsync は、はてなブログ用の CLI クライアントです。これを使うと、ブログのダウンロードや、更新などをおこなうことができます。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fx-motemen%2Fblogsync" title="GitHub - x-motemen/blogsync: Push and pull blog entries from/to local filesystem" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/x-motemen/blogsync">github.com</a></cite></p>
<p>パッケージのインストールには <a href="https://aquaproj.github.io/">aqua</a> を使っています。今回インストールしたパッケージは以下のようになっています。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">registries</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">type</span><span class="synSpecial">:</span> standard
<span class="synIdentifier">ref</span><span class="synSpecial">:</span> v4.92.2<span class="synComment"> # renovate: depName=aquaproj/aqua-registry</span>
<span class="synIdentifier">packages</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> x-motemen/blogsync@v0.20.1
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> stedolan/jq@jq-1.7
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> a8m/envsubst@v1.4.2
</pre>
<p>blogsync では blogsync.yaml というファイルを用いて、はてなブログの設定をおこないます。
一方弊社では<a href="https://tech.gunosy.io">テックブログ</a>と<a href="https://data.gunosy.io">データ分析ブログ</a>の 2 つのブログを運用しています。そのため、単一の blogsync.yaml では設定をおこなうことができず、少し工夫が必要でした。</p>
<h3 id="ブログの取得">ブログの取得</h3>
<p>はてなブログ上にあるブログを取得する <code>pull</code> は以下のようになっています。ここでは、はてなブログの API キーなどを Secrets Manager に保存しています。
先述したように 2 つのブログを運用しているため、今回は template ファイルと envsubst を用いて、ブログごとに設定を変えるようにしています。</p>
<pre class="code lang-sh" data-lang="sh" data-unlink><span class="synComment">#!/bin/bash</span>
<span class="synStatement">set</span><span class="synIdentifier"> </span><span class="synSpecial">-e</span>
<span class="synIdentifier">SECRET</span>=<span class="synPreProc">$(</span><span class="synSpecial">aws secretsmanager get-secret-value --secret-id </span><span class="synStatement">"</span><span class="synConstant">techblog</span><span class="synStatement">"</span><span class="synSpecial"> </span><span class="synStatement">|</span><span class="synSpecial"> jq -r </span><span class="synStatement">'</span><span class="synConstant">.SecretString</span><span class="synStatement">'</span><span class="synPreProc">)</span>
<span class="synStatement">export</span> <span class="synIdentifier">API_KEY</span>=<span class="synPreProc">$(</span><span class="synStatement">echo</span><span class="synConstant"> </span><span class="synStatement">"</span><span class="synPreProc">$SECRET</span><span class="synStatement">"</span><span class="synConstant"> </span><span class="synStatement">|</span><span class="synSpecial"> jq -r </span><span class="synStatement">'</span><span class="synConstant">.api_key</span><span class="synStatement">'</span><span class="synPreProc">)</span>
<span class="synStatement">export</span> <span class="synIdentifier">USER_NAME</span>=<span class="synPreProc">$(</span><span class="synStatement">echo</span><span class="synConstant"> </span><span class="synStatement">"</span><span class="synPreProc">$SECRET</span><span class="synStatement">"</span><span class="synConstant"> </span><span class="synStatement">|</span><span class="synSpecial"> jq -r </span><span class="synStatement">'</span><span class="synConstant">.user_name</span><span class="synStatement">'</span><span class="synPreProc">)</span>
<span class="synStatement">if [</span> <span class="synStatement">"</span><span class="synPreProc">$#</span><span class="synStatement">"</span> <span class="synStatement">-eq</span> <span class="synConstant">1</span> <span class="synStatement">]</span> <span class="synStatement">&&</span> <span class="synSpecial">{</span> <span class="synStatement">[</span> <span class="synStatement">"</span><span class="synPreProc">$1</span><span class="synStatement">"</span> <span class="synStatement">==</span> <span class="synStatement">"</span><span class="synConstant">data</span><span class="synStatement">"</span> <span class="synStatement">]</span> <span class="synStatement">||</span> <span class="synStatement">[</span> <span class="synStatement">"</span><span class="synPreProc">$1</span><span class="synStatement">"</span> <span class="synStatement">==</span> <span class="synStatement">"</span><span class="synConstant">tech</span><span class="synStatement">"</span> <span class="synStatement">];</span> <span class="synSpecial">}</span><span class="synStatement">;</span> <span class="synStatement">then</span>
envsubst <span class="synStatement"><</span> <span class="synStatement">"</span><span class="synPreProc">${1}</span><span class="synConstant">_blogsync.yaml.template</span><span class="synStatement">"</span> <span class="synStatement">></span> blogsync.yaml
blogsync pull <span class="synStatement">"</span><span class="synConstant">gunosy-</span><span class="synPreProc">${1}</span><span class="synConstant">.hatenablog.com</span><span class="synStatement">"</span>
<span class="synStatement">rm</span> blogsync.yaml
<span class="synStatement">elif</span> <span class="synStatement">[</span> <span class="synStatement">"</span><span class="synPreProc">$#</span><span class="synStatement">"</span> <span class="synStatement">-eq</span> <span class="synConstant">0</span> <span class="synStatement">];</span> <span class="synStatement">then</span>
envsubst <span class="synStatement"><</span> <span class="synStatement">"</span><span class="synConstant">data_blogsync.yaml.template</span><span class="synStatement">"</span> <span class="synStatement">></span> blogsync.yaml
blogsync pull <span class="synStatement">"</span><span class="synConstant">gunosy-data.hatenablog.com</span><span class="synStatement">"</span>
<span class="synStatement">rm</span> blogsync.yaml
envsubst <span class="synStatement"><</span> <span class="synStatement">"</span><span class="synConstant">tech_blogsync.yaml.template</span><span class="synStatement">"</span> <span class="synStatement">></span> blogsync.yaml
blogsync pull <span class="synStatement">"</span><span class="synConstant">gunosy-tech.hatenablog.com</span><span class="synStatement">"</span>
<span class="synStatement">rm</span> blogsync.yaml
<span class="synStatement">else</span>
<span class="synStatement">echo</span><span class="synConstant"> </span><span class="synStatement">"</span><span class="synConstant">Usage: </span><span class="synPreProc">$0</span><span class="synConstant"> [data|tech]</span><span class="synStatement">"</span>
<span class="synStatement">exit</span> <span class="synConstant">1</span>
<span class="synStatement">fi</span>
</pre>
<p>上記で指定されている template ファイルは以下のようになっています。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">gunosy-tech.hatenablog.com</span><span class="synSpecial">:</span>
<span class="synIdentifier">username</span><span class="synSpecial">:</span> ${USER_NAME}
<span class="synIdentifier">password</span><span class="synSpecial">:</span> ${API_KEY}
<span class="synIdentifier">default</span><span class="synSpecial">:</span>
<span class="synIdentifier">local_root</span><span class="synSpecial">:</span> ./
</pre>
<h3 id="リポジトリと下書きの同期">リポジトリと下書きの同期</h3>
<p>リポジトリを作成してすぐの頃は、手動で GitHub とはてなブログを同期していました。そうすると、GitHub 上では修正されているのに、はてなブログ上では修正されていなかったり、逆にはてなブログ上では修正されているのに、GitHub 上では修正されていなかったりすることがありました。</p>
<p>これを解消するために、Pull Request を作成すると CI によって、ブログの下書きが更新されるようにしました。これによって、レビューをおこなう際に GitHub と下書きの内容が同期されるようになりました。</p>
<p>以下は、(テック)ブログの下書きを更新する GitHub Actions の設定です<a href="#f-59cceb15" id="fn-59cceb15" name="fn-59cceb15" title="blogsync.yaml の取得に secrets コンテキストを用いていますが、 pull でおこなったように修正したいですね">*1</a><a href="#f-77021abb" id="fn-77021abb" name="fn-77021abb" title="最近 blogsync に GitHub Actions が追加されていましたのでこちらを使うと良さそうです https://github.com/x-motemen/blogsync?tab=readme-ov-file#github-actions">*2</a>。
これによって GitHub とはてなブログの下書きが同期され、手動で同期する必要がなくなり、上記の問題を解消することができました。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">name</span><span class="synSpecial">:</span> push to tech blog
<span class="synIdentifier">on</span><span class="synSpecial">:</span>
<span class="synIdentifier">pull_request</span><span class="synSpecial">:</span>
<span class="synIdentifier">paths</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>gunosy-tech.hatenablog.com/entry/**
<span class="synIdentifier">jobs</span><span class="synSpecial">:</span>
<span class="synIdentifier">push</span><span class="synSpecial">:</span>
<span class="synIdentifier">name</span><span class="synSpecial">:</span> push
<span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest
<span class="synIdentifier">env</span><span class="synSpecial">:</span>
<span class="synIdentifier">GITHUB_TOKEN</span><span class="synSpecial">:</span> ${{ secrets.GITHUB_TOKEN }}
<span class="synIdentifier">steps</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v3
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Get changed files
<span class="synIdentifier">id</span><span class="synSpecial">:</span> changed-files-specific
<span class="synIdentifier">uses</span><span class="synSpecial">:</span> tj-actions/changed-files@v35
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">files</span><span class="synSpecial">:</span> gunosy-tech.hatenablog.com/entry/**
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> aquaproj/aqua-installer@v2.0.2
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">aqua_version</span><span class="synSpecial">:</span> v1.34.2
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> Setup tech blogsync
<span class="synIdentifier">run</span><span class="synSpecial">:</span> |
echo <span class="synConstant">"${{ secrets.TECH_BLOG_CONFIG_YAML }}"</span> > blogsync.yaml
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> push blog to hatena blog
<span class="synIdentifier">run</span><span class="synSpecial">:</span> |
for file in ${{ steps.changed-files-specific.outputs.all_changed_files }}; do
blogsync push $file
done
</pre>
<p>余談ですが、<code>pull</code> がすべてのブログを更新するのに対して、指定したブログのみを更新したいときに使う <code>fetch</code> は以下のようになっています。</p>
<pre class="code lang-sh" data-lang="sh" data-unlink><span class="synComment">#!/bin/bash</span>
<span class="synStatement">set</span><span class="synIdentifier"> </span><span class="synSpecial">-e</span>
<span class="synIdentifier">SECRET</span>=<span class="synPreProc">$(</span><span class="synSpecial">aws secretsmanager get-secret-value --secret-id </span><span class="synStatement">"</span><span class="synConstant">techblog</span><span class="synStatement">"</span><span class="synSpecial"> </span><span class="synStatement">|</span><span class="synSpecial"> jq -r </span><span class="synStatement">'</span><span class="synConstant">.SecretString</span><span class="synStatement">'</span><span class="synPreProc">)</span>
<span class="synStatement">export</span> <span class="synIdentifier">API_KEY</span>=<span class="synPreProc">$(</span><span class="synStatement">echo</span><span class="synConstant"> </span><span class="synStatement">"</span><span class="synPreProc">$SECRET</span><span class="synStatement">"</span><span class="synConstant"> </span><span class="synStatement">|</span><span class="synSpecial"> jq -r </span><span class="synStatement">'</span><span class="synConstant">.api_key</span><span class="synStatement">'</span><span class="synPreProc">)</span>
<span class="synStatement">export</span> <span class="synIdentifier">USER_NAME</span>=<span class="synPreProc">$(</span><span class="synStatement">echo</span><span class="synConstant"> </span><span class="synStatement">"</span><span class="synPreProc">$SECRET</span><span class="synStatement">"</span><span class="synConstant"> </span><span class="synStatement">|</span><span class="synSpecial"> jq -r </span><span class="synStatement">'</span><span class="synConstant">.user_name</span><span class="synStatement">'</span><span class="synPreProc">)</span>
<span class="synIdentifier">tech_blog_pattern</span>=<span class="synStatement">"</span><span class="synConstant">*gunosy-tech.hatenablog.com/entry/*</span><span class="synStatement">"</span>
<span class="synIdentifier">data_blog_pattern</span>=<span class="synStatement">"</span><span class="synConstant">*gunosy-data.hatenablog.com/entry/*</span><span class="synStatement">"</span>
<span class="synStatement">if [</span> <span class="synStatement">"</span><span class="synPreProc">$#</span><span class="synStatement">"</span> <span class="synStatement">-eq</span> <span class="synConstant">1</span> <span class="synStatement">]</span> <span class="synStatement">&&</span> <span class="synSpecial">[[</span> <span class="synStatement">"</span><span class="synPreProc">$1</span><span class="synStatement">"</span> <span class="synStatement">==</span> <span class="synPreProc">$tech_blog_pattern</span> <span class="synSpecial">]]</span><span class="synStatement">;</span> <span class="synStatement">then</span>
envsubst <span class="synStatement"><</span> <span class="synStatement">"</span><span class="synConstant">tech_blogsync.yaml.template</span><span class="synStatement">"</span> <span class="synStatement">></span> blogsync.yaml
blogsync fetch <span class="synStatement">"</span><span class="synPreProc">$1</span><span class="synStatement">"</span>
<span class="synStatement">rm</span> blogsync.yaml
<span class="synStatement">elif</span> <span class="synStatement">[</span> <span class="synStatement">"</span><span class="synPreProc">$#</span><span class="synStatement">"</span> <span class="synStatement">-eq</span> <span class="synConstant">1</span> <span class="synStatement">]</span> <span class="synStatement">&&</span> <span class="synSpecial">[[</span> <span class="synStatement">"</span><span class="synPreProc">$1</span><span class="synStatement">"</span> <span class="synStatement">==</span> <span class="synPreProc">$data_blog_pattern</span> <span class="synSpecial">]]</span><span class="synStatement">;</span> <span class="synStatement">then</span>
envsubst <span class="synStatement"><</span> <span class="synStatement">"</span><span class="synConstant">data_blogsync.yaml.template</span><span class="synStatement">"</span> <span class="synStatement">></span> blogsync.yaml
blogsync fetch <span class="synStatement">"</span><span class="synPreProc">$1</span><span class="synStatement">"</span>
<span class="synStatement">rm</span> blogsync.yaml
<span class="synStatement">else</span>
<span class="synStatement">echo</span><span class="synConstant"> </span><span class="synStatement">"</span><span class="synConstant">Usage: </span><span class="synPreProc">$0</span><span class="synConstant"> <path/to/file></span><span class="synStatement">"</span>
<span class="synStatement">exit</span> <span class="synConstant">1</span>
<span class="synStatement">fi</span>
</pre>
<h2 id="textlint-の導入">textlint の導入</h2>
<p>似たようなレビューを繰り返していたので、機械的に検知できる部分は CI に任せたいという思いがありました。
そこで、<a href="https://textlint.github.io/">textlint</a> を導入しました。 textlint は、テキストファイルの構文や文章の書き方をチェックするツールで、プラグインを利用することで、様々なチェックをおこなうことができます。</p>
<p>また、<a href="https://github.com/tsuyoshicho/action-textlint">action-textlint</a> を用いることで、 textlint の結果を Pull Request のコメントとして表示することができます。
自動修正可能なものについては変更を suggest してくれるため、修正も容易です。</p>
<p><figure class="figure-image figure-image-fotolife" title="reviewdog による commit suggestion"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m-hamashita/20231202/20231202144311.png" width="1262" height="878" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>reviewdog による commit suggestion</figcaption></figure></p>
<p>以下に GitHub Actions の設定を示します。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">name</span><span class="synSpecial">:</span> reviewdog
<span class="synIdentifier">on</span><span class="synSpecial">:</span> <span class="synSpecial">[</span>pull_request<span class="synSpecial">]</span>
<span class="synIdentifier">jobs</span><span class="synSpecial">:</span>
<span class="synIdentifier">textlint</span><span class="synSpecial">:</span>
<span class="synIdentifier">name</span><span class="synSpecial">:</span> runner / textlint
<span class="synIdentifier">runs-on</span><span class="synSpecial">:</span> ubuntu-latest
<span class="synIdentifier">steps</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">uses</span><span class="synSpecial">:</span> actions/checkout@v2
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">submodules</span><span class="synSpecial">:</span> <span class="synConstant">true</span>
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> textlint-github-pr-review
<span class="synIdentifier">uses</span><span class="synSpecial">:</span> tsuyoshicho/action-textlint@v3
<span class="synIdentifier">with</span><span class="synSpecial">:</span>
<span class="synIdentifier">github_token</span><span class="synSpecial">:</span> ${{ secrets.github_token }}
<span class="synIdentifier">reporter</span><span class="synSpecial">:</span> github-pr-review
<span class="synIdentifier">level</span><span class="synSpecial">:</span> warning
<span class="synIdentifier">textlint_flags</span><span class="synSpecial">:</span> <span class="synConstant">"**/*.md"</span>
</pre>
<p>現在はルールをあまり設定していません。これはルールをたくさん設定すると、GitHub Actions のコメントで埋まってしまう & 人によって流儀が異なるといったことがあったため、いったん最低限の設定にしています<a href="#f-c9cd9577" id="fn-c9cd9577" name="fn-c9cd9577" title="個人的に設定したいルールはたくさんあります">*3</a>。</p>
<h1 id="導入した結果">導入した結果</h1>
<p>導入した結果、過去のレビューの問題点がどのようになったか、また反省点について振り返ります。</p>
<ul>
<li>問題点振り返り
<ul>
<li>レビュー箇所をコピー&ペーストしてレビューするのが面倒
<ul>
<li>→ GitHub でレビューできるようになり、レビュー箇所を指定してレビューできるようになった</li>
<li>commit suggestion を使うことで、修正も楽になった</li>
</ul>
</li>
<li>レビュー内容が反映されないことが多々あった
<ul>
<li>→場所を指定してレビューできるので、修正されているか確認しやすくなった</li>
</ul>
</li>
<li>レビューされないまま公開される場合があった
<ul>
<li>→(意識的な問題か)レビューされずに公開されることはなくなった</li>
</ul>
</li>
<li>毎回同じようなレビューをおこなっていた
<ul>
<li>→ textlint で多少カバーできているが、まだ同じような指摘をおこなうことがある<a href="#f-c6fe46f2" id="fn-c6fe46f2" name="fn-c6fe46f2" title="ルールを実装しようと思っていましたが間に合いませんでした">*4</a></li>
</ul>
</li>
</ul>
</li>
<li>反省: 環境構築が難しい人がいた
<ul>
<li>全エンジニアが AWS CLI を入れていると思っていたがそうではなかった</li>
<li>→ README を手厚く書いたり、導入をサポートした。それでも難しい人のために、代替案を用意した</li>
</ul>
</li>
</ul>
<h1 id="まとめ">まとめ</h1>
<p>今回は、ブログのレビューを GitHub リポジトリでおこなうことで、レビューの効率化をおこないました。
今後はブログレビューをより効率化するために以下のことをしていきたいと思っています<a href="#f-c972624e" id="fn-c972624e" name="fn-c972624e" title="このブログを書いているときに GPT でのレビューが追加されていました。雰囲気良さそうな部分もあったので、ブログでも初手のレビューとして GPT を動かすというのも良さそうです">*5</a>。</p>
<ul>
<li><a href="https://github.com/hatena/hatenablog-workflows/tree/110e35a9da149dca8cb2b7d0f70c5e942cb561c7/.github/workflows">hatenablog-workflows</a> などのエッセンスを取り入れる
<ul>
<li>下書き作成から pull までを一貫して実行</li>
</ul>
</li>
<li>textlint の設定を拡充する
<ul>
<li>はてなブログに合わせたルールの自作</li>
</ul>
</li>
<li>ブランチルールによる draft Pull Request の自動作成</li>
</ul>
<p>明日は hyamamoto さんが稼働中のデータ基盤を安全に dbt 移行する仕組みについて書くそうです!とても楽しみです!</p>
<div class="footnote">
<p class="footnote"><a href="#fn-59cceb15" id="f-59cceb15" name="f-59cceb15" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">blogsync.yaml の取得に secrets コンテキストを用いていますが、 pull でおこなったように修正したいですね</span></p>
<p class="footnote"><a href="#fn-77021abb" id="f-77021abb" name="f-77021abb" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">最近 blogsync に GitHub Actions が追加されていましたのでこちらを使うと良さそうです <a href="https://github.com/x-motemen/blogsync?tab=readme-ov-file#github-actions">https://github.com/x-motemen/blogsync?tab=readme-ov-file#github-actions</a></span></p>
<p class="footnote"><a href="#fn-c9cd9577" id="f-c9cd9577" name="f-c9cd9577" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">個人的に設定したいルールはたくさんあります</span></p>
<p class="footnote"><a href="#fn-c6fe46f2" id="f-c6fe46f2" name="f-c6fe46f2" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">ルールを実装しようと思っていましたが間に合いませんでした</span></p>
<p class="footnote"><a href="#fn-c972624e" id="f-c972624e" name="f-c972624e" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text">このブログを書いているときに GPT でのレビューが追加されていました。雰囲気良さそうな部分もあったので、ブログでも初手のレビューとして GPT を動かすというのも良さそうです</span></p>
</div>
m-hamashita
いろんなやり方の読書会をやってみたら当日音読その場でまとめ方式が最高だった話
hatenablog://entry/6801883189062603939
2023-12-03T12:00:00+09:00
2024-01-11T21:23:11+09:00 こんにちは、SRE チーム マネージャーの TksYamaguchi です。 こちらの記事はGunosy Advent Calendar 2023の3日目の記事です。 前回の記事は森田さんの LLM 論文の探し方でした。 概要 SRE チームは、シニアエンジニアとジュニアエンジニアで構成されているチームで、チーム内の技術的な知識の偏りの解消や、知識のタコツボ化の解消を目的として読書会をしてきました。 その過程でいろいろな読書会のパターンを行ってきましたので、その結果を共有します。 概要 読書会開始前の SRE チームの課題感 読書会 のやり方の整理と評価の観点 やり方 評価の観点 当日音読・S…
<p>こんにちは、SRE チーム マネージャーの TksYamaguchi です。</p>
<p>こちらの記事は<a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a>の3日目の記事です。
前回の記事は森田さんの <a href="https://data.gunosy.io/entry/LLM_paper_survey">LLM 論文の探し方</a>でした。</p>
<h1 id="概要">概要</h1>
<p>SRE チームは、シニアエンジニアとジュニアエンジニアで構成されているチームで、チーム内の技術的な知識の偏りの解消や、知識のタコツボ化の解消を目的として読書会をしてきました。</p>
<p>その過程でいろいろな読書会のパターンを行ってきましたので、その結果を共有します。</p>
<ul class="table-of-contents">
<li><a href="#概要">概要</a></li>
<li><a href="#読書会開始前の-SRE-チームの課題感">読書会開始前の SRE チームの課題感</a></li>
<li><a href="#読書会-のやり方の整理と評価の観点">読書会 のやり方の整理と評価の観点</a><ul>
<li><a href="#やり方">やり方</a></li>
<li><a href="#評価の観点">評価の観点</a></li>
</ul>
</li>
<li><a href="#当日音読Slack-に投下する方式">当日音読・Slack に投下する方式</a><ul>
<li><a href="#実際のまとめ">実際のまとめ</a></li>
<li><a href="#方式詳細">方式詳細</a></li>
<li><a href="#評価">評価</a></li>
<li><a href="#メリット">メリット</a></li>
<li><a href="#デメリット">デメリット</a></li>
</ul>
</li>
<li><a href="#事前に読んで-Miro1-に付箋当日議論する方式">事前に読んで Miro*1 に付箋・当日議論する方式</a><ul>
<li><a href="#実際のまとめ-1">実際のまとめ</a></li>
<li><a href="#方式詳細-1">方式詳細</a></li>
<li><a href="#評価-1">評価</a></li>
<li><a href="#メリット-1">メリット</a></li>
<li><a href="#デメリット-1">デメリット</a></li>
</ul>
</li>
<li><a href="#当日黙読その場で-Miro-に付箋当日議論する方式">当日黙読・その場で Miro に付箋・当日議論する方式</a><ul>
<li><a href="#実際のまとめ-2">実際のまとめ</a></li>
<li><a href="#方式詳細-2">方式詳細</a></li>
<li><a href="#評価-2">評価</a></li>
<li><a href="#メリット-2">メリット</a></li>
<li><a href="#デメリット-2">デメリット</a></li>
</ul>
</li>
<li><a href="#当日音読その場でモブで-Miro-にまとめ方式">当日音読・その場でモブで Miro にまとめ方式</a><ul>
<li><a href="#実際のまとめ-3">実際のまとめ</a></li>
<li><a href="#方式詳細-3">方式詳細</a></li>
<li><a href="#評価-3">評価</a></li>
<li><a href="#メリット-3">メリット</a></li>
<li><a href="#デメリット-3">デメリット</a></li>
</ul>
</li>
<li><a href="#当日音読その場でモブで-Confluence-にまとめ方式">当日音読・その場でモブで Confluence にまとめ方式</a><ul>
<li><a href="#実際のまとめ-4">実際のまとめ</a></li>
<li><a href="#方式詳細-4">方式詳細</a></li>
<li><a href="#評価-4">評価</a></li>
<li><a href="#メリット-4">メリット</a></li>
<li><a href="#デメリット-4">デメリット</a></li>
</ul>
</li>
<li><a href="#例外-BlackBelt-の動画をみんなで見る会">例外: BlackBelt の動画をみんなで見る会</a><ul>
<li><a href="#実際のまとめ-5">実際のまとめ</a></li>
<li><a href="#方式詳細-5">方式詳細</a></li>
<li><a href="#評価-5">評価</a></li>
<li><a href="#メリット-5">メリット</a></li>
<li><a href="#デメリット-5">デメリット</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h1 id="読書会開始前の-SRE-チームの課題感">読書会開始前の SRE チームの課題感</h1>
<p>前述の通り、SRE チームは2名で構成されていて、TksYamaguchi はマネージャーです。</p>
<p>チームメンバーのバックグラウンドも異なり、また読書会開始前の時点ではセキュリティ施策は主に私が担当してたため、セキュリティに関する知識や、その他技術スキルに偏りがある状態でした。</p>
<p>今後セキュリティやその他施策を全社展開していくにあたり、スキルや知識の偏りがタスクアサインに影響を与える可能性があるため、読書会を通じてお互いの技術移転や知識伝搬を行うことを計画しました。</p>
<p>社外事例を調査すると、読書会のやり方にはさまざまな方式があり、また社内でも複数のパターンが見受けられましたので、チームに適用できそうな方式は積極的に試してみました。</p>
<p>今回は業務に必要な知識を、業務時間を使って得ることを目的としているので、業務外時間をできるだけ使わない方式を模索しました。</p>
<h1 id="読書会-のやり方の整理と評価の観点">読書会 のやり方の整理と評価の観点</h1>
<h3 id="やり方">やり方</h3>
<p>検索してみるといろんなやり方がありましたが、こんな感じで整理しました。
開催タイミングもパラメーターとしてはありますが、今回は<strong>業務時間に業務に必要な知識をチームで得る</strong>という目的のため除外しています。</p>
<ul>
<li>事前準備の有無</li>
<li>まとめの作成の有無とその方法</li>
<li>議論の証跡作成の有無</li>
<li>議論のまとめ作成の有無とその方法</li>
<li>音読の有無</li>
</ul>
<h3 id="評価の観点">評価の観点</h3>
<p>いろんな観点があると思いますが、今回はこんな観点で整理しました。</p>
<ul>
<li>手軽さ</li>
<li>理解の深まりの程度</li>
<li>ストック情報が残るか
<ul>
<li>後で見返した時に役に立つか</li>
</ul>
</li>
<li>読書の進むスピード</li>
</ul>
<p>評価値は 5 点満点で★の数で表します。</p>
<p>実際にどんな読書会をやってみたかを以下に記載します。</p>
<h2 id="当日音読Slack-に投下する方式">当日音読・Slack に投下する方式</h2>
<p>読書会当日に、範囲を決めて一人が音読(節単位)して、音読した範囲についての感想や、参考記事を Slack のスレに投下していく方式です。</p>
<p>こんな感じで進めました。</p>
<ul>
<li>みんなで集まって、1 人が音読
<ul>
<li>音読担当は節単位で交代</li>
</ul>
</li>
<li>気になったところ、重要だと思うところを Slack のスレに書く
<ul>
<li>参考記事があればそのURLもスレに書く</li>
</ul>
</li>
<li>節単位にみんなで議論する
<ul>
<li>議論の中で<strong>チームに持ち帰って改善できる内容が無いかを毎回確認する</strong></li>
</ul>
</li>
<li>議論した感じたことや重要だと思うことをそれぞれスレに書く</li>
</ul>
<h4 id="実際のまとめ">実際のまとめ</h4>
<p><figure class="figure-image figure-image-fotolife" title="クラウドセキュリティ本を読んだ時のSlackのスレ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/T/TksYamaguchi/20231129/20231129144342.png" width="1001" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>クラウドセキュリティ本を読んだ時のSlackのスレ</figcaption></figure></p>
<h4 id="方式詳細">方式詳細</h4>
<table>
<thead>
<tr>
<th>パラメータ</th>
<th>値</th>
</tr>
</thead>
<tbody>
<tr>
<td> 事前準備 </td>
<td>無 </td>
</tr>
<tr>
<td> まとめ作成 </td>
<td>無 </td>
</tr>
<tr>
<td> まとめ方法 </td>
<td>ー </td>
</tr>
<tr>
<td> 議論の証跡作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> 議論のまとめ作成 </td>
<td>無 </td>
</tr>
<tr>
<td> 議論のまとめ方法 </td>
<td>ー </td>
</tr>
<tr>
<td> 音読 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
</tbody>
</table>
<h4 id="評価">評価</h4>
<table>
<thead>
<tr>
<th>評価指標</th>
<th>結果</th>
</tr>
</thead>
<tbody>
<tr>
<td>手軽さ </td>
<td>★★★★★ </td>
</tr>
<tr>
<td>理解の深まりの程度 </td>
<td>★★★ </td>
</tr>
<tr>
<td>ストック情報が残るか </td>
<td>★ </td>
</tr>
<tr>
<td>読書の進むスピード </td>
<td>★★★★★ </td>
</tr>
</tbody>
</table>
<p>手軽に読書会できる点は良く、理解も読書会時点では深いのですが、後で見返すものが散文的な Slack だけになるのが問題点です。</p>
<p>しかし、読書会の回数をこなしてその本に慣れてくると口頭での議論で済ませてしまい、加えて Slack への投下が少なくなる傾向が見られました。</p>
<h4 id="メリット">メリット</h4>
<ul>
<li>お手軽</li>
<li>進みが早い</li>
</ul>
<h4 id="デメリット">デメリット</h4>
<ul>
<li>理解の程度が浅くなりがち</li>
<li>ストック情報が残らない</li>
</ul>
<h2 id="事前に読んで-Miro1-に付箋当日議論する方式">事前に読んで Miro<a href="#f-b8e9a82e" id="fn-b8e9a82e" name="fn-b8e9a82e" title="https://miro.com/ja/">*1</a> に付箋・当日議論する方式</h2>
<p>これは朝会で確保していた時間が余りはじめたので、余った時間で読書会にチャレンジしてみた時にやってみた方式です。</p>
<p>こんな感じで進めました。</p>
<ul>
<li>各自で事前に数ページを読んで、 気になったところ、重要だと思うところを Miro に付箋で貼る</li>
<li>当日、お互いの付箋で、気になるところ議論したいところを投票する</li>
<li>投票数が多いものから順にみんなで議論して理解を深めていく
<ul>
<li>議論した内容は Miro に付箋で残す</li>
</ul>
</li>
<li>最後に<strong>チームに持ち帰って改善できる内容が無いかを確認する</strong>
<ul>
<li>持ち帰れる内容を Miro に付箋で残す</li>
</ul>
</li>
</ul>
<h4 id="実際のまとめ-1">実際のまとめ</h4>
<p><figure class="figure-image figure-image-fotolife" title="入門監視を読んだ時のMiro"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/T/TksYamaguchi/20231129/20231129144351.png" width="1200" height="630" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>入門監視を読んだ時のMiro</figcaption></figure></p>
<h4 id="方式詳細-1">方式詳細</h4>
<table>
<thead>
<tr>
<th>パラメータ</th>
<th>値</th>
</tr>
</thead>
<tbody>
<tr>
<td> 事前準備 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> まとめ作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> まとめ方法 </td>
<td>Miro に付箋 </td>
</tr>
<tr>
<td> 議論の証跡作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> 議論のまとめ作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> 議論のまとめ方法 </td>
<td>Miro に付箋 </td>
</tr>
<tr>
<td> 音読 </td>
<td>無 </td>
</tr>
</tbody>
</table>
<h4 id="評価-1">評価</h4>
<table>
<thead>
<tr>
<th>評価指標</th>
<th>結果</th>
</tr>
</thead>
<tbody>
<tr>
<td>手軽さ </td>
<td>★★★ </td>
</tr>
<tr>
<td>理解の深まりの程度 </td>
<td>★★★★ </td>
</tr>
<tr>
<td>ストック情報が残るか </td>
<td>★★★ </td>
</tr>
<tr>
<td>読書の進むスピード </td>
<td>★★★ </td>
</tr>
</tbody>
</table>
<p>事前読書と付箋のまとめ作成がある分だけ負担が高く、負担を高くしすぎないために数ページづつ読むことにしたので読書の進みもイマイチでした。
付箋で気になったところを残して、当日議論して、議論した結果も付箋で残したので理解の深まりも良く、後から見返すものもあり、短時間で行うには良い方法でした。</p>
<p>ただ、ストック情報になっているのはあくまで読書会に参加した人たち用のストック情報なので、再利用性がないのが問題点です。</p>
<h4 id="メリット-1">メリット</h4>
<ul>
<li>理解が深い</li>
<li>情報がストックされる</li>
</ul>
<h4 id="デメリット-1">デメリット</h4>
<ul>
<li>事前準備の負担がある</li>
<li>進みが遅い</li>
<li>ストックされる情報が自分たち用</li>
</ul>
<h2 id="当日黙読その場で-Miro-に付箋当日議論する方式">当日黙読・その場で Miro に付箋・当日議論する方式</h2>
<p>事前に読んで Miro に付箋・当日議論する方式 の事前に読んで Miro に付箋を貼るところを当日やってみました。</p>
<p>こんな感じで進めました。</p>
<ul>
<li>みんなで集まって、40 分で1章を黙読</li>
<li>気になったところ、重要だと思うところを Miro に付箋で貼る</li>
<li>当日、お互いの付箋で、気になるところ議論したいところを投票する</li>
<li>投票数が多いものから順にみんなで議論して理解を深めていく
<ul>
<li>議論した内容は Miro に付箋で残す</li>
</ul>
</li>
<li>最後に<strong>チームに持ち帰って改善できる内容が無いかを確認する</strong>
<ul>
<li>持ち帰れる内容を Miro に付箋で残す</li>
</ul>
</li>
</ul>
<h4 id="実際のまとめ-2">実際のまとめ</h4>
<p><figure class="figure-image figure-image-fotolife" title="SRE本を読んだ時のMiro"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/T/TksYamaguchi/20231129/20231129144357.png" width="1200" height="952" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>SRE本を読んだ時のMiro</figcaption></figure></p>
<h4 id="方式詳細-2">方式詳細</h4>
<table>
<thead>
<tr>
<th>パラメータ</th>
<th>値</th>
</tr>
</thead>
<tbody>
<tr>
<td> 事前準備 </td>
<td>無 </td>
</tr>
<tr>
<td> まとめ作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> まとめ方法 </td>
<td>Miro に付箋 </td>
</tr>
<tr>
<td> 議論の証跡作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> 議論のまとめ作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> 議論のまとめ方法 </td>
<td>Miro に付箋 </td>
</tr>
<tr>
<td> 音読 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
</tbody>
</table>
<h4 id="評価-2">評価</h4>
<table>
<thead>
<tr>
<th>評価指標</th>
<th>結果</th>
</tr>
</thead>
<tbody>
<tr>
<td>手軽さ </td>
<td>★★ </td>
</tr>
<tr>
<td>理解の深まりの程度 </td>
<td>★★★★ </td>
</tr>
<tr>
<td>ストック情報が残るか </td>
<td>★★★ </td>
</tr>
<tr>
<td>読書の進むスピード </td>
<td>★ </td>
</tr>
</tbody>
</table>
<p>事前準備がない分だけお手軽かと思ったのですが、当日黙読で理解して感じたことを付箋を貼っていくのが思いのほか負荷が高く、数回で断念しました。</p>
<h4 id="メリット-2">メリット</h4>
<ul>
<li>理解の深まりはある</li>
<li>情報がストックされる</li>
</ul>
<h4 id="デメリット-2">デメリット</h4>
<ul>
<li>負荷が高い</li>
<li>進みが遅い</li>
<li>ストックされる情報が自分たち用</li>
</ul>
<h2 id="当日音読その場でモブで-Miro-にまとめ方式">当日音読・その場でモブで Miro にまとめ方式</h2>
<p>当日音読の手軽さに気付いたのと Miro にまとめるのが思いのほかよかったので、それを組み合わせてみました。</p>
<p>こんな感じで進めました。</p>
<ul>
<li>みんなで集まって、1人が音読
<ul>
<li>音読担当は節単位で交代</li>
</ul>
</li>
<li>気になったところ、重要だと思うところをみんなで<strong>ワイワイ</strong>しながら Miro に付箋で貼る
<ul>
<li>参考記事があればその URL も Miro に貼り付ける</li>
</ul>
</li>
<li>節単位にみんなで<strong>ワイワイ</strong>議論する
<ul>
<li>議論の中で<strong>チームに持ち帰って改善できる内容が無いかを毎回確認する</strong></li>
<li>持ち帰れる内容をみんなで<strong>ワイワイ</strong>しながら Miro に付箋で残す</li>
</ul>
</li>
</ul>
<h4 id="実際のまとめ-3">実際のまとめ</h4>
<p><figure class="figure-image figure-image-fotolife" title="SRE本を読んだ時のMiro"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/T/TksYamaguchi/20231129/20231129144402.png" width="1200" height="1169" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>SRE本を読んだ時のMiro</figcaption></figure></p>
<h4 id="方式詳細-3">方式詳細</h4>
<table>
<thead>
<tr>
<th>パラメータ</th>
<th>値</th>
</tr>
</thead>
<tbody>
<tr>
<td> 事前準備 </td>
<td>無 </td>
</tr>
<tr>
<td> まとめ作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> まとめ方法 </td>
<td> Miro で付箋 </td>
</tr>
<tr>
<td> 議論の証跡作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> 議論のまとめ作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> 議論のまとめ方法 </td>
<td> Miro で付箋 </td>
</tr>
<tr>
<td> 音読 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
</tbody>
</table>
<h4 id="評価-3">評価</h4>
<table>
<thead>
<tr>
<th>評価指標</th>
<th>結果</th>
</tr>
</thead>
<tbody>
<tr>
<td>手軽さ </td>
<td>★★★★★ </td>
</tr>
<tr>
<td>理解の深まりの程度 </td>
<td>★★★★★ </td>
</tr>
<tr>
<td>ストック情報が残るか </td>
<td>★★★ </td>
</tr>
<tr>
<td>読書の進むスピード </td>
<td>★★★★ </td>
</tr>
</tbody>
</table>
<p>まとめも議論もワイワイしながら Miro に付箋を貼っていくので、ワーク感があって楽しめます。</p>
<h4 id="メリット-3">メリット</h4>
<ul>
<li>お手軽</li>
<li>楽しい</li>
<li>理解が深まる</li>
<li>意外と進みが早い</li>
</ul>
<h4 id="デメリット-3">デメリット</h4>
<ul>
<li>ストックされる情報が自分たち用</li>
</ul>
<h2 id="当日音読その場でモブで-Confluence-にまとめ方式">当日音読・その場でモブで Confluence にまとめ方式</h2>
<p>Miro にまとめると自分たち用の域をでないので、思い切って Confluence にまとめてみました。</p>
<p>こんな感じで進めました。</p>
<ul>
<li>みんなで集まって、1人が音読
<ul>
<li>音読担当は節単位で交代</li>
</ul>
</li>
<li>気になったところ、重要だと思うところをみんなで<strong>ワイワイ</strong>しながら Confluence に書いていく
<ul>
<li>参考記事があればその URL も Confluence に貼り付ける</li>
</ul>
</li>
<li>節単位にみんなで<strong>ワイワイ</strong>議論する
<ul>
<li>議論の中で<strong>チームに持ち帰って改善できる内容が無いかを毎回確認する</strong></li>
<li>持ち帰れる内容をみんなで<strong>ワイワイ</strong>しながら Confluence で残す</li>
</ul>
</li>
</ul>
<h4 id="実際のまとめ-4">実際のまとめ</h4>
<p><figure class="figure-image figure-image-fotolife" title="SRE本を読んだ時のConfluence"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/T/TksYamaguchi/20231129/20231129144408.png" width="1200" height="963" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>SRE本を読んだ時のConfluence</figcaption></figure></p>
<h4 id="方式詳細-4">方式詳細</h4>
<table>
<thead>
<tr>
<th>パラメータ</th>
<th>値</th>
</tr>
</thead>
<tbody>
<tr>
<td> 事前準備 </td>
<td>無 </td>
</tr>
<tr>
<td> まとめ作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> まとめ方法 </td>
<td> Confluence に箇条書き </td>
</tr>
<tr>
<td> 議論の証跡作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> 議論のまとめ作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> 議論のまとめ方法 </td>
<td>Confluence に箇条書き </td>
</tr>
<tr>
<td> 音読 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
</tbody>
</table>
<h4 id="評価-4">評価</h4>
<table>
<thead>
<tr>
<th>評価指標</th>
<th>結果</th>
</tr>
</thead>
<tbody>
<tr>
<td>手軽さ </td>
<td>★★★★ </td>
</tr>
<tr>
<td>理解の深まりの程度 </td>
<td>★★★★★ </td>
</tr>
<tr>
<td>ストック情報が残るか </td>
<td>★★★★★ </td>
</tr>
<tr>
<td>読書の進むスピード </td>
<td>★★★ </td>
</tr>
</tbody>
</table>
<p>まとめも議論の過程結果を Confluence にまとめるのは、付箋よりも負担感はありました。
ただ、後で再利用可能な情報がストックできるのは大きい利点です。</p>
<h4 id="メリット-4">メリット</h4>
<ul>
<li>お手軽</li>
<li>楽しい</li>
<li>理解が深まる</li>
<li>再利用できるストック情報が残る</li>
</ul>
<h4 id="デメリット-4">デメリット</h4>
<ul>
<li>進みが遅い</li>
<li>まとめ作業の負担が付箋よりも大きい</li>
</ul>
<h2 id="例外-BlackBelt-の動画をみんなで見る会">例外: BlackBelt の動画をみんなで見る会</h2>
<p>これは、AWS BlackBelt で公開されている動画をみんなで見ながら Slack のスレで議論したり、見終わった後に感想戦をする形式になります。
初期は BlackBelt 読み会として担当者が資料を読み込んでみんなに説明する形式で始めたのですが、試しに動画見ながらみんなでワイワイ議論してみたら、そっちの方が準備が楽でさらに学びがあったので、今の形式に落ち着いています。</p>
<p>こんな機能があったんだーとか、こっちのプロダクトではこんな感じでつかっているよー、みたいな感じでチーム間での情報交換も出来てお手軽なんですが、楽しい会です。</p>
<p>こんな感じで進めました。</p>
<ul>
<li>担当者を決める</li>
<li>当日までに担当者がみんなで見る動画を決める</li>
<li>Meet 上に集まって、担当者が動画を再生してみんなで鑑賞( Meet でタブを共有すると動画の音声も流せる)
<ul>
<li>鑑賞しながら気になったところとか、参照URLとかをみんなで<strong>ワイワイ</strong>しながら Slack のスレに書いていく</li>
</ul>
</li>
<li>動画が終わったら、マイクをONにしたりでみんなで議論したりする</li>
<li>最後に次の担当者を決める</li>
</ul>
<h4 id="実際のまとめ-5">実際のまとめ</h4>
<p><figure class="figure-image figure-image-fotolife" title="実際の議論スレ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/T/TksYamaguchi/20231201/20231201103004.png" width="1200" height="1008" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>実際の議論スレ</figcaption></figure></p>
<h4 id="方式詳細-5">方式詳細</h4>
<table>
<thead>
<tr>
<th>パラメータ</th>
<th>値</th>
</tr>
</thead>
<tbody>
<tr>
<td> 事前準備 </td>
<td>無 </td>
</tr>
<tr>
<td> まとめ作成 </td>
<td>無 </td>
</tr>
<tr>
<td> まとめ方法 </td>
<td>ー </td>
</tr>
<tr>
<td> 議論の証跡作成 </td>
<td><span style="color: #ff5252">有</span> </td>
</tr>
<tr>
<td> 議論のまとめ作成 </td>
<td>無 </td>
</tr>
<tr>
<td> 議論のまとめ方法 </td>
<td>ー </td>
</tr>
<tr>
<td> 音読 </td>
<td>無 </td>
</tr>
</tbody>
</table>
<h4 id="評価-5">評価</h4>
<table>
<thead>
<tr>
<th>評価指標</th>
<th>結果</th>
</tr>
</thead>
<tbody>
<tr>
<td>手軽さ </td>
<td>★★★★★ </td>
</tr>
<tr>
<td>理解の深まりの程度 </td>
<td>★★★★ </td>
</tr>
<tr>
<td>ストック情報が残るか </td>
<td>★★★ </td>
</tr>
<tr>
<td>読書の進むスピード </td>
<td>★★★★★ </td>
</tr>
</tbody>
</table>
<p>お手軽さは最高ですし、みんなでワイワイ議論しながら理解も深められるので、用途が合えばお勧めです。</p>
<h4 id="メリット-5">メリット</h4>
<ul>
<li>お手軽</li>
<li>楽しい</li>
<li>理解が深まる</li>
</ul>
<h4 id="デメリット-5">デメリット</h4>
<ul>
<li>対象が BlackBelt で公開されているものだけになる</li>
<li>ストック情報が残らない</li>
</ul>
<h1 id="まとめ">まとめ</h1>
<p>今のところは、しっかりまとめたい時は「当日音読・その場でモブで Confluence にまとめ方式」、議論して発散させたい時は「当日音読・その場でモブで Miro にまとめ方式」が良さそうな感じでした。</p>
<p>読書会の方法もいろいろあり、目的や使える時間、許容可能な負担感に合わせて開催することで、持続可能で意義のある読書会になると思います。
今回の取り組みが参考になれば幸いです。</p>
<p>明日は m-hamashita さんが GitHub 上でブログのレビューをおこなっている話について書くそうです!お楽しみに!</p>
<div class="footnote">
<p class="footnote"><a href="#fn-b8e9a82e" id="f-b8e9a82e" name="f-b8e9a82e" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://miro.com/ja/">https://miro.com/ja/</a></span></p>
</div>
TksYamaguchi
Android DataStore の段階導入
hatenablog://entry/6801883189060238400
2023-12-01T15:00:00+09:00
2024-01-11T21:21:54+09:00 Android DataStore の導入と段階的移行の基本実装を行ったため、その内容についてお伝えします。
<p>こんにちは。Android アプリ開発担当の nagayama(<a href="https://twitter.com/nagayan_dev">@nagayan_dev</a>)です。<br></p>
<p>この記事は <a href="https://adventar.org/calendars/9182">Gunosy Advent Calendar 2023</a> の 1 日目の記事です。昨年最後の記事は koid さんの <a href="https://tech.gunosy.io/entry/internal-communications-in-emergency">障害発生時の社内コミュニケーションをスムーズにするために</a> でした。 今年もよろしくお願いします。<br></p>
<p>今回は私が担当している「auサービスToday」で DataStore の導入と段階的移行の基本実装を行ったため、その内容についてお伝えします。</p>
<p><br>
<figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20231120/20231120113628.png" width="500" height="405" loading="lazy" title="" class="hatena-fotolife" style="width:450px" itemprop="image"></span></figure>
<br></p>
<ul class="table-of-contents">
<li><a href="#DataStore-とは">DataStore とは</a></li>
<li><a href="#現状と対応方針">現状と対応方針</a><ul>
<li><a href="#方針-1-Repository-のインタフェースとなっているデータごとの取得保存メソッドはそのまま使用する">方針 1. Repository のインタフェースとなっている「データごとの取得・保存メソッド」はそのまま使用する</a></li>
<li><a href="#方針-2-Repository-の戻り値は-Flow型-にする">方針 2. Repository の戻り値は Flow型 にする</a></li>
<li><a href="#方針-3-SharedPreferences-と-DataStore-が共存できるようにする">方針 3. SharedPreferences と DataStore が共存できるようにする</a></li>
</ul>
</li>
<li><a href="#移行実装">移行実装</a><ul>
<li><a href="#依存関係を追加">依存関係を追加</a></li>
<li><a href="#enum-クラスの-DataStoreKeys-を作成">enum クラスの DataStoreKeys を作成</a></li>
<li><a href="#DataStore-へ保存取得するメソッドを作成">DataStore へ保存・取得するメソッドを作成</a></li>
<li><a href="#SharedPreferences-から-Migration-をする">SharedPreferences から Migration をする</a></li>
<li><a href="#DataStore-の処理に繋ぎこむ">DataStore の処理に繋ぎこむ</a></li>
<li><a href="#Repository-を使用する-ViewModel--UseCase-側の変更をする">Repository を使用する ViewModel / UseCase 側の変更をする</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<p><br></p>
<h1 id="DataStore-とは">DataStore とは</h1>
<p>DataStore とは、プロトコルバッファを使用して Key-Value ペアや型付きオブジェクトを格納できるデータストレージソリューションです。同じような機能として Android 1.5 から SharedPreferences があり、「auサービスToday」でも長い間使用してきました。公式のアナウンスからも SharedPreferences から DataStore への移行が推奨されているため、今回を機に対応したいと思います。</p>
<p>DataStore には 2 種類あります。SharedPreferences と同様にキーを使用してデータを保存する Preferences DataStore と、データ型のインスタンスとして保存をする Proto DataStore です。今回は Preferences DataStore で進めたいと思います。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.android.com%2Ftopic%2Flibraries%2Farchitecture%2Fdatastore" title="App Architecture: Data Layer - DataStore - Android Developers" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.android.com/topic/libraries/architecture/datastore">developer.android.com</a></cite></p>
<h1 id="現状と対応方針">現状と対応方針</h1>
<p>「auサービスToday」は MVVM のアーキテクチャパターンを採用しています。Model 層に PreferencesRepository が存在し、SharedPreferences で使用するキーの定義やデータごとの取得・保存のメソッドがあります。</p>
<p> PreferencesRepository の例</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@Singleton</span>
<span class="synType">class</span> PreferencesRepository <span class="synIdentifier">@Inject</span> <span class="synType">constructor</span>(
<span class="synType">private</span> <span class="synType">val</span> context: Context
) {
<span class="synType">companion</span> <span class="synType">object</span> {
<span class="synType">private</span> <span class="synType">const</span> <span class="synType">val</span> PREF_NAME = <span class="synConstant">"name"</span>
<span class="synComment">// Preference のキー</span>
<span class="synType">private</span> <span class="synType">const</span> <span class="synType">val</span> PREF_KEY_AGREE_FOR_TERMS = <span class="synConstant">"agree_for_terms"</span>
〜省略〜
}
<span class="synType">private</span> <span class="synType">val</span> preference: SharedPreferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
<span class="synType">fun</span> setAgreedForTerms(isAgree: <span class="synType">Boolean</span>) {
preference.edit().putBoolean(PREF_KEY_AGREE_FOR_TERMS, isAgree).apply()
}
<span class="synType">fun</span> isAgreedForTerms(): <span class="synType">Boolean</span> {
<span class="synStatement">return</span> preference.getBoolean(PREF_KEY_AGREE_FOR_TERMS, <span class="synConstant">false</span>)
}
}
</pre>
<p>今回 DataStore に移行するにあたり、下記の方針としました。</p>
<h4 id="方針-1-Repository-のインタフェースとなっているデータごとの取得保存メソッドはそのまま使用する">方針 1. Repository のインタフェースとなっている「データごとの取得・保存メソッド」はそのまま使用する</h4>
<p>アプリのファーストリリースからの設計方針であったため、多くの UseCase から参照されています。そのため、例えばインタフェースを getString のようにシンプルにするといった変更をすると、アプリケーションに多大な影響が出てしまいます。今回は影響を最小限にするよう、インタフェース部分はそのまま使用します。</p>
<h4 id="方針-2-Repository-の戻り値は-Flow型-にする">方針 2. Repository の戻り値は Flow型 にする</h4>
<p>インタフェースはそのままと言いましたが、取得メソッドの戻り値は Flow 型に変更します。</p>
<p>DataStore は Flow 型で返されることが特徴です。一方、ViewModel や UseCase では SharedPreferences から取得された値を LiveData や Flow に変換して使用するシーンが多かったです。不要な変換処理を減らす観点から、DataStore で取得された Flow 型をそのまま返して使用するようにします。</p>
<h4 id="方針-3-SharedPreferences-と-DataStore-が共存できるようにする">方針 3. SharedPreferences と DataStore が共存できるようにする</h4>
<p>PreferencesRepository が多くの UseCase から参照されている関係で、一度に全ての処理を DataStore へ移行することが難しいです。段階を分けて対応をしていくため、一定期間までは SharedPreferences と DataStore が共存する状態になります。</p>
<h1 id="移行実装">移行実装</h1>
<p>それでは移行実装を行なっていきます。</p>
<h3 id="依存関係を追加">依存関係を追加</h3>
<p>まずは build.gradle ファイルに Preference DataStore の依存関係を追加します。</p>
<pre class="code" data-lang="" data-unlink>dependencies {
implementation "androidx.datastore:datastore-preferences:$DATA_STORE_VERSION"
}</pre>
<p>下記から最新の DataStore のバージョンを確認できます。
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.android.com%2Fjetpack%2Fandroidx%2Freleases%2Fdatastore" title="DataStore | Jetpack | Android Developers" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.android.com/jetpack/androidx/releases/datastore">developer.android.com</a></cite></p>
<h3 id="enum-クラスの-DataStoreKeys-を作成">enum クラスの DataStoreKeys を作成</h3>
<p>一定期間 SharedPreferences と DataStore が共存する状態となるため、PreferencesRepository で定義されたキーの文字列がどこまで DataStore へ移行ができているかの判別がつきにくくなっていました。「DataStore へ移行したキーの一覧」が分かるように、 enum クラスを作成しました。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">enum</span> <span class="synType">class</span> DataStoreKeys {
agree_for_terms
}
</pre>
<p>DataStoreKeys.agree_for_terms.names のように参照を簡略化するため、キーの文字列をそのまま enum に定義しました。</p>
<h3 id="DataStore-へ保存取得するメソッドを作成">DataStore へ保存・取得するメソッドを作成</h3>
<p>DataStore へ保存・取得等を行うメソッドを作成します。</p>
<p>DataStore インスタンスは、preferencesDataStore を用いて作成します。引数の name には SharedPreferences と同様に設定名を指定します。後にこの name が内部でフォルダ名になるとのことです。</p>
<p>今回は Boolean の値の保存と取得を例にしているため、 updateBoolean と getBoolean を作成します。getBoolean は戻り値を Flow 型にしています。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">private</span> <span class="synType">val</span> Context.dataStore: DataStore<Preferences> <span class="synStatement">by</span> preferencesDataStore(
name = PREF_NAME
)
<span class="synType">suspend</span> <span class="synType">fun</span> updateBoolean(key: DataStoreKeys, value: <span class="synType">Boolean</span>) {
<span class="synType">val</span> preferenceKey = booleanPreferencesKey(key.name)
context.dataStore.edit { preferences <span class="synType">-></span>
preferences[preferenceKey] = value
}
}
<span class="synType">fun</span> getBoolean(key: DataStoreKeys, defaultValue: <span class="synType">Boolean</span>?): Flow<<span class="synType">Boolean</span>?> {
<span class="synType">val</span> preferenceKey = booleanPreferencesKey(key.name)
<span class="synStatement">return</span> context.dataStore.data.map { preferences <span class="synType">-></span>
preferences[preferenceKey] <span class="synStatement">?:</span> defaultValue
}
}
</pre>
<h3 id="SharedPreferences-から-Migration-をする">SharedPreferences から Migration をする</h3>
<p>SharedPreferences から DataStore へ移行するため、元々保存していた値を持ってくる必要があります。preferencesDataStore の引数に produceMigrations があり、移行する対象を DataMigration のリストを指定します。DataMigration を継承している SharedPreferencesMigration のインスタンスを作成し、その引数の keysToMigrate に移行対象のキーを入れて Migration を行います。</p>
<p>また、この Migration をすることで SharedPreferences 側が削除されて DataStore に移行される ため、保存データが増えてしまう心配も無用です。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">companion</span> <span class="synType">object</span> {
<span class="synType">private</span> <span class="synType">val</span> KEY_TO_MIGRATE = DataStoreKeys.values().map { it.name }.toSet()
}
<span class="synType">private</span> <span class="synType">val</span> Context.dataStore: DataStore<Preferences> <span class="synStatement">by</span> preferencesDataStore(
name = PREF_NAME,
produceMigrations = { context <span class="synType">-></span>
listOf(
SharedPreferencesMigration(
context = context,
sharedPreferencesName = PREF_NAME,
keysToMigrate = KEY_TO_MIGRATE
)
)
}
)
〜省略〜
</pre>
<h3 id="DataStore-の処理に繋ぎこむ">DataStore の処理に繋ぎこむ</h3>
<p>DataStore のメソッドに繋ぎ込みをします。保存メソッドは suspend 関数にし、取得メソッドの戻り値は Flow 型に変更します。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@Singleton</span>
<span class="synType">class</span> PreferencesRepository <span class="synIdentifier">@Inject</span> <span class="synType">constructor</span>(〜省略〜) {
〜省略〜
<span class="synType">suspend</span> <span class="synType">fun</span> setAgreedForTerms(isAgree: <span class="synType">Boolean</span>) {
updateBoolean(DataStoreKeys.agree_for_terms, isAgree)
}
<span class="synType">fun</span> isAgreedForTerms(): Flow<<span class="synType">Boolean</span>?> {
<span class="synStatement">return</span> getBoolean(DataStoreKeys.agree_for_terms, <span class="synConstant">false</span>)
}
}
</pre>
<h3 id="Repository-を使用する-ViewModel--UseCase-側の変更をする">Repository を使用する ViewModel / UseCase 側の変更をする</h3>
<p>Repository の変更に伴い、使用している ViewModel / UseCase 側も変更を行います。</p>
<p>呼び元は Coroutines で実行する必要があります。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">fun</span> onAgree() {
viewModelScope.launch {
useCase.setAgreedForTerms(isChecked)
}
}
</pre>
<p>またちょっとしたところで使用できるよう、 Coroutines で実行しなくても良いように getBlockedFirstOrNull の Extension を作成しました。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">fun</span> <T> Flow<T>.getBlockedFirstOrNull(): T? {
<span class="synStatement">return</span> runBlocking(Dispatchers.Default) {
<span class="synStatement">this</span>@getBlockedFirstOrNull.firstOrNull()
}
}
</pre>
<p>使用例は下記のようになります。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">fun</span> onCheck() {
<span class="synStatement">if</span> (preferenceRepository.isAgreedForTerms().getBlockedFirstOrNull() <span class="synStatement">==</span> <span class="synConstant">true</span>) {
showPopup()
}
}
</pre>
<p>以上で DataStore の段階移行が完了となります。</p>
<p>今回は Boolean の方法を記載しましたが、 String や Int 等の場合でも同様な実装になります。</p>
<h1 id="まとめ">まとめ</h1>
<p>DataStore の導入と段階的な移行をするための基本実装を行いました。今後のスムーズな移行実装を実現する設計にできたと思います。また一括で対応をするのではなく徐々に置き換えを進めていくことで、エラーの検知や QA の負荷を抑えつつ進められそうです。</p>
<p>今年も Gunosy Advent Calendar 2023 がスタートしました。このあと 24 日間もぜひお付き合いください。明日は morita さんが「LLM 論文の探し方」です!お楽しみに!</p>
nagayama_gn
社内 AWS GameDayを開催しました
hatenablog://entry/6801883189053557773
2023-11-17T18:00:00+09:00
2024-01-11T21:22:39+09:00 こんにちは、テクノロジー本部 プロダクト開発部 SRE の koizumi です。 今回の記事は、AWSと共同開催した社内GameDayのレポートとなります。 本記事ではGameDayの大まかな内容とGameDayに参加したことで得られたものを紹介していきます。 また、今後の参加者の方にとってネタバレにならないように、出題内容を具体的に書かないようにしていますのでご了承ください。 GameDayの内容 参加者レポート koizumi johnmanjiro june.y k.oshiro totake hyamamoto imai 社内GameDayを開催してみて 経緯 日程の確保 参加者の確…
<p>こんにちは、テクノロジー本部 プロダクト開発部 SRE の koizumi です。
今回の記事は、AWSと共同開催した社内GameDayのレポートとなります。</p>
<p>本記事ではGameDayの大まかな内容とGameDayに参加したことで得られたものを紹介していきます。
また、今後の参加者の方にとってネタバレにならないように、出題内容を具体的に書かないようにしていますのでご了承ください。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kozumikun/20231026/20231026141253.jpg" width="720" height="540" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<ul class="table-of-contents">
<li><a href="#GameDayの内容">GameDayの内容</a></li>
<li><a href="#参加者レポート">参加者レポート</a><ul>
<li><a href="#koizumi">koizumi</a></li>
<li><a href="#johnmanjiro">johnmanjiro</a></li>
<li><a href="#juney">june.y</a></li>
<li><a href="#koshiro">k.oshiro</a></li>
<li><a href="#totake">totake</a></li>
<li><a href="#hyamamoto">hyamamoto</a></li>
<li><a href="#imai">imai</a></li>
</ul>
</li>
<li><a href="#社内GameDayを開催してみて">社内GameDayを開催してみて</a><ul>
<li><a href="#経緯">経緯</a></li>
<li><a href="#日程の確保">日程の確保</a></li>
<li><a href="#参加者の確保">参加者の確保</a></li>
<li><a href="#各マネージャーへの参加許可の依頼">各マネージャーへの参加許可の依頼</a></li>
<li><a href="#チーム分け">チーム分け</a></li>
<li><a href="#当日の流れ">当日の流れ</a></li>
<li><a href="#軽食手配について">軽食手配について</a></li>
<li><a href="#賞品について">賞品について</a></li>
<li><a href="#教育効果の狙い">教育効果の狙い</a></li>
<li><a href="#最後に">最後に</a></li>
</ul>
</li>
</ul>
<h1 id="GameDayの内容">GameDayの内容</h1>
<p>以下、AWSのレポートからの引用となります。</p>
<p><a href="https://aws.amazon.com/jp/blogs/startup/event-report-gameday-2023/">チームで協力して問題を解決し、スキルを磨く。AWS GameDay 2023 年 6 月 1 日 開催レポート | AWS Startup ブログ</a></p>
<blockquote><p>“GameDay”とは、避難訓練や消防訓練など、何らかのトラブルが起きた場合を想定した訓練を意味する言葉です。「AWS GameDay」はそうした試みを AWS 環境上で行うというイベントであり、「システム上でトラブルが起きたと仮定して、AWS のサービスを有効活用しつつ、課題を解決する」ことを目指しています。</p></blockquote>
<p>今回はこのGameDayを 社内16名+社外8名 で開催しました。
各チーム2人でペアとなり、与えられた課題を解いていきます。</p>
<p>課題を解いていくごとに点数が加算されていき、最も点数を取ったチームが優勝、個人の最高得点者が個人優勝となります。</p>
<p>課題を進めていくに従ってクイズの難易度が上がっていき、発生しているセキュリティインシデントの内容や影響範囲が明らかになっていきます。</p>
<p>今回は、課題の回答を行うゲームポータルとログ分析を行うSIEM on Amazon OpenSearch、そしてチャットツールの3つのツールを提供いただきました。
インシデント調査はSIEM on Amazon OpenSearchに取り込まれた各種ログと与えられた構成図等を頼りに実施していきます。</p>
<p>SIEM on Amazon OpenSearchとは、AWSが公開しているSIEMソリューションです。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Faws-samples%2Fsiem-on-amazon-opensearch-service%2Ftree%2Fmain" title="GitHub - aws-samples/siem-on-amazon-opensearch-service: A solution for collecting, correlating and visualizing multiple types of logs to help investigate security incidents." class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/aws-samples/siem-on-amazon-opensearch-service/tree/main">github.com</a></cite></p>
<h1 id="参加者レポート">参加者レポート</h1>
<p>ここからは、この社内GameDayにご参加いただいた弊社エンジニアのレポートになります。</p>
<h4 id="koizumi">koizumi</h4>
<p>今回はチーム成績では2位、個人成績では1位を獲得することができました。
前回SREチームでAWS主催のGameDay(オンライン)に参加した際にSIEMツールの使い方や脆弱性に対するログの調査方法を学べたのが大きかったなと思います。
(ゲーム終了後の解説もあり、非常に良かったです)</p>
<p>実際のセキュリティインシデントにおいても時間が非常に重要な要素となりますが、本イベントでもチーム内で役割分担し時間内に多くのミッションをこなすことができ、非常に貴重な経験となりました。</p>
<p>また、組織におけるセキュリティへの意識向上やメンバーとの連携を深められた点も非常に良かったです。</p>
<h4 id="johnmanjiro">johnmanjiro</h4>
<p>GameDayは今回で初参加でした。チーム成績は途中まで1位だったのですが、後半で失速してしまい入賞することはできませんでした。</p>
<p>内容がわからなかったため特に準備もしていなかった(業務でOpenSearchを触っていた程度)のですが、それでも当日は楽しむことができました。</p>
<p>普段の業務ではなかなか経験することができないような課題に触れることができ、非常に勉強になりました。
また、GameDay終了後に社内のメンバーで振り返りを実施し、課題の内容や解法、自分たちの環境で起きたりしないかなど議論することができたのも良かったです。</p>
<p>機会があればまた参加したいと思います。</p>
<h4 id="juney">june.y</h4>
<p>GameDayは今回初参加でした。チーム成績は中程をずっとうろうろしており、上位に入ることができなかったため残念です。</p>
<p>セキュリティインシデント調査の知見もほとんどない状態での参加でしたが、調査の過程をなぞるように設問が組まれていたおかげで置いていかれることもなく、楽しく課題に取り組むことができました。</p>
<p>技術的な面で勉強になっただけでなく、インシデントが発生した場合の対応の大変さや被害の大きさを実感することで、普段からセキュリティリスクを潰しておくことの重要性をあらためて認識することができました。</p>
<p>次の機会があれば、ぜひ上位入賞を目指したいと思います。</p>
<h4 id="koshiro">k.oshiro</h4>
<p>GameDayは初参加でした。</p>
<p>セキュリティインシデント調査に関する知識がほとんどない状態で参加したのですが、さまざまな知識レベルの人が楽しめるように問題が用意されていたので、楽しく課題に取り組むことができました。</p>
<p>解説パートにて、設問の意図と調査過程の関係性を説明してくださり、調査によってどういった事象が発生していたのかを明らかにしていく流れはとても学びになりました。</p>
<p>結果として順位は最下位でしたが、セキュリティインシデント調査について楽しく学ぶことができる良い機会でした。</p>
<p>次の機会があればまた参加したいと思います。</p>
<h4 id="totake">totake</h4>
<p>普段はデータエンジニアとしてニュース記事推薦モデルの開発等を行なっており、セキュリティインシデント対応についてはほとんど素人の状態で初参加しました。</p>
<p>問題の難易度や解いていく順番が工夫されており、楽しみながら擬似的なインシデント対応を体験することができました。</p>
<p>2人1組のチームとして、普段は部署が異なる先輩の経験豊富なエンジニアの方と組んで、コミュニケーションを取りながら参加できたことも良い経験でした。</p>
<h4 id="hyamamoto">hyamamoto</h4>
<p>OpenSearch のダッシュボードもほとんど触ったことがなかったので、今回のGameDayはとても勉強になりました。</p>
<p>インシデントをログを関連付けながら探していく感覚は、普段のプログラミングで行うデバッグとはまた違う感覚でとても楽しかったです。</p>
<p>また、チームメンバーとオフラインでコミュニケーションを取りながら進められたのも良かったです。</p>
<h4 id="imai">imai</h4>
<p>今回GameDayというイベントの存在を初めて知り、とりあえず参加してみようと思って参加してみましたがとても良い経験になりました。</p>
<p>このイベントで使われた「SIEM on Amazon OpenSearch Service」は今まで使用したことがありませんでした。ですが、クイズが始まる前に簡単な操作説明があったため、スムーズにクイズに入っていくことができました。</p>
<p>「SIEM on Amazon OpenSearch Service」はできることが多いと感じましたが、初めての使用であったため、それを使いこなすことができませんでした。今後このツールを使う機会があれば今回の経験を活かしてリベンジしたいと思います。</p>
<h1 id="社内GameDayを開催してみて">社内GameDayを開催してみて</h1>
<p>企画、運営を行なった SRE チーム マネージャーの TksYamaguchi です。</p>
<p>裏側を書いて見たいと思います。</p>
<h2 id="経緯">経緯</h2>
<p>AWS さんと定期開催しているオフィスアワーで社内 AWS GameDay をやりたい旨はお伝えしていたのですが、最低人数の要件があり、そこまでの社内のエンジニアの参加が見込めず断念していました。</p>
<p>今回、SA さんに WeWork の大きめの会議室での勉強会をお願いしたところ、社外の方を招待しての社内 AWS GameDay をご提案いただいたので、2つ返事で開催を決断しました。</p>
<h2 id="日程の確保">日程の確保</h2>
<p>エンジニアの各チームで定期開催している会議、ミーティングを避けつつ、WeWork の会議室の空き予定と睨めっこしながら候補日をピックアップ。</p>
<p>候補日、時間を仮決めした上で、社内ミーティングをズラしてもらい日程を確保しました。</p>
<h2 id="参加者の確保">参加者の確保</h2>
<p>エンジニア全員参加チャネルで参加者を募集したのですが参加状況が芳しくなかったため、以下のような感じで参加者を募集しました。</p>
<ul>
<li>各自の Times チャネルで参加を依頼</li>
<li>各開発チームのチャネルでそれぞれ個人を指名して参加を依頼</li>
</ul>
<p>参加依頼時は、「XXさんは、AWS は十分できていると思いますが触ってないサービスとか良い勉強になりますので参加どうです?」みたいな感じで行いました。
By Name で参加募集を行ったのが、参加が拡大した要因かなと思います。</p>
<h2 id="各マネージャーへの参加許可の依頼">各マネージャーへの参加許可の依頼</h2>
<p>業務時間帯に長時間エンジニアを拘束することになるので、マネージャー陣への参加許可を取り付けました。</p>
<p>結果的に、エンジニアからの参加表明をもらってからの許可申請(事後申請)となったのが反省点です。</p>
<h2 id="チーム分け">チーム分け</h2>
<p>AWS GameDay はチーム戦なので、チームを組む必要があります。
レギュレーションで2名チーム、各社のメンバーのみでチームを組むという縛りがありましたので、どういう組み合わせでチームを組むかを考えることになりました。</p>
<p>チームの組み方は以下のようなパターンを考えました。</p>
<ul>
<li>ランダム</li>
<li>開発チームで組む</li>
<li>AWSの経験が長い人と浅い人</li>
</ul>
<p>AWS の経験が浅い人への教育効果を期待して、Gunosy 歴が長いメンバー(≒ AWS の経験が長い)+ SRE と Gunosy 歴が短いメンバー(≒ AWS の経験が短い)で、開発チームが異なるメンバー同士でチームを組みました。</p>
<h2 id="当日の流れ">当日の流れ</h2>
<p>スケジュールは 15時から18時までの3時間で、SIEM on OpenSearch の操作説明、シナリオの説明が行われた後に競技開始、競技後に表彰式、表彰式後にアルコール+軽食でネットワーキングを行う流れで実施しました。</p>
<h2 id="軽食手配について">軽食手配について</h2>
<p>Gunosy 側で食べ物とソフトドリンクを用意し、ビールは WeWork のビアサーバーから各自汲んでくるようにしました。</p>
<p>AWS 様には、ちょっといいおやつと、おいしいお酒をご用意いただきました。ありがとうございます!</p>
<p>食べ物は、食べ切りできる量を狙って人数の7割量程度を用意しました。結果として(余ったら取り扱いに困る)生ものは全てなくなり乾き物だけが少し残るという狙い通りの結果となりました。</p>
<p>飲み足りない食べ足りない量なので、それぞれで2次会に行ってさらに懇親を深める流れもあり、結果として良かったと思います。</p>
<h2 id="賞品について">賞品について</h2>
<p>AWS 様にご用意いただきました。ありがとうございます!</p>
<h2 id="教育効果の狙い">教育効果の狙い</h2>
<p>Gunosy では AWS を主に利用していますので、エンジニアそれぞれ AWS 自体の利用は日常的に行なっているのですが、普段触っていない AWS のサービスを触ることでの刺激や、AWS の知識だけでは解けない問題に触れることでの成長意欲への刺激やセキュリティへの意識向上を狙いました。</p>
<p>AWS GameDay 翌日の業務後に任意で集まって、解けなかった問題をみんなで解いてみるみたいな流れがあったので、狙いは達成できたかと思います。</p>
<h2 id="最後に">最後に</h2>
<p>社内 AWS GameDay をデリバリーしていただいた AWS 様ありがとうございました!</p>
kozumikun
Android + Kotlin Coroutines の実用的な開発Tips
hatenablog://entry/6801883189057387041
2023-11-17T14:00:00+09:00
2024-01-11T21:21:43+09:00 こんにちは、グノシー Android アプリの開発担当の Liang です。 今回はグノシー Android アプリの開発において、Kotlin Coroutines を用いた実用的な Tips をいくつか紹介したいと思います。 ExoPlayer + Coroutines Dispatchers.Main + Yield CoroutineDispatcher.limitedParallelism Flow flatMapConcat flatMapLatest scan / runningFold 感想 参考 ExoPlayer + Coroutines ExoPlayerにはLooper…
<p>こんにちは、グノシー Android アプリの開発担当の Liang です。</p>
<p>今回はグノシー Android アプリの開発において、Kotlin Coroutines を用いた実用的な Tips をいくつか紹介したいと思います。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/j/jinrong/20231110/20231110160125.jpg" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<ul class="table-of-contents">
<li><a href="#ExoPlayer--Coroutines">ExoPlayer + Coroutines</a></li>
<li><a href="#DispatchersMain--Yield">Dispatchers.Main + Yield</a></li>
<li><a href="#CoroutineDispatcherlimitedParallelism">CoroutineDispatcher.limitedParallelism</a></li>
<li><a href="#Flow">Flow</a><ul>
<li><a href="#flatMapConcat">flatMapConcat</a></li>
<li><a href="#flatMapLatest">flatMapLatest</a></li>
<li><a href="#scan--runningFold">scan / runningFold</a></li>
</ul>
</li>
<li><a href="#感想">感想</a></li>
<li><a href="#参考">参考</a></li>
</ul>
<h2 id="ExoPlayer--Coroutines">ExoPlayer + Coroutines</h2>
<ul>
<li><code>ExoPlayer</code>には<code>Looper</code>を設置出来る、指定した<code>Process</code>の低い Priority で再生などを実行すれば、Main Thread に行うUIの更新と操作を優先させる</li>
<li><code>CoroutineContext</code>には<code>Looper</code>を持った Thread も対応しており、<code>asCoroutineDispatcher</code>することで、Coroutine にも相互の運用が出来る</li>
<li>改善点:グノシーの運用では、<code>RecyclerView</code>内に自動再生の動画<code>ViewHolder</code>を実装した場面で、スクロールの操作が更にスムーズになった</li>
<li>注意点:<code>ExoPlayer</code>に関する処理は全て非同期になり、設置した<code>Looper</code>を持った Thread で実行しなければならない</li>
</ul>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">val</span> priority = Process.THREAD_PRIORITY_BACKGROUND
<span class="synType">val</span> handlerThread = HandlerThread(<span class="synConstant">"ExoPlayer"</span>, priority)
<span class="synType">val</span> playerHandler = Handler(handlerThread.looper)
<span class="synType">val</span> exoPlayer = ExoPlayer.Builder().setLooper(playerHandler.looper)
<span class="synType">val</span> playerContext = playerHandler.asCoroutineDispatcher(<span class="synConstant">"PlayerDispatcher"</span>)
<span class="synComment">// play by coroutine</span>
playerContext.launch {
exoPlayer.playWhenReady = <span class="synConstant">true</span>
}
</pre>
<ul>
<li>Reference
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fexoplayer.dev%2Fdoc%2Freference%2Fcom%2Fgoogle%2Fandroid%2Fexoplayer2%2FExoPlayer.Builder.html%23setLooper(android.os.Looper)" title="ExoPlayer.Builder (ExoPlayer library)" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/ExoPlayer.Builder.html#setLooper(android.os.Looper)">exoplayer.dev</a></cite>
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fkotlinlang.org%2Fapi%2Fkotlinx.coroutines%2Fkotlinx-coroutines-core%2Fkotlinx.coroutines%2Fas-coroutine-dispatcher.html" title="asCoroutineDispatcher" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/as-coroutine-dispatcher.html">kotlinlang.org</a></cite></li>
</ul>
<h2 id="DispatchersMain--Yield">Dispatchers.Main + Yield</h2>
<ul>
<li><code>Dispatchers.Main</code>で実行した Coroutine の Main Thread 内には、処理を順番に行う決まりになっている</li>
<li>処理毎に優先度を調整したい時、<code>yield()</code>を宣言した Coroutine には、該当 Thread を一時的に他の処理に実行を譲ることが出来る</li>
<li><code>yield()</code>後の処理は一時的に中断され、同一 Thread が解放してから、該当 Coroutine を再開する</li>
</ul>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink>coroutineScope.launch(Dispatchers.Main) {
println(<span class="synConstant">"low priority work launched"</span>)
yield()
<span class="synComment">// The work can be done lately</span>
println(<span class="synConstant">"low priority work done"</span>)
}
coroutineScope.launch(Dispatchers.Main) {
println(<span class="synConstant">"high priority work launched"</span>)
<span class="synComment">// The work must be done right now</span>
println(<span class="synConstant">"high priority work done"</span>)
}
</pre>
<ul>
<li>上記の処理結果により、<code>Low Priority</code>の Coroutine が後回しにされ、<code>High priority</code>の Coroutine を完了してから実行することになった</li>
</ul>
<pre class="code" data-lang="" data-unlink>Low priority work launched
High priority work launched
High priority work done
Low priority work done</pre>
<ul>
<li>改善点:グノシーの運用では、アプリの起動時に Main Thread を使ったログの送信を後回しにして、フレーム描画によるフリーズが緩和された</li>
<li>注意点:<code>yield()</code>の処理コストは低くないため、使う場面の選定が重要になる</li>
<li>Reference
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fkotlinlang.org%2Fapi%2Flatest%2Fjvm%2Fstdlib%2Fkotlin.sequences%2F-sequence-scope%2Fyield.html" title="yield - Kotlin Programming Language" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.sequences/-sequence-scope/yield.html">kotlinlang.org</a></cite></li>
</ul>
<h2 id="CoroutineDispatcherlimitedParallelism">CoroutineDispatcher.limitedParallelism</h2>
<ul>
<li>該当<code>CoroutineContext</code>を用いた並行で実行する Coroutine の数を制限出来る</li>
<li><code>newFixedThreadPoolContext</code>による特定 Thread Pool の利用とは違い、今空いている Thread を使うため、処理コストが低い</li>
<li>改善点:グノシーの運用では、記事の複数<code>ViewHolder</code>を表示すると同時に、記事内容のキャッシュ保存に当たって、並列処理を最多2件に制限することで、実行の負担を減らした</li>
<li>注意点:必ずしも特定の Thread を使うことではない、<code>delay</code>や<code>async</code>で一時的に suspend した間は他の Coroutine にも実行する</li>
<li>現時点は<code>ExperimentalCoroutinesApi</code></li>
</ul>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">val</span> coroutineContext = Dispatchers.IO.limitedParallelism(<span class="synConstant">2</span>)
coroutineScope.launch(coroutineContext) {
<span class="synComment">// doing simultaneously</span>
}
coroutineScope.launch(coroutineContext) {
<span class="synComment">// doing simultaneously</span>
}
coroutineScope.launch(coroutineContext) {
<span class="synComment">// waiting to do until the parallelized work done</span>
}
</pre>
<ul>
<li>Reference
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fkotlinlang.org%2Fapi%2Fkotlinx.coroutines%2Fkotlinx-coroutines-core%2Fkotlinx.coroutines%2F-coroutine-dispatcher%2Flimited-parallelism.html" title="limitedParallelism" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-dispatcher/limited-parallelism.html">kotlinlang.org</a></cite></li>
</ul>
<h2 id="Flow">Flow</h2>
<h4 id="flatMapConcat">flatMapConcat</h4>
<ul>
<li><code>flatMapConcat</code>に変換した<code>Flow</code>では、次の要素が入る時に、今の Coroutine 結果を出力されてから、次の Coroutine を開始する</li>
<li>改善点:グノシーの運用では、ユーザーの連続クリックによる重複の処理を防ぐ</li>
<li>注意点:<code>map</code>に<code>flattenConcat</code>を足す形として新しい<code>Flow</code>内に順次に処理するため、公式により<code>map</code>だけの利用も検討して良いでしょう</li>
<li>現時点は<code>ExperimentalCoroutinesApi</code></li>
</ul>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink>setOnClickListener {
clickFlow.tryEmit()
}
clickFlow.flatMapConcat {
flow {
<span class="synComment">// Clicking until the previous clicked event is done</span>
}
}
</pre>
<h4 id="flatMapLatest">flatMapLatest</h4>
<ul>
<li><code>flatMapLatest</code>に変換した<code>Flow</code>では、次の要素が入る時に、直ちに今の Coroutine をキャンセルし、次の Coroutine を開始する</li>
<li>改善点:グノシーの運用では、RecyclerView<code>OnScrollListener.onScrolled</code>の処理が頻繁に呼ばれるため、すぐ上書きされる要素を破棄することで、無駄な処理を減らした</li>
<li>注意点:Coroutine がキャンセルされていたことにより、<code>Flow</code>内に<code>CancellationException</code>が発生するため、Exception の制御も必要になる</li>
<li>現時点は<code>ExperimentalCoroutinesApi</code></li>
</ul>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">override</span> <span class="synType">fun</span> onScrolled(recyclerView: RecyclerView, dx: <span class="synType">Int</span>, dy: <span class="synType">Int</span>) {
scrollFlow.tryEmit()
}
scrollFlow.flatMapLatest {
flow {
<span class="synComment">// Scrolling be canceled if there is a next scrolled event</span>
}
}
</pre>
<ul>
<li>Reference
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fkotlinlang.org%2Fapi%2Fkotlinx.coroutines%2Fkotlinx-coroutines-core%2Fkotlinx.coroutines.flow%2Fflat-map-concat.html" title="flatMapConcat" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/flat-map-concat.html">kotlinlang.org</a></cite>
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fkotlinlang.org%2Fapi%2Fkotlinx.coroutines%2Fkotlinx-coroutines-core%2Fkotlinx.coroutines.flow%2Fflat-map-latest.html" title="flatMapLatest" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/flat-map-latest.html">kotlinlang.org</a></cite></li>
</ul>
<h4 id="scan--runningFold">scan / runningFold</h4>
<ul>
<li><code>Flow</code>内に一度出力された要素を保存し、関数を実行する度に、全ての要素を順番に返却する</li>
<li><code>runningReduce</code>とは違い、初期値の設置に別の値として出力出来る</li>
<li>改善点:グノシーの運用では、<code>RecyclerView.Adapter</code>の要素の更新に当たって、歴史と最新の要素の比較:<code>DiffResult</code>を出力するまで、一連の処理を<code>StateFlow</code>内で完結出来た</li>
<li>注意点:Thread の切り替え、Buffer との統合運用にも留意しましょう</li>
</ul>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink>itemsFlow
.scan(emptyList<Item>() to DiffResult())
{ (oldItems, diffResult), newItems <span class="synType">-></span>
newItems to DiffUtil.calculateDiff() <span class="synComment">// with the oldItems</span>
}
.flowOn(Dispatchers.IO)
.collect { (items, diffResult) <span class="synType">-></span>
<span class="synComment">// notify the adapter's items</span>
}
</pre>
<ul>
<li>Reference
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fkotlinlang.org%2Fapi%2Fkotlinx.coroutines%2Fkotlinx-coroutines-core%2Fkotlinx.coroutines.flow%2Fscan.html" title="scan" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/scan.html">kotlinlang.org</a></cite></li>
</ul>
<h2 id="感想">感想</h2>
<p>Kotlin Coroutines を使う場面が多くなる Android 開発においては、パフォーマンスには少々疑問を思う所もあるかもしれませんが、グノシーの運用としては開発効率の向上を実感しました。今後 Android との親和性も更に高まっていくことでしょう。</p>
<h2 id="参考">参考</h2>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.techyourchance.com%2Fkotlin-coroutines-android-reference-guide%2F" title="Kotlin Coroutines in Android Reference Guide" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.techyourchance.com/kotlin-coroutines-android-reference-guide/">www.techyourchance.com</a></cite></p>
jinrong
Gunosyでテスト自動化ツール「MagicPod」を活用している話
hatenablog://entry/6801883189055420029
2023-11-09T13:00:00+09:00
2024-01-11T21:22:48+09:00 こんにちは。QAチームのmiyagiです。 今回はQAチームで導入しているテスト自動化ツール「MagicPod」でどのようなテストを行っているかご紹介します。 MagicPodについて MagicPodを使ってやっていること 回帰テストの自動化 相互運用性テスト MagicPodによる自動テストを導入して良かったこと 現状の課題と今後について MagicPodについて GunosyのQAチームでは、テスト自動化ツールとして「MagicPod」を導入しています。 MagicPodはプログラミング知識がなくても簡単に自動テストのスクリプトを作成することができる便利なツールです。 ブラウザのテストの…
<p>こんにちは。QAチームのmiyagiです。<br>
今回はQAチームで導入しているテスト自動化ツール「MagicPod」でどのようなテストを行っているかご紹介します。</p>
<ul class="table-of-contents">
<li><a href="#MagicPodについて">MagicPodについて</a></li>
<li><a href="#MagicPodを使ってやっていること">MagicPodを使ってやっていること</a><ul>
<li><a href="#回帰テストの自動化">回帰テストの自動化</a></li>
<li><a href="#相互運用性テスト">相互運用性テスト</a></li>
</ul>
</li>
<li><a href="#MagicPodによる自動テストを導入して良かったこと">MagicPodによる自動テストを導入して良かったこと</a></li>
<li><a href="#現状の課題と今後について">現状の課題と今後について</a></li>
</ul>
<h1 id="MagicPodについて">MagicPodについて</h1>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gn_myg/20231102/20231102112741.png" width="1200" height="630" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span>
GunosyのQAチームでは、テスト自動化ツールとして「<a href="https://magicpod.com/">MagicPod</a>」を導入しています。<br>
MagicPodはプログラミング知識がなくても簡単に自動テストのスクリプトを作成することができる便利なツールです。<br>
ブラウザのテストの他にiOS・Androidのモバイルアプリのテストにも対応しているため、Gunosyが開発している各アプリのテストでも活用できています。</p>
<p>実際にテストケースを作る際は以下のような手順で行います。</p>
<ol>
<li>エミュレータでテスト対象のアプリを起動して画面キャプチャを取得する</li>
<li>キャプチャからテストしたい画面の要素をスクリプト上にドラッグ&ドロップする</li>
<li>タップやスクロールなど画面操作のコマンドを選択する</li>
</ol>
<p><figure class="figure-image figure-image-fotolife" title="MagicPodのテストケース作成画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gn_myg/20231102/20231102114129.png" width="1191" height="901" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>MagicPodのテストケース作成画面</figcaption></figure></p>
<p><figure class="figure-image figure-image-fotolife" title="コマンドの選択"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gn_myg/20231102/20231102114536.png" width="742" height="418" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>コマンドの選択</figcaption></figure></p>
<h1 id="MagicPodを使ってやっていること">MagicPodを使ってやっていること</h1>
<h4 id="回帰テストの自動化">回帰テストの自動化</h4>
<p>QAチームでは各プロダクトのリリース毎に次の2つのテストを実施しています。<br>
1つは新規に追加や改善された機能に対するテストです。こちらはマニュアルテストで実行します。<br>
2つ目は、既存の機能に対して行う回帰テストです。MagicPodで自動化しているのはこの回帰テストの一部となります。<br></p>
<p>MagicPodはiOS・Anddroidのエミュレータを使用してテストを実行することができますが、外部ツールと連携してのテスト実行も可能です。<br>
回帰テスト実行時にはクラウド上でiOS・Androidの実機が利用できる<a href="https://saucelabs.com/">SauceLabs</a>というサービスと連携しています。SauceLabsと連携することで、OSバージョンと機種名を指定して該当する実機でテストを行うことができます。</p>
<p>MagicPodのテスト実行結果の履歴は一覧で見ることができ、<br>
実行結果の詳細ページではテストのステップ毎にスクリーンショットが保存されているため、テスト失敗時にはどこで失敗しているのか把握しやすくなっています。<br></p>
<p><figure class="figure-image figure-image-fotolife" title="テスト実行結果の一覧"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gn_myg/20231102/20231102110316.png" width="1048" height="434" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>テスト実行結果の一覧</figcaption></figure></p>
<h4 id="相互運用性テスト">相互運用性テスト</h4>
<p>MagicPodを活用して行っているもう1つのテストが、相互運用性テスト(Interoperability Test)です。<br>
Gunosyではアプリの定期的なリリース以外に、サーバーサイドのリリースも日々行われています。<br>
サーバーサイドの変更がリリース済みのアプリの挙動に影響していないか確認するためのテストが相互運用性テストの役割となります。<br>
このテストは夜間に定期実行しており、Slackのチャンネルに通知されたテスト結果を翌朝に確認する運用をしています。<br>
テストが失敗していた場合には、MagicPod側のテストの問題なのかGunosyのサーバー側で問題が起きているのか切り分けを行い、必要に応じてテストスクリプトの修正またはバグの報告を行っています。<br></p>
<p><figure class="figure-image figure-image-fotolife" title="テスト結果のSlack通知"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/gn_myg/20231102/20231102111359.png" width="1200" height="647" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>テスト結果のSlack通知</figcaption></figure></p>
<h1 id="MagicPodによる自動テストを導入して良かったこと">MagicPodによる自動テストを導入して良かったこと</h1>
<p>MagicPodで定期的にテスト実行していることによるメリットは以下の3点です。</p>
<ul>
<li><p><strong>回帰テストの負担の軽減</strong> <br>
アプリの機能追加や変更が大きくなると回帰テストで実施するテストケースも増加します。サポートしているOSバージョンすべてでテストする場合、さらに負荷がかかります。<br>
自動テストを導入するとメンテナンス等の工数が新たに発生することはありますが、回帰テストが一部分でも自動化されていることで、体感的には負担は減らせていると感じます。<br></p></li>
<li><p><strong>主要機能が正常動作していることが分かる</strong> <br>
アプリの全ての機能が自動化できるわけではありませんが、メインの機能については自動テストで確認できているため、MagicPodの定期実行のテストが全て成功していると安心感が得られます。<br></p></li>
<li><p><strong>バグが検知できる</strong> <br>
MagicPodの自動テストでこれまでに以下のようなバグを見つけることができています。<br>
・クラッシュ<br>
・表示されるべきコンテンツが表示されない<br>
・タップしても画面遷移できない<br></p></li>
</ul>
<h1 id="現状の課題と今後について">現状の課題と今後について</h1>
<p>メリットがある一方で以下のような課題もあります。</p>
<ul>
<li><p>一部プロダクトのみでの運用になっている <br>
各プロダクトでそれぞれ自動テストの実装上の課題があり、現状1つのプロダクトでの運用のみに留まっています。<br></p></li>
<li><p>一部のテストケースの実行が不安定 <br>
以下のような要因で実行が不安定なテストケースが一部あります。<br>
・アプリの仕様で表示が不規則になる箇所があり失敗することがある<br>
・MagicPodのエミュレータとSauceLabsで実行環境の違いによる失敗<br></p></li>
</ul>
<p>今後は各プロダクトへのMagicPod利用の拡大と、安定したテスト実行ができるようテストの改善を行っていく予定です。</p>
gn_myg
「AWS Community Builders」に選出されました
hatenablog://entry/6801883189056733273
2023-11-09T12:00:00+09:00
2024-01-11T21:22:08+09:00 「AWS Community Builders」に選出されました
<p>こんにちは、SRE チーム マネージャーの TksYamaguchi です。</p>
<p>この度、私(TksYamaguchi)が「AWS Community Builders(AWS コミュニティビルダー)<a href="#f-f2a9ab7c" id="fn-f2a9ab7c" name="fn-f2a9ab7c" title="https://aws.amazon.com/jp/developer/community/community-builders/">*1</a>」に Security & Identity 部門で選出されました。</p>
<p><figure class="figure-image figure-image-fotolife" title="AWS Community Builders Directory"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/T/TksYamaguchi/20231107/20231107105349.png" width="1200" height="887" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>AWS Community Builders Directory</figcaption></figure></p>
<h1 id="AWS-Community-Builder-とは">AWS Community Builder とは</h1>
<p>AWS コミュニティビルダーズプログラムは、知識の共有や技術コミュニティとの連携に熱心な AWS 技術愛好家や新興のソートリーダーに、技術リソース、教育、ネットワーキングの機会を提供するプログラムです。
年 2 回の応募審査があり、合格者は年 2 回招待がなされます。このプログラムに参加するための特別な条件はありませんが、ブログ投稿、ビデオ、オープンソースへの貢献、プレゼンテーションなど、他の AWS に特化したビルダーを支援する技術コンテンツの証拠と精度が重視されます。AWS re:Post、Stack Overflow、Reddit 等への投稿など、オンラインでの知識共有も考慮されます。リンクは一般の方が閲覧可能であることが条件となります。(公式サイトより)</p>
<p>(参考:<a href="https://aws.amazon.com/jp/developer/community/community-builders/">https://aws.amazon.com/jp/developer/community/community-builders/</a>)</p>
<h1 id="AWS-Community-Builder-になるには">AWS Community Builder になるには</h1>
<blockquote><p>年 2 回の応募審査があり、合格者は年 2 回招待がなされます。このプログラムに参加するための特別な条件はありませんが、ブログ投稿、ビデオ、オープンソースへの貢献、プレゼンテーションなど、他の AWS に特化したビルダーを支援する技術コンテンツの証拠と精度が重視されます。AWS re:Post、Stack Overflow、Reddit 等への投稿など、オンラインでの知識共有も考慮されます。リンクは一般の方が閲覧可能であることが条件となります。</p></blockquote>
<p>「よくある質問」にもある通り、Publicなアウトプット、コミュニティ活動を<strong>自薦</strong>で申請する必要があります。
審査時にその内容のリンクを共有し、審査されます。</p>
<p>自薦なので「誰にでもチャンス」があります。</p>
<p>申請するためにはアウトプットは必要ですが、アウトプット自体がエンジニアとしての成長やブランディングにもつながりますし、アウトプットする過程で知識の整理や深掘りができるので無駄にはなりません。</p>
<h1 id="実際にどういうことをやってきたのか">実際にどういうことをやってきたのか</h1>
<p>昨年から SRE チームが取り組んでいる AWS 環境のセキュリティ対応やデプロイパイプラインのセキュリティ対応(DevSecOps)の過程で出てきた課題や、課題に対する対応内容をまとめてコミュニティで発表しています。</p>
<ul>
<li><a href="https://speakerdeck.com/yamaguchitk333/security-jaws-number-27-aws-security-hub">Security-JAWS #27 AWS Security Hubの導入から 運用を回すためにやってきたこと - Speaker Deck</a></li>
<li><a href="https://speakerdeck.com/yamaguchitk333/aws-security-hubgaiketenainodeaws-security-lakewoshi-sitemita">JAWS-UG千葉支部 #19 AWS Security HubのダッシュボードがイケてないのでAWS Security Lakeを試してみた - Speaker Deck</a></li>
<li><a href="https://speakerdeck.com/yamaguchitk333/jaws-ug-kontenazhi-bu-x-jaws-ug-qian-xie-zhi-bu-number-1-kontenasekiyuriteidui-ying-desnykwodao-ru-zhong-ni-fa-sheng-sitafa-sheng-zhong-noironnakoto">JAWS-UG コンテナ支部 × JAWS-UG 千葉支部 #1 コンテナセキュリティ対応でSnykを導入中に 発生した・発生中のいろんなこと - Speaker Deck</a></li>
</ul>
<h1 id="今後">今後</h1>
<p>今後も業務課題を解決する過程で出てきた泥臭いところや、それを解決した内容を生々しくアウトプットしていきたいと思います。</p>
<div class="footnote">
<p class="footnote"><a href="#fn-f2a9ab7c" id="f-f2a9ab7c" name="f-f2a9ab7c" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://aws.amazon.com/jp/developer/community/community-builders/">https://aws.amazon.com/jp/developer/community/community-builders/</a></span></p>
</div>
TksYamaguchi
Android Jetpack Compose 横向きフルスクリーン表示
hatenablog://entry/6801883189053049280
2023-10-27T15:00:00+09:00
2024-01-11T21:21:32+09:00 Android Jetpack Compose で横向きフルスクリーン表示の対応をしたため、その内容をお伝えします。
<p>こんにちは。Android アプリ開発担当の nagayama(<a href="https://twitter.com/nagayan_dev">@nagayan_dev</a>)です。<br>
今回は Jetpack Compose で横向きフルスクリーン表示の対応をしたため、その内容をお伝えします。</p>
<p><br>
<figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20231024/20231024162844.png" width="1200" height="540" loading="lazy" title="" class="hatena-fotolife" style="width:450px" itemprop="image"></span></figure>
<br></p>
<ul class="table-of-contents">
<li><a href="#対応方針">対応方針</a></li>
<li><a href="#実装">実装</a><ul>
<li><a href="#ダイアログを表示する">ダイアログを表示する</a></li>
<li><a href="#縦横切り替え">縦横切り替え</a></li>
<li><a href="#フルスクリーン">フルスクリーン</a></li>
<li><a href="#ステータスバー表示切り替え">ステータスバー表示切り替え</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<p><br></p>
<h1 id="対応方針">対応方針</h1>
<p>今回は WebView を、<strong>横画面</strong> かつ <strong>フルスクリーン</strong> で表示をしたいと思います。WebView の実装は <code>SamplePage</code> の Composable で実装しており、詳細は割愛します。<br>
また一時的な表示とするため、ダイアログで横画面・フルスクリーンの表示にします。そのダイアログが終了すると、画面は元に戻る仕様にします。</p>
<h1 id="実装">実装</h1>
<h2 id="ダイアログを表示する">ダイアログを表示する</h2>
<p>Dialog を用いて、ダイアログを実装します。<code>fullScreen</code> フラグを <code>remember</code> で保持し、表示切り替えができるようにします。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">var</span> fullScreen <span class="synStatement">by</span> remember { mutableStateOf(<span class="synConstant">true</span>) }
<span class="synStatement">if</span> (fullScreen) {
Dialog(
onDismissRequest = {
<span class="synComment">// ダイアログがキャンセルになった時の処理</span>
fullScreen = <span class="synConstant">false</span>
},
content = {
SamplePage(
modifier = Modifier
.fillMaxSize()
)
}
)
}
</pre>
<p><br>
<figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20231024/20231024162504.png" width="540" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span></figure>
<br></p>
<h2 id="縦横切り替え">縦横切り替え</h2>
<p>続けて画面を縦から横に切り替える処理を実装します。下記のように、画面の縦横を切り替える Composable を作成します。</p>
<p>Fragment 等で縦横切り替えを行う処理と同様に、Activity のメソッドで切り替えを行います。 Activity を参照するため、 Context から探すメソッドを作成します。</p>
<p>今回は一時的な横画面表示であるため、DisposableEffect の <code>onDispose</code> で Composable が破棄された時に元に戻すようにします。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@Composable</span>
<span class="synType">private</span> <span class="synType">fun</span> ChangeOrientationLandscape() {
<span class="synType">val</span> context = LocalContext.current
<span class="synStatement">if</span> (context.resources.configuration.orientation <span class="synStatement">==</span> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) <span class="synStatement">return</span>
DisposableEffect(context) {
<span class="synType">val</span> activity = context.findActivity() <span class="synStatement">?:</span> <span class="synStatement">return</span>@DisposableEffect onDispose {}
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
}
<span class="synType">private</span> <span class="synType">fun</span> Context.findActivity(): Activity? {
<span class="synStatement">return</span> <span class="synStatement">when</span> (<span class="synStatement">this</span>) {
<span class="synStatement">is</span> Activity <span class="synType">-></span> <span class="synStatement">this</span>
<span class="synStatement">is</span> ContextWrapper <span class="synType">-></span> baseContext.findActivity()
<span class="synStatement">else</span> <span class="synType">-></span> <span class="synConstant">null</span>
}
}
</pre>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink> Dialog(
〜省略〜
content = {
<span class="synComment">// 横画面切り替え</span>
ChangeOrientationLandscape()
SamplePage(
modifier = Modifier
.fillMaxSize()
)
}
)
</pre>
<p><br>
<figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20231024/20231024162736.png" width="1200" height="540" loading="lazy" title="" class="hatena-fotolife" style="width:450px" itemprop="image"></span></figure>
<br></p>
<h2 id="フルスクリーン">フルスクリーン</h2>
<p>フルスクリーン表示の実装を行います。</p>
<p>まず、Dialog の <code>properties</code> パラメータに DialogProperties のインスタンスを指定し、<code>decorFitsSystemWindows</code> を false に設定します。これにより Dialog の横幅を変更できるようになります。</p>
<p>次に、ダイアログで表示している Window サイズをリサイズし、画面全体で表示するよう LayoutParams を操作します。先ほどと同じ処理から Activity を参照し、Dialog の <code>window</code> と親 View にコピーします。</p>
<p>ここまで行うと、ナビゲーションバーにダイアログが被って表示されてしまいます。これを回避するため、 WindowInsets からナビゲーションバーの高さを取得し、その分の <code>padding</code> を設定します。ジェスチャーナビゲーションを設定している場合はこの高さが 0 になるため、問題なく表示されます。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink> Dialog(
〜省略〜
properties = DialogProperties(
decorFitsSystemWindows = <span class="synConstant">false</span>
),
content = {
ChangeOrientationLandscape()
<span class="synComment">// フルスクリーン</span>
<span class="synType">val</span> activityWindow = LocalView.current.context.findActivity()?.window
<span class="synType">val</span> dialogWindow = (LocalView.current.parent <span class="synStatement">as?</span> DialogWindowProvider)?.window
<span class="synType">val</span> parentView = LocalView.current.parent <span class="synStatement">as</span> View
SideEffect {
<span class="synStatement">if</span> (activityWindow <span class="synStatement">!=</span> <span class="synConstant">null</span> <span class="synStatement">&&</span> dialogWindow <span class="synStatement">!=</span> <span class="synConstant">null</span>) {
<span class="synType">val</span> attributes = WindowManager.LayoutParams().also {
it.copyFrom(activityWindow.attributes)
it.type = dialogWindow.attributes.type
}
dialogWindow.attributes = attributes
parentView.layoutParams = FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height)
}
}
<span class="synComment">// 全画面にした時 Navigation Bar に被って表示されてしまうため、そのサイズ分 padding を設定します</span>
<span class="synType">val</span> navigationPadding = WindowInsets.navigationBars.asPaddingValues().calculateEndPadding(LayoutDirection.Ltr)
SamplePage(
modifier = Modifier
.fillMaxSize()
.padding(end = navigationPadding)
)
}
)
</pre>
<p><br>
<figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20231024/20231024162821.png" width="1200" height="540" loading="lazy" title="" class="hatena-fotolife" style="width:450px" itemprop="image"></span></figure>
<br></p>
<h2 id="ステータスバー表示切り替え">ステータスバー表示切り替え</h2>
<p>最後の仕上げです。フルスクリーンで表示された WebView の上にステータスバーのアイコン等が表示されます。フルスクリーン時にステータスバーを非表示にするため、再度 Activity を取得して Window クラスに処理を行います。</p>
<p>API レベル 30 以上の場合は、WindowInsetsController を取得し、<code>show</code> / <code>hide</code> でステータスバーを指定して表示を切り替えます。 30 未満の場合は、Window クラスの <code>setFlags</code> / <code>clearFlags</code> でステータスバーの Flag を追加・削除をして切り替えを行います。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@Composable</span>
<span class="synType">fun</span> ChangeStatusBarVisibility(isShowStatusBar: <span class="synType">Boolean</span>) {
<span class="synType">val</span> activityWindow = LocalView.current.context.findActivity()?.window <span class="synStatement">?:</span> <span class="synStatement">return</span>
<span class="synStatement">if</span> (isShowStatusBar) {
<span class="synStatement">if</span> (Build.VERSION.SDK_INT<span class="synStatement"> >=</span> Build.VERSION_CODES.R) {
activityWindow.decorView.windowInsetsController?.show(android.view.WindowInsets.Type.statusBars())
} <span class="synStatement">else</span> {
activityWindow.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
}
} <span class="synStatement">else</span> {
<span class="synStatement">if</span> (Build.VERSION.SDK_INT<span class="synStatement"> >=</span> Build.VERSION_CODES.R) {
activityWindow.decorView.windowInsetsController?.hide(android.view.WindowInsets.Type.statusBars())
} <span class="synStatement">else</span> {
activityWindow.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
}
}
}
</pre>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synIdentifier">@Composable</span>
<span class="synType">fun</span> FullScreenSample() {
<span class="synType">var</span> fullScreen <span class="synStatement">by</span> remember { mutableStateOf(<span class="synConstant">false</span>) }
<span class="synStatement">if</span> (fullScreen) {
<span class="synComment">// フルスクリーン表示</span>
Dialog(〜省略〜)
} <span class="synStatement">else</span> {
<span class="synComment">// 通常表示</span>
〜省略〜
}
ChangeStatusBarVisibility(<span class="synStatement">!</span>fullScreen)
}
</pre>
<p><br>
<figure class="figure-image figure-image-fotolife"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20231024/20231024162844.png" width="1200" height="540" loading="lazy" title="" class="hatena-fotolife" style="width:450px" itemprop="image"></span></figure>
<br></p>
<p>以上で横画面フルスクリーン表示ができました。</p>
<h1 id="まとめ">まとめ</h1>
<p>Jetpack Compose の横向きフルスクリーン表示についてまとめました。やることが多く複雑な処理が多いですが、処理を分割して整理しながら実装できるのは Compose の大きなメリットであると思います。これからも Jetpack Compose ライフを楽しみましょう。</p>
nagayama_gn
GunosyからiOSDC Japan 2023に参加しました
hatenablog://entry/820878482965692395
2023-10-04T19:11:55+09:00
2024-01-11T21:22:44+09:00 こんにちは。auサービスTodayの開発をしている吉岡(@rikusouda)です。先日開催されたiOSDC Japan 2023にオフライン参加(リアル参加)してきました。
<p>こんにちは。auサービスTodayの開発をしている吉岡(<a href="https://twitter.com/rikusouda">@rikusouda</a>)です。先日開催されたiOSDC Japan 2023にオフライン参加(リアル参加)してきました。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rikusouda/20230927/20230927184953.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h1 id="iOSDC-Japanとは">iOSDC Japanとは</h1>
<p>公式サイトからの抜粋ですが、下記のような技術カンファレンスです。</p>
<blockquote><p>iOSDC Japan 2023はiOS関連技術をコアのテーマとしたソフトウェア技術者のためのカンファレンスです。今年もリアル会場とオンライン配信のハイブリッド開催です。</p>
<p>日本中、世界中から公募した知的好奇心を刺激するトークの他にも、パンフレットに掲載された技術記事、参加者であれば誰でも作れる即興のトーク・アンカンファレンスなど、初心者から上級者まで楽しめるコンテンツがみなさんを待っています。 9月1日(金) 〜 9月3日(日)はお祭りです!お楽しみに!!</p></blockquote>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fiosdc.jp%2F2023%2F" title="iOSDC Japan 2023" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://iosdc.jp/2023/">iosdc.jp</a></cite></p>
<p>しばらくは感染症対策でアルコールの提供がなかったのですが、2019年以来久しぶりにアルコール提供のある開催とのことで楽しみでした。</p>
<h1 id="特に印象に残ったトーク">特に印象に残ったトーク</h1>
<h2 id="モバイルアプリの行動ログの運用つらさ解消法">モバイルアプリの行動ログの運用つらさ解消法</h2>
<p><a href="https://fortee.jp/iosdc-japan-2023/proposal/0ea36011-b566-478a-b067-d9785c957fe5">モバイルアプリの行動ログの運用つらさ解消法 by あおい | トーク | iOSDC Japan 2023 #iosdc - fortee.jp</a></p>
<p><iframe id="talk_frame_1072060" class="speakerdeck-iframe" src="//speakerdeck.com/player/35b3fc784a9e45699494dc0a88a53537" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/aomathwift/mobile-log-troubleshooting">speakerdeck.com</a></cite></p>
<p>このトークでは、ユーザーの行動をログに残すに当たっての苦労話の共有と、それを解決するための取り組みが紹介されていました。</p>
<p>Gunosy社で開発しているアプリでもログを使った分析をもとにアプリの改善を進めているためとても共感できる内容でした。このトークで紹介されていた「ログやっていき委員会」方式は一度試してみたいと思いました!</p>
<h2 id="Appleにおけるプライバシーの全容を把握する">Appleにおけるプライバシーの全容を把握する</h2>
<p><a href="https://fortee.jp/iosdc-japan-2023/proposal/c9247098-377a-4288-be71-5e210d330042">Appleにおけるプライバシーの全容を把握する by akatsuki174 | トーク | iOSDC Japan 2023 #iosdc - fortee.jp</a></p>
<p><iframe id="talk_frame_1071348" class="speakerdeck-iframe" src="//speakerdeck.com/player/4a3211b107ec460eb003732bc50ad935" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/akatsuki174/appleniokeruhuraihasinoquan-rong-woba-wo-suru">speakerdeck.com</a></cite></p>
<p>このトークでは「プライバシー」に関連する考え方や、アプリの利用者がより安心して使うためのiOSの機能について紹介されていました。
端末の「連絡先」や「写真」にアプリからアクセスする場合にユーザーの許可を得てアクセスするのが従来の方法でしたが、アプリ側ではアクセス権を得ずにiOS標準コントロール経由で必要最低限の情報のみを取得する方法も紹介されていました。アクセス権をユーザーに求める場面はありそうですが、できる限り最小限の権限でユーザーにも安心して使ってもらえるという観点はしっかり持っておきたいと再認識しました。</p>
<p>またこのトークで興味深かったのは「事前収録によるリモート登壇」であったことです。
子育て中などの事情で当日に登壇できるかわからない人もいるので、そのような人でも登壇できるような配慮はすごすぎました。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/r/rikusouda/20230929/20230929142845.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h1 id="感想">感想</h1>
<p>去年もリアル会場での開催でしたが、今年はお酒が提供される形式での開催は2019年以来だったのでこの形式での開催が復活したことを嬉しく思います。
他の参加者やスポンサーブースの方々とも心置きなくコミュニケーションを取ることができ、モチベーションアップや知見を広げることができました。</p>
<p>初めての参加で知り合いがいなくて不安な方でもスポンサーブースに行けばブースの方とお話できたり、ポスターセッションを楽しむという時間の使い方があるというのはありがたかったです。</p>
<p>みなさんが登壇している姿に触発されて、来年はスピーカーに挑戦したいと思いました。</p>
<p>iOSDCで関わっていただいたすべての皆様に、ここであらためてお礼を申し上げます。</p>
rikusouda
DroidKaigi 2023 に参加してきました
hatenablog://entry/820878482968730461
2023-09-20T15:00:00+09:00
2024-01-11T21:22:33+09:00 DroidKaigi2023 に参加してきました。その内容を簡単にご紹介したいと思います。
<p>こんにちは。Android アプリ開発担当の nagayama(<a href="https://twitter.com/nagayan_dev">@nagayan_dev</a>)です。<br>
2023 年 9 月 14 日 〜 16 日にかけて行われた、 DroidKaigi 2023 に参加してきました。 今回はその内容を簡単にご紹介したいと思います。</p>
<ul class="table-of-contents">
<li><a href="#DroidKaigi-2023">DroidKaigi 2023</a></li>
<li><a href="#聞いたセッションのご紹介">聞いたセッションのご紹介</a><ul>
<li><a href="#-Push-通知許諾率向上へのアプローチ">① Push 通知許諾率向上へのアプローチ</a></li>
<li><a href="#モニタリングでパフォーマンス改善入門">②モニタリングでパフォーマンス改善入門</a></li>
<li><a href="#-Kotlin-ハイパフォーマンスプログラミング">③ Kotlin ハイパフォーマンスプログラミング</a></li>
<li><a href="#-Material3-やめました">④ Material3 やめました</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h1 id="DroidKaigi-2023">DroidKaigi 2023</h1>
<p>DroidKaigi とは、 Android の技術の共有とコミュニケーションを目的としたカンファレンスになります。
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2F2023.droidkaigi.jp%2F" title="DroidKaigi 2023" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://2023.droidkaigi.jp/">2023.droidkaigi.jp</a></cite></p>
<p>今回で 9 回目の開催となります。
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20230919/20230919084837.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>会場は渋谷ガーデンタワーで、昨年よりも大きな会場での開催となりました。
セッション会場は 5 つで、14 日・ 15 日合わせて 46 のセッションが行われました。
昨年までは 25 分又は 40 分のセッションとなっていましたが、今年は全て 40 分で行われていました。
これにより一つ一つのセッションがより深く分かりやすくなった印象でした。</p>
<p>参加者はオフライン参加者だけで 1000 人以上の規模となり、昨年の 600 人を大きく超えており、その勢いが感じられました。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20230919/20230919084954.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>またセッション以外にもイベントが用意されており、私は今年もスタンプラリーに参加をしてきました。
スタンプラリーは協賛企業の展示ブースを回ってスタンプをもらうことで、その個数に応じて景品がもらえるというものになります。昨年いただいたタンブラーがボロボロになってしまったため、新しいタンブラーをゲットしました。昨年のものよりもより大きいサイズになっており、たくさんコーヒーを入れられるようになって大満足です。</p>
<center>
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nagayama_gn/20230919/20230919085039.jpg" width="900" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span>
</center>
<h1 id="聞いたセッションのご紹介">聞いたセッションのご紹介</h1>
<p>私が聞いたセッションの中の一部をご紹介します。</p>
<h2 id="-Push-通知許諾率向上へのアプローチ">① Push 通知許諾率向上へのアプローチ</h2>
<p>Push 通知許諾率を改善させた施策の内容について話されていました。 Push 通知を受け取ってもらうのは、プロダクト運営上とても大切なものです。受け取ってもらうようにするため、「Push通知が OFF にされない工夫」と「Push 通知を ON にしてもらう工夫」の 2 つのアプローチをしていました。あえて速報以外の通知チャンネルの重要度の設定を下げて通知音が出ない通知を作ることで、煩わしさを減らし、かつ速報通知が目立つことで開いてくれやすくなるのは目から鱗でした。弊社でも Push 通知許諾率は重要な課題であるため、本セッションを参考に私たちも改善に取り組みたいと思いました。</p>
<p>発表された資料はこちらになります。<br>
<iframe id="talk_frame_1077270" class="speakerdeck-iframe" src="//speakerdeck.com/player/ca70a0f8c30d4462ba20bc080dc4887d" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/miyabigouji/pushtong-zhi-xu-nuo-lu-xiang-shang-henoapuroti">speakerdeck.com</a></cite></p>
<h2 id="モニタリングでパフォーマンス改善入門">②モニタリングでパフォーマンス改善入門</h2>
<p>公式のアプリパフォーマンスガイドを元に、Android アプリパフォーマンス改善の手法を紹介されていました。PassiveTest ・ ManualTest ・ AutomatedTest の 3 つの観点での手法を紹介していました。基本的な用語や画面の見方、手順方法まで詳細に説明されており、初学者も安心してできる内容であったと思います。中でも AutomatedTest で紹介された Macrobenchmark は、自動で対象の処理速度を測定してくれるもので、普段から導入しておくと異常状態になった時に検知できる優れものであったため、こちらはすぐにでも担当しているアプリ開発のフローに取り組みたいと思いました。</p>
<p>発表された資料はこちらになります。<br>
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdocs.google.com%2Fpresentation%2Fd%2F1m4b8EYs4jrXEjNIFoelInZMSxZHCFArBUkcZz1zzRQY%2Fedit%23slide%3Did.p" title="モニタリングでパフォーマンス改善入門(shimizuAsami)" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://docs.google.com/presentation/d/1m4b8EYs4jrXEjNIFoelInZMSxZHCFArBUkcZz1zzRQY/edit#slide=id.p">docs.google.com</a></cite></p>
<h2 id="-Kotlin-ハイパフォーマンスプログラミング">③ Kotlin ハイパフォーマンスプログラミング</h2>
<p>「JVM 言語は遅い」という漠然とした印象が広がっていますが、最適化した Kotlin はネイティブに負けず劣らずのパフォーマンスを出すことができます。「処理速度の遅いメモリ上で多くの処理をしていないか。」「便利な Kolint の記法に甘んじてそれによって処理が多くなっていないか。」「可変長引数を安易に使っていないか。」等、 Android 開発者ならハッと気づく指摘が多々ありました。ただしこの「最適化」も限度はあり、メンテナンス性を下げるのは得策ではないといいます。通常は便利な記法は積極的に使い、大量・高頻度に使用される箇所に限定して適用することが良いと言っていました。私も最適化の箇所を考慮しつつ、パフォーマンスの積極的な測定と改善を行っていきたいと思いました。</p>
<p>発表された資料はこちらになります。<br>
<iframe id="talk_frame_1077740" class="speakerdeck-iframe" src="//speakerdeck.com/player/e3254567e0214a9da959514643ec549e" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/ohmae/kotlinhaipahuomansupuroguramingu">speakerdeck.com</a></cite></p>
<h2 id="-Material3-やめました">④ Material3 やめました</h2>
<p>Material3 は 2021 年に登場した新しいデザインガイドラインです。アプリの移行がどんどん進んではいるものの、 Material3 のデザインシステムと実際の開発とで問題が発生したため、Material3 の一部デザインシステムを辞めた話になります。
Material3 は 1 つの色を選択することで、アプリで使用する 29 の色が決定される仕組みになっています。しかし指定した色と決定された色とで若干の色の違いが発生したり、またその色をカスタマイズすると他の 28 色に影響が出たりと、「色」に関して様々な問題が発生しました。発表者はその Material3 の色のシステムを辞め、独自のシステムを導入しました。これによって望んだ色の指定ができるようになりましたが、component の色指定に工夫が必要であるため、ちょっと手間は発生するとのことです。</p>
<p>発表された資料はこちらになります。<br>
<iframe id="talk_frame_1078123" class="speakerdeck-iframe" src="//speakerdeck.com/player/e53d3f04061e44d9b5dd995b9211b19c" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/yanzm/good-bye-m3-design-system">speakerdeck.com</a></cite></p>
<h1 id="まとめ">まとめ</h1>
<p>今年も DroidKaigi2023 に参加しました。普段の業務ではなかなか目を向けられないパフォーマンス改善や他社の施策の取り組みは、とても刺激を受けるものでした。すぐにでも担当プロダクトに取り込みたい、新しく挑戦したいと、Android 開発に対する強い熱意が体の中から湧き上がってきています。セッションの発表者の方々、そして毎年このような素晴らしい催しを開催してくださる DroidKaigi スタッフの皆様に厚くお礼を申し上げます。
来年もまた行きたい!</p>
nagayama_gn
広告システムにおける機械学習モデルの推論差分検知について
hatenablog://entry/820878482953750971
2023-08-24T09:00:00+09:00
2024-01-11T21:21:23+09:00 弊社では広告配信の最適化のために CTR・CVR((CTR はクリック率(Click Through Rate)、CVR はコンバージョン率(ConVersion Rate)を表します)) を推定する機械学習モデルを開発していて、定期的な学習とモデルの更新を行っています。
このようなシステムにおいて、学習済みモデルが推論システムで問題なく動作することを保証するために、デプロイされるモデルの挙動を検証する仕組みが必要です。
特に、学習時と推論時で同一の広告リクエストに対して同じ推論値を出力するかを確認する仕組みを「差分検知」と呼んでいます。
この記事では、弊社の広告システムにおける機械学習モデルの差分検知について紹介します。
<p>こんにちは。データサイエンス部の石川です。</p>
<p>弊社では広告配信の最適化のために CTR・CVR<a href="#f-8e2a875e" id="fn-8e2a875e" name="fn-8e2a875e" title="CTR はクリック率(Click Through Rate)、CVR はコンバージョン率(ConVersion Rate)を表します">*1</a> を推定する機械学習モデルを開発していて、定期的な学習とモデルの更新を行っています。</p>
<p>このようなシステムにおいて、学習済みモデルが推論システムで問題なく動作することを保証するために、デプロイされるモデルの挙動を検証する仕組みが必要です。
特に、学習時と推論時で同一の広告リクエストに対して同じ推論値を出力するかを確認する仕組みを「差分検知」と呼んでいます。</p>
<p>この記事では、弊社の広告システムにおける機械学習モデルの差分検知について紹介します。</p>
<ul class="table-of-contents">
<li><a href="#背景">背景</a></li>
<li><a href="#課題">課題</a></li>
<li><a href="#解決策">解決策</a><ul>
<li><a href="#PyO3-の実装">PyO3 の実装</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
</ul>
<h2 id="背景">背景</h2>
<p>弊社の CTR・CVR を推定する機械学習システムでは、ワークフローエンジンが定期的にモデルの学習を実行し、その後学習済みモデルを S3 にアップロードします。
広告スコアリングサーバは S3 上のモデル変更を検知し、学習済みモデルをロードして CTR・CVR 推定を行います。</p>
<p><figure class="figure-image figure-image-fotolife" title="スコアリングサーバの構成"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/takaishikawa42/20230728/20230728094801.png" width="1200" height="1003" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>スコアリングサーバの構成</figcaption></figure></p>
<p>学習ロジックは Python で記述されていますが、スコアリングサーバは Rust (以前は Go )で記述されているため、学習時の処理の一部(例えば、データ前処理や特徴量生成)はスコアリングサーバでも同様に行われる必要があります<a href="#f-980eeb44" id="fn-980eeb44" name="fn-980eeb44" title="特徴量は基本的にデータストアに保存されてるものを利用しますが、一部の特徴量の変換は推論時に行っています">*2</a>。</p>
<p>そのため、同一の広告リクエストに対して学習時と推論時に同一の推定値を返すか(適切な前処理や特徴量生成が行われ、モデルの入出力値が同じか)を確認する必要があります。そうでなければ、意図しない推定値に基づいて最適化され、収益性や広告効果などのビジネス KPI に悪影響を及ぼす可能性があります。</p>
<h2 id="課題">課題</h2>
<p>最近 Go で書かれていた推論 API を Rust でリプレイスをしました。
Go サーバの際は推論用の実行バイナリを作成し、モデル学習時に Python の subprocess ライブラリを利用して実行バイナリを呼び出すことで、推論 API の推論値を取得していました。</p>
<p>しかし Rust 移行の際にアーキテクチャを変更し、複数のコンテナによって実行されるサービスにしたため、今までのように単一の実行バイナリを呼び出すことができなくなりました。
そのため、別の方法でスコアリングサーバの CTR・CVR 推定の差分検知を行える仕組みが必要になりました。</p>
<p>移行時の詳細の話は以前のブログにまとめられているので、ご覧ください。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Freplace_ads_scoring_server" title="広告のスコアリングサーバをフルリプレイスしました - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/replace_ads_scoring_server">tech.gunosy.io</a></cite></p>
<h2 id="解決策">解決策</h2>
<p>要件を整理した上で、以下の 3 つの解決策を考えました。</p>
<ol>
<li>差分検知用サーバの構築</li>
<li>差分検知用 API エンドポイントの追加</li>
<li>推論処理の Python バインディングの作成</li>
</ol>
<p>上記の 3 つの解決策について詳細と、メリット・デメリットを整理します。</p>
<p><u><strong>1. 差分検知用サーバの構築</strong></u></p>
<p><strong>詳細</strong></p>
<ul>
<li>差分検知用のサーバを構築し、学習ワークフローはそのサーバに対して gRPC リクエストを送る</li>
</ul>
<p><strong>メリット</strong></p>
<ul>
<li>アプリケーションコードへの変更が少ない</li>
<li>ローカル環境でもテストが容易である</li>
</ul>
<p><strong>デメリット</strong></p>
<ul>
<li>別のインスタンスが起動するため運用のコストがかかる</li>
</ul>
<p><u><strong>2. 差分検知用 API エンドポイントの追加</strong></u></p>
<p><strong>詳細</strong></p>
<ul>
<li>差分検知用の API エンドポイントを追加し、学習ワークフローはステージング環境の API エンドポイントをコールする</li>
</ul>
<p><strong>メリット</strong></p>
<ul>
<li>新しい API の追加のみで、実装が容易である</li>
</ul>
<p><strong>デメリット</strong></p>
<ul>
<li>動作確認のために staging 環境へのデプロイが都度発生し、開発効率が悪い</li>
</ul>
<p><u><strong>3. 推論処理の Python バインディングの作成</strong></u></p>
<p><strong>詳細</strong></p>
<ul>
<li>推論処理の Python バインディングを作成し、学習ワークフローは作成したパッケージをインストールして用いる</li>
</ul>
<p><strong>メリット</strong></p>
<ul>
<li>サーバの起動や staging 環境への修正が不要である</li>
<li>ポータビリティが高く、CI での動作確認も容易である</li>
</ul>
<p><strong>デメリット</strong></p>
<ul>
<li>モデル管理システムを含めた統合テストにはならない</li>
<li>Python バインディングの実装が必要になる</li>
</ul>
<p>以上のメリット・デメリットを検討した結果、3 番の案を採用することになりました。</p>
<p>3 番の案を選んだ理由は、サーバの起動が不要で個人の開発環境をすぐ利用できることを重視したためです。実際、新しい特徴量の追加時には推論 API 側の修正が必要になりますが、その際のトライアンドエラーが容易になりました。</p>
<p>また、デメリットとして挙がった統合テストにならない点については単体テストを拡充することで許容することにしました。
もう一つのデメリットだった Python のバインディングの実装については一度実装してしまえばその後大きな修正が入ることは稀だと判断し、頑張って実装することにしました。</p>
<p><figure class="figure-image figure-image-fotolife" title="差分検知パッケージの生成と利用のイメージ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/takaishikawa42/20230802/20230802223804.png" width="1200" height="1047" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>差分検知パッケージの生成と利用のイメージ</figcaption></figure></p>
<h3 id="PyO3-の実装">PyO3 の実装</h3>
<p>Rust で実装した CTR・CVR 推定モジュールを Python から実行できるように <a href="https://github.com/PyO3/pyo3">PyO3</a> を利用しました。</p>
<p>Rust で実装された機能を Python から実行できるようにする PyO3 のイメージについて、 PyO3 のドキュメントにあるサンプルコードを用いて説明します。
まず以下のような Rust コードがあるとします<a href="#f-0e34ea82" id="fn-0e34ea82" name="fn-0e34ea82" title="事前に maturin init コマンドで PyO3 に必要なテンプレートを作成します">*3</a>。 <code>sum_as_string</code> という 2 つの引数を足し合わせて文字列にして返す関数があり、これを <code>string_sum</code> というモジュール<a href="#f-eefb77d8" id="fn-eefb77d8" name="fn-eefb77d8" title="Cargo.toml の lib.name と同じ必要があります">*4</a>に追加することで、Python 側から import して参照することができます。</p>
<pre class="code lang-rust" data-lang="rust" data-unlink><span class="synStatement">use</span> <span class="synPreProc">pyo3</span><span class="synSpecial">::</span><span class="synPreProc">prelude</span><span class="synSpecial">::</span><span class="synType">*</span>;
<span class="synPreProc">#[pyfunction]</span>
<span class="synStatement">fn</span> <span class="synIdentifier">sum_as_string</span>(a: <span class="synType">usize</span>, b: <span class="synType">usize</span>) <span class="synStatement">-></span> PyResult<span class="synStatement"><</span><span class="synType">String</span><span class="synStatement">></span> {
<span class="synConstant">Ok</span>((a <span class="synStatement">+</span> b).<span class="synIdentifier">to_string</span>())
}
<span class="synPreProc">#[pymodule]</span>
<span class="synStatement">fn</span> <span class="synIdentifier">string_sum</span>(_py: Python<span class="synStatement"><</span><span class="synSpecial">'_</span><span class="synStatement">></span>, m: <span class="synType">&</span>PyModule) <span class="synStatement">-></span> PyResult<span class="synStatement"><</span>()<span class="synStatement">></span> {
m.<span class="synIdentifier">add_function</span>(<span class="synPreProc">wrap_pyfunction!</span>(sum_as_string, m)<span class="synSpecial">?</span>)<span class="synSpecial">?</span>;
<span class="synConstant">Ok</span>(())
}
</pre>
<p><code>maturin develop</code> でパッケージをビルド・インストールすることで準備完了です。Python では以下のようにして Rust で実装した機能を利用することが出来ます。</p>
<pre class="code lang-sh" data-lang="sh" data-unlink>$ python
<span class="synStatement">>>></span> import string_sum
<span class="synStatement">>>></span> string_sum.sum_as_string<span class="synPreProc">(</span><span class="synConstant">5</span><span class="synSpecial">, </span><span class="synConstant">20</span><span class="synPreProc">)</span>
<span class="synStatement">'</span><span class="synConstant">25</span><span class="synStatement">'</span>
</pre>
<p>同じ要領で Rust で実装された CTR・CVR 推定モジュールを Python から実行できるように整備し、ここで作られたパッケージを含んだ wheel を作成し、学習システムに差分検知の仕組みを取り込むことが出来ました。</p>
<p>PyO3 の導入については以前にブログが書かれているので参照してください。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.gunosy.io%2Fentry%2Fintroduce-pyo3" title="PyO3 による Rust の Python バインディング - Gunosy Tech Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://tech.gunosy.io/entry/introduce-pyo3">tech.gunosy.io</a></cite></p>
<p>PyO3 導入によって Python に公開されている関数や構造体を変更する場合、追従するための実装コストが発生する苦労がありました。また、API 側で必要なリクエストのデータクラスを学習側に公開して利用するようにしているので、リクエストのデータクラスのスキーマが変更した場合に学習側でも対応する必要があり、バージョン管理の煩雑さが発生しました。</p>
<p>しかし、ローカルでの開発で実際のスコアリングサーバに依存することなく、API での推論結果を確認可能になりました。</p>
<h2 id="まとめ">まとめ</h2>
<p>この記事では、学習時と推論時で同一の広告リクエストに対して同じ推論値を出力するかを確認する機構について紹介しました。</p>
<p>今回の差分検知の方法によって、API の推論結果の開発時の確認が容易になり、トライアンドエラーが簡単になりました。</p>
<p>現在は推論結果の差分の有無だけを確認する機構ですが、デバッグをより容易にするために、どの特徴量の変換が間違っているかを把握できるように対応する予定です。</p>
<div class="footnote">
<p class="footnote"><a href="#fn-8e2a875e" id="f-8e2a875e" name="f-8e2a875e" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">CTR はクリック率(Click Through Rate)、CVR はコンバージョン率(ConVersion Rate)を表します</span></p>
<p class="footnote"><a href="#fn-980eeb44" id="f-980eeb44" name="f-980eeb44" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">特徴量は基本的にデータストアに保存されてるものを利用しますが、一部の特徴量の変換は推論時に行っています</span></p>
<p class="footnote"><a href="#fn-0e34ea82" id="f-0e34ea82" name="f-0e34ea82" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">事前に maturin init コマンドで PyO3 に必要なテンプレートを作成します</span></p>
<p class="footnote"><a href="#fn-eefb77d8" id="f-eefb77d8" name="f-eefb77d8" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">Cargo.toml の lib.name と同じ必要があります</span></p>
</div>
takaishikawa42