-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathfeed.xml
More file actions
2976 lines (2316 loc) · 304 KB
/
Copy pathfeed.xml
File metadata and controls
2976 lines (2316 loc) · 304 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Pepabo Tech Portal</title>
<id>https://tech.pepabo.com/</id>
<link href="https://tech.pepabo.com/"/>
<link href="https://tech.pepabo.com/feed.xml" rel="self"/>
<updated>2026-06-12T00:00:00+09:00</updated>
<author>
<name>GMO Pepabo, Inc.</name>
</author>
<entry>
<title>【レポート】毬藻企画とGMOペパボでWebアクセシビリティのイベントを開催しました!</title>
<link rel="alternate" href="https://tech.pepabo.com/2026/06/12/suzuri-a11y-walkthrough/"/>
<id>https://tech.pepabo.com/2026/06/12/suzuri-a11y-walkthrough/</id>
<published>2026-06-12T00:00:00+09:00</published>
<updated>2026-06-23T00:06:10+00:00</updated>
<author>
<name>tarutaru</name>
</author>
<content type="html"><ol id="markdown-toc">
<li><a href="#この記事について" id="markdown-toc-この記事について">この記事について</a></li>
<li><a href="#イベントの背景概要" id="markdown-toc-イベントの背景概要">イベントの背景・概要</a></li>
<li><a href="#当日の様子" id="markdown-toc-当日の様子">当日の様子</a> <ol>
<li><a href="#アクセシビリティウォークスルー" id="markdown-toc-アクセシビリティウォークスルー">アクセシビリティウォークスルー</a></li>
<li><a href="#改善提案ディスカッション" id="markdown-toc-改善提案ディスカッション">改善提案ディスカッション</a></li>
</ol>
</li>
<li><a href="#イベントを振り返って" id="markdown-toc-イベントを振り返って">イベントを振り返って</a></li>
<li><a href="#さいごに" id="markdown-toc-さいごに">さいごに</a></li>
</ol>
<h2 id="この記事について">この記事について</h2>
<p>GMOペパボの<a href="https://x.com/tarutaruio">tarutaru</a>です。</p>
<p>2026年3月24日、アクセシビリティエキスパート集団である<a href="https://marimokikakugk.com/">毬藻企画合同会社</a>さんとの共催で、「<a href="https://pepabo.connpass.com/event/385692/">【GMOペパボ・毬藻企画】スクリーンリーダーによる実践的アクセシビリティウォークスルー 〜SUZURI byGMOペパボ編〜</a>」というイベントを開催しました。</p>
<p>GMOペパボにとっては初めてのアクセシビリティイベント。この記事では、イベント当日の内容・様子など振り返っていきます。</p>
<h2 id="イベントの背景概要">イベントの背景・概要</h2>
<p>GMOペパボでは、「人類のアウトプットを増やす」というミッションのもと、誰もが表現活動の機会を持てる世界を目指してアクセシビリティ推進にも取り組んでいます。ただ、組織としての浸透も各サービスの改善も、まだ「道半ば」というのが正直なところで、SUZURIもその例外ではありませんでした。</p>
<p>そんな折に、SUZURIでグッズ販売をしてくださっている毬藻企画の<a href="https://x.com/securecat">森田さん</a>からお声がけいただきました。お話を重ねるなかで、「SUZURIのアクセシビリティ改善に取り組むなら、まずは現状の課題を知るところから。せっかくならオープンにやってみるといいんじゃないか」と、今回のイベントをご提案いただきました。</p>
<p>お話を受けて、弊社の事例を公開することが、アクセシビリティに取り組まれている方の学びや刺激になれば。そして、その外に向けた発信が巡り巡って社内のアクセシビリティ推進の後押しにもなれば。そんな期待もあって、今回共催という形でイベントを開催することにしました。</p>
<p>本イベントは、弊社のサービス「<a href="https://suzuri.jp/">SUZURI byGMOペパボ</a>」に対して、<strong>アクセシビリティウォークスルー</strong>を実施いただくイベントとして開催されました。あまり聞き馴染みのないワードだと思いますが、アクセシビリティウォークスルーは、認知的ウォークスルー(初見のユーザーがUIを一手順ずつ操作していくプロセスを追体験し、つまずきを抽出する手法)とエキスパートレビューを組み合わせたもので、毬藻企画さんによる造語になります。</p>
<p>当日は、全盲のエンジニアである<a href="https://yncat.net/">catさん(野澤 幸男さん)</a>をゲストとしてお招きし、SUZURIをスクリーンリーダー(NVDA)で操作しながらウォークスルーを実施いただきました。ウォークスルー中は、毬藻企画の<a href="https://x.com/magi1125">伊原さん</a>がファシリテートと解説を担当。ウォークスルー後には、catさん・伊原さんに加え、毬藻企画の<a href="https://x.com/mt_dew2">坂巻さん</a>、SUZURIデザイナーの<a href="https://tech.pepabo.com/authors/%E3%83%84%E3%83%90%E3%82%B5/">白石さん</a>・<a href="https://x.com/na0v0_matsu">並松さん</a>も加わり問題点の振り返りと改善方法のディスカッションを行いました。</p>
<p>今回は現地参加のみの開催でしたが、デザイナー・エンジニアなど計40名の方にご参加いただきました。当日ご参加いただいた皆さんありがとうございました。</p>
<h2 id="当日の様子">当日の様子</h2>
<h3 id="アクセシビリティウォークスルー">アクセシビリティウォークスルー</h3>
<p>ウォークスルー本番の前に、比較材料としてまず晴眼者である私が、マウス操作でSUZURIの商品を購入するデモを行いました。SUZURI公式ショップで「忍者スリスリくんのパーカー」を探して、カートに入れ、注文を完了する。私は特に引っかかるところもなく注文を終え、晴眼者にとっては数分で完了するタスクでした。</p>
<p>これに続く形でcatさんにはアクセシビリティウォークスルーを始めていただきました。catさんには、SUZURI内の毬藻企画さんのショップで「アクセシスタディーズのアクリルスタンド」を購入する、というタスクを遂行してもらいました。商品を探して、カートに入れ、注文を完了させる。流れ自体は基本的に同じですが、それをスクリーンリーダーで行うことで先ほどの晴眼者のウォークスルーとどのくらいギャップがあるのか、を参加者の方には体感いただきました。</p>
<p><img src="/blog/2026/06/12/suzuri-a11y-walkthrough/walkthrough.jpg" alt="ウォークスルー中の会場の様子。参加者が着席して見守るなか、登壇者2名がスクリーン脇に立っている。メインスクリーンにはSUZURIのページが映し出され、サブモニターにはUDトークの字幕が表示されている" /></p>
<p>結果だけ言ってしまうと、ウォークスルー中には本当にさまざまな引っかかりが発生しました。一つの商品を購入するだけでこれだけのつまづきがあるのかと、普段から慣れ親しんだ自分たちのサービスが、まるで全然知らないサービスに見えるくらい衝撃を受けました。</p>
<p>ここですべてのつまづきを取り上げることはできないので1つだけピックアップさせていただきますが、イベントの中で会場が一番ざわついたのが、商品のサイズ選択の場面でした。</p>
<p>SUZURIでは、サイズを選ぶUIとしてHTMLの<code>select</code>要素が使用されていました。これ自体はなんの違和感もないことですが、catさんがスクリーンリーダーでこの<code>select</code>要素を操作してサイズを選択し、カートに入れるボタンを押してみたところ、カートに入れることができませんでした。カートに入れられなかった理由は”サイズが未選択だから”。先ほど確かにサイズを選択したのをこの目で確認したはずなのに、実際にはサイズは選ばれていない状態で、皆の頭に「?」が浮かぶ状況でした。</p>
<p>実際には何が起きていたかというと、実はこの<code>select</code>要素、<code>select</code>要素としての機能は無効化されており、マウスのクリックでカスタムのドロワーUIを開くためのトリガーとして使用されていました。サイズ選択は、そのドロワー側で選択しないとサイズが確定しない仕様になっていたのです。ところが、スクリーンリーダー(キーボード)からは通常の<code>select</code>として操作も選択もできてしまったため「選んだのに、選べていない」という状態になっていたのです。</p>
<p>catさんはこの問題について、こう表現しました。</p>
<blockquote>
<p>お風呂用洗剤はお風呂を洗うために作られたのに他の目的で使うと何が起こるかわからない。つまり、用法用量を守って使ってほしい。</p>
</blockquote>
<p>ネイティブの<code>select</code>要素には、ブラウザが保証する期待された動作があります。それを別の目的で使えば、期待どおりに動かないケースが出てくる。シンプルだけど重い指摘でした。(※ なお、このサイズ選択の問題については現在改善対応を進めており、近日中に修正される予定です。)</p>
<p>このサイズ選択のほかにも、フォーカスが移動せずモーダルウインドウの存在に気づけなかったり、支払い方法のラジオボタンのグルーピングが把握しづらかったり、広告バナーを誤クリックしてしまったりと、多くのつまずきが発生しましたが、それでもcatさんは最終的に注文を完了させ、無事に(?)ウォークスルーを終えることができました。</p>
<h3 id="改善提案ディスカッション">改善提案ディスカッション</h3>
<p>ウォークスルーを終えた後は、catさん・伊原さん・坂巻さん・白石さん・並松さんの5名で、問題点の振り返りと改善方法を議論しました。モーダルウインドウへのフォーカス移動やアイコンのラベル付けなど、改善策はいくつも挙がりましたが、中でも印象に残ったのは、「グッズの探索しやすさ」をめぐるやり取りでした。</p>
<p><img src="/blog/2026/06/12/suzuri-a11y-walkthrough/discussion.jpg" alt="改善提案ディスカッション中の会場の様子。参加者が着席して見守るなか、複数の登壇者がスクリーン脇から発言している。メインスクリーンにはSUZURIのページが映し出され、サブモニターにはUDトークの字幕が表示されている" /></p>
<p>きっかけは、並松さんからの問いかけでした。ウォークスルー中、catさんが「アクリルスタンド」と書かれたリンクを押すと、毬藻企画ショップ内ではなく、SUZURI全体のアクリルスタンドの商品一覧(毬藻とは関係のないアクスタが並ぶ画面)に飛んでしまう、という場面がありました。並松さんはここを取り上げて、「グッズを探しやすくするには、どんな工夫があり得るんでしょうか?」と切り出しました。</p>
<p>これに対しcatさんは、商品一覧から選ぶこと自体は不便ではなかったとしつつ、「そもそも『デザインを選ばないとTシャツやアクスタが出てこない』構造に気づくまでに時間がかかった」と振り返りました。これを受けて、伊原さんがSUZURIの構造的な特徴をこう整理してくれました。</p>
<blockquote>
<p>SUZURIには「Tシャツやアクリルスタンドといったハードウェアとしての商品」と「その上に乗るデザイン」という二つの概念があり、これらが同じ画面に同居しているので、自分がいま触れているのが商品の話なのかデザインの話なのかがユーザーには見えづらい。catさんがリンクを押して関係のない商品一覧に飛んでしまったのも、まさにこの概念の混在が原因。</p>
</blockquote>
<p>また、地続きの話として白石さんから興味深い補足が入りました。SUZURIショップのトップに並ぶナビゲーション(「デザイン」「グッズ」など)は、実はショップオーナー側でカスタマイズできる仕様になっており、SUZURI公式ショップでは「グッズ」というタブが並んでいるのに対し、今回の毬藻企画ショップのナビゲーションには「グッズ」が存在していなかったそうです。catさんがショップの構造を掴むのに時間がかかった一因も、おそらくここにあったのではないか。同じSUZURIでも、ショップによって構造の理解しやすさが揺らいでしまう、そんな現状そのものが課題として見えてきた、そんな議論でした。</p>
<p>最初はUIの小さな引っかかりに聞こえた話が、気づけば「SUZURIというサービスの情報構造をどう設計するか」という、もう一回り大きな問いに変わっていく。アクセシビリティは「スクリーンリーダーでどう読み上げさせるか」だけではなく、その手前にある情報設計のレイヤーまで含めて考えるべきテーマなのだと、改めて気づかされた時間でした。一つの問いからここまで議論が広がっていくその奥行きが、ディスカッションの中でもとくに面白く、印象に残ったセッションでした。</p>
<h2 id="イベントを振り返って">イベントを振り返って</h2>
<p>イベントを終えた今正直に書くと、「まだアクセシビリティの取り組みが浅いSUZURIを題材にして本当に大丈夫か」という不安は企画当初からずっとありました。社内でも協議を重ね結果開催に至りましたが、その不安は当日まで消えませんでした。</p>
<p>それでも、いざイベントが始まってみると、catさんや伊原さんがユーモアを交えながら明るくウォークスルーを進めてくださり、ディスカッションでも「どうすれば良くなるか」が前向きに議論されていきました。参加者の皆さんもその一つひとつに真剣に耳を傾けてくださっていて、会場には終始「目の前の事象に一緒に向き合う」良い空気が流れていました。SNS上でもハッシュタグ <a href="https://x.com/hashtag/pepabo_marimo">#pepabo_marimo</a> で学びをポジティブにシェアしてくださり、読みながらほっとしたのを覚えています。</p>
<p>そうした空気のなかで、私自身もたくさんの学びを持ち帰った一日でした。中でも気づかされたのは、catさんが操作のほとんどの時間を「ページの構造を把握する/要素を把握する/情報を把握する」ことに費やしていた、という事実です。それはおそらく、晴眼者が視覚情報で無意識のうちにやっていることでもあります。この現状把握でつまずきが多いと、目的のアクションにはなかなかたどり着けないし、たどり着くまでに想像以上のエネルギーがかかってしまう。catさんはあらゆる手段を駆使して現状を把握されていましたが、それはエンジニアであるcatさんだからこその引き出しの多さでもあったはずです。誰もがこの把握をスムーズに行える状態を、当たり前のものとして目指していきたいな、と改めて感じる時間でした。</p>
<p>そしてもうひとつ、企画者として嬉しかったことがあります。当日参加していた弊社デザイナーが、翌日にはもう自分の担当サービスでアクセシビリティ改善のPRを立てていたのです。「社外に向けたイベントが、社内にもいい刺激になれば」とひそかに期待していたことが、想像よりずっと早く、目に見える形で動き出していました。</p>
<p>ディスカッションの終盤、catさんがこう言ってくださったのが、強く印象に残っています。</p>
<blockquote>
<p>みんなでいいものができていけばいいんじゃないかな。</p>
</blockquote>
<p>このひとことに、今回のイベントの空気が全部詰まっていたように思います。アクセシビリティは、一社あるいは一人だけで完結するテーマではありません。社内外で関わる人たちと一緒に課題に向き合いながら、少しずつ良いものに変えていく。今回のイベントが、その小さな一歩目になっていたら嬉しいです。</p>
<h2 id="さいごに">さいごに</h2>
<p>以上、イベントのふりかえりでした。</p>
<p>改めて、catさん、毬藻企画の皆さん、当日ご参加くださった皆さん、そして運営に関わってくださった皆さん、本当にありがとうございました!</p>
<p>今回のイベントで見つかったSUZURIの課題は、これからひとつずつ改善していければと思っています。数も多いので時間がかかるものもあるかもしれませんが、ゆっくり見守っていただけたら嬉しいです。</p>
<p>ちなみに、初開催ということもあり、運営面での反省点・学びもありました。たとえば、UDトークの字幕にスクリーンリーダーの読み上げ音声まで一緒に拾われてしまい、品質に影響が出てしまったり。こうした学びは、次回以降のイベントでしっかり活かしていきます。</p>
<p>今回のイベントは、毬藻企画さんとcatさんのお力を大いにお借りしての開催となりました。弊社としても、アクセシビリティを通して良いものづくりにつながる取り組みを、これからも続けていきたいです。今回はスクリーンリーダーが切り口でしたが、ほかにもさまざまな角度からアクセシビリティに触れられる場を作っていき、皆さんと一緒に学ばせていただけたらと思っています。</p>
<p>アクセシビリティに関わるみなさんも、これから興味を持つみなさんも、一緒に学び、いいものづくりをしましょう!</p>
</content>
</entry>
<entry>
<title>GA 直後の Amazon S3 Files を SUZURI の本番 EKS に投入し コンテナイメージを 1/20 に圧縮した</title>
<link rel="alternate" href="https://tech.pepabo.com/2026/05/13/s3-files-suzuri-lens/"/>
<id>https://tech.pepabo.com/2026/05/13/s3-files-suzuri-lens/</id>
<published>2026-05-13T00:00:00+09:00</published>
<updated>2026-06-23T00:06:10+00:00</updated>
<author>
<name>shibatch</name>
</author>
<content type="html"><ol id="markdown-toc">
<li><a href="#はじめに" id="markdown-toc-はじめに">はじめに</a></li>
<li><a href="#課題assets-が-コンテナイメージに焼き込まれる構造的問題" id="markdown-toc-課題assets-が-コンテナイメージに焼き込まれる構造的問題">課題:assets が コンテナイメージに焼き込まれる構造的問題</a> <ol>
<li><a href="#lenslens2-が扱う-assets-とは" id="markdown-toc-lenslens2-が扱う-assets-とは">lens/lens2 が扱う assets とは</a></li>
<li><a href="#移行前の状況" id="markdown-toc-移行前の状況">移行前の状況</a></li>
</ol>
</li>
<li><a href="#s3-files-という選択肢" id="markdown-toc-s3-files-という選択肢">S3 Files という選択肢</a> <ol>
<li><a href="#当初の候補-efs--datasync" id="markdown-toc-当初の候補-efs--datasync">当初の候補: EFS + DataSync</a></li>
<li><a href="#2026-年-4-月-7-日-ga-amazon-s3-files" id="markdown-toc-2026-年-4-月-7-日-ga-amazon-s3-files">2026 年 4 月 7 日 GA: Amazon S3 Files</a></li>
</ol>
</li>
<li><a href="#アーキテクチャ設計" id="markdown-toc-アーキテクチャ設計">アーキテクチャ設計</a> <ol>
<li><a href="#移行後の全体像" id="markdown-toc-移行後の全体像">移行後の全体像</a></li>
<li><a href="#設計のポイント" id="markdown-toc-設計のポイント">設計のポイント</a></li>
</ol>
</li>
<li><a href="#実装-lens2rustで先行検証" id="markdown-toc-実装-lens2rustで先行検証">実装: lens2(Rust)で先行検証</a> <ol>
<li><a href="#eks-への-s3-files-マウント" id="markdown-toc-eks-への-s3-files-マウント">EKS への S3 Files マウント</a></li>
<li><a href="#phase-2a-動作確認並列マウント" id="markdown-toc-phase-2a-動作確認並列マウント">Phase 2a: 動作確認(並列マウント)</a></li>
<li><a href="#phase-2b-assets_dir-切り替え" id="markdown-toc-phase-2b-assets_dir-切り替え">Phase 2b: ASSETS_DIR 切り替え</a></li>
<li><a href="#phase-2c-dockerfile-から-assets-を除外" id="markdown-toc-phase-2c-dockerfile-から-assets-を除外">Phase 2c: Dockerfile から assets を除外</a></li>
</ol>
</li>
<li><a href="#ハマりどころ-efs-csi-driver-を動かしてわかったこと" id="markdown-toc-ハマりどころ-efs-csi-driver-を動かしてわかったこと">ハマりどころ: EFS CSI Driver を動かしてわかったこと</a> <ol>
<li><a href="#1-inline-ephemeral-csi-volume-は非対応" id="markdown-toc-1-inline-ephemeral-csi-volume-は非対応">1. inline (Ephemeral) CSI volume は非対応</a></li>
<li><a href="#2-accessmodes-readonlymany-が非対応" id="markdown-toc-2-accessmodes-readonlymany-が非対応">2. <code>accessModes: ReadOnlyMany</code> が非対応</a></li>
<li><a href="#3-volumehandle-に-s3files-プレフィックスが必須" id="markdown-toc-3-volumehandle-に-s3files-プレフィックスが必須">3. <code>volumeHandle</code> に <code>s3files:</code> プレフィックスが必須</a></li>
<li><a href="#4-mountoptions-iam-の明示指定が必要" id="markdown-toc-4-mountoptions-iam-の明示指定が必要">4. <code>mountOptions: [iam]</code> の明示指定が必要</a></li>
<li><a href="#5-controller-sa-と-node-sa-に別々の-iam-ロールが必要" id="markdown-toc-5-controller-sa-と-node-sa-に別々の-iam-ロールが必要">5. controller SA と node SA に別々の IAM ロールが必要</a></li>
<li><a href="#補足-s3-files-障害時の挙動と監視" id="markdown-toc-補足-s3-files-障害時の挙動と監視">補足: S3 Files 障害時の挙動と監視</a></li>
</ol>
</li>
<li><a href="#計測結果" id="markdown-toc-計測結果">計測結果</a> <ol>
<li><a href="#lens2rust本番実測" id="markdown-toc-lens2rust本番実測">lens2(Rust)本番実測</a></li>
<li><a href="#lensjavascript本番実測" id="markdown-toc-lensjavascript本番実測">lens(JavaScript)本番実測</a></li>
<li><a href="#s3-files-の読み取りレイテンシlens-本番実測" id="markdown-toc-s3-files-の読み取りレイテンシlens-本番実測">S3 Files の読み取りレイテンシ(lens 本番実測)</a></li>
</ol>
</li>
<li><a href="#lensjavascriptへのロールアウト" id="markdown-toc-lensjavascriptへのロールアウト">lens(JavaScript)へのロールアウト</a> <ol>
<li><a href="#lens-ならではの差異-上書きマウント方式" id="markdown-toc-lens-ならではの差異-上書きマウント方式">lens ならではの差異: 上書きマウント方式</a></li>
<li><a href="#lens2-のノウハウがそのまま活きた" id="markdown-toc-lens2-のノウハウがそのまま活きた">lens2 のノウハウがそのまま活きた</a></li>
</ol>
</li>
<li><a href="#まとめ" id="markdown-toc-まとめ">まとめ</a> <ol>
<li><a href="#成果サマリ" id="markdown-toc-成果サマリ">成果サマリ</a></li>
<li><a href="#s3-files-を選んでよかった点" id="markdown-toc-s3-files-を選んでよかった点">S3 Files を選んでよかった点</a></li>
<li><a href="#さいごに" id="markdown-toc-さいごに">さいごに</a></li>
</ol>
</li>
</ol>
<h2 id="はじめに">はじめに</h2>
<p>こんにちは、技術部技術基盤グループで SUZURI / minne / カラーミーショップなどのインフラをサービス横断で担当している shibatch です。</p>
<p>SUZURI は、オリジナルグッズを手軽に作れる・購入できるサービスです。ユーザーがアップロードした画像とあらかじめ用意した商品テンプレート(assets)を合成して、Tシャツやマグカップの完成イメージを生成する「画像合成サービス」が中核を担っています。この処理を担うのが <strong>lens</strong> と <strong>lens2</strong> という 2 つのサービスです。</p>
<ul>
<li><strong>lens</strong>: JavaScript + ImageMagick 製の画像合成サービス(主力)</li>
<li><strong>lens2</strong>: Rust + ImageMagick 製の画像合成サービス(新世代、lens から段階的に移行中)</li>
</ul>
<p>2 サービスを合わせると <strong>1 日最大約 420 万リクエスト(月間約 1.1 億リクエスト)</strong> を本番処理しています。</p>
<p>今回はそのlens/lens2に、2026年4月7日にGAとなったばかりの <strong>Amazon S3 Files</strong> を本番EKSに投入した話をします。トラフィックの少ないlens2でスモールスタートし、ゴールデンウィークをまたいでレイテンシの実績を積んでからlensへ展開する、という段階的なリスク設計で進めました。その過程と計測結果をお伝えします。</p>
<hr />
<h2 id="課題assets-が-コンテナイメージに焼き込まれる構造的問題">課題:assets が コンテナイメージに焼き込まれる構造的問題</h2>
<h3 id="lenslens2-が扱う-assets-とは">lens/lens2 が扱う assets とは</h3>
<p>lens/lens2 が扱う assets は、SUZURI で販売されるすべての商品テンプレート——Tシャツ・マグカップ・トートバッグなどの型紙画像、フォントファイル、ウォーターマーク画像——の集合です。新商品が追加されるたびに増え続けます。</p>
<h3 id="移行前の状況">移行前の状況</h3>
<p>移行前は assets がそのまま コンテナイメージに焼き込まれていました。</p>
<p><img src="/blog/2026/05/13/s3-files-suzuri-lens/before-architecture.png" alt="図1: 移行前アーキテクチャ。assets がイメージに焼き込まれているため、ビルド・起動・ECR コストがすべて assets のサイズに比例する" /></p>
<table>
<thead>
<tr>
<th>サービス</th>
<th>assets サイズ</th>
<th>コンテナイメージ(ECR 圧縮後)</th>
</tr>
</thead>
<tbody>
<tr>
<td>lens2</td>
<td>1.7 GB</td>
<td><strong>1.75 GB</strong></td>
</tr>
<tr>
<td>lens</td>
<td>8.6 GB</td>
<td><strong>10.3 GB</strong></td>
</tr>
</tbody>
</table>
<p>これはビルドパイプライン全体に悪影響を与えていました。lens のビルド時間を実測すると次のような内訳です。</p>
<table>
<thead>
<tr>
<th>ステップ</th>
<th>所要時間</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>assets の S3 sync(毎ビルドダウンロード)</td>
<td>2m 08s</td>
<td>8.6 GB を毎回取得</td>
</tr>
<tr>
<td>コンテナビルド &amp; push</td>
<td>11m 43s</td>
<td>assets を含む巨大レイヤーの書き出し</td>
</tr>
<tr>
<td><strong>合計 (wall-clock)</strong></td>
<td><strong>14m 31s</strong></td>
<td>CI の初期化・checkout 等含む</td>
</tr>
</tbody>
</table>
<p>さらに Karpenter による新規ノード追加時(キャッシュなし)の Pod 起動には、イメージサイズが直撃します。</p>
<table>
<thead>
<tr>
<th>サービス</th>
<th>Image pull(cold)</th>
<th>Pod Scheduled→Ready(cold)</th>
</tr>
</thead>
<tbody>
<tr>
<td>lens2</td>
<td>—</td>
<td><strong>86〜102 秒</strong></td>
</tr>
<tr>
<td>lens</td>
<td>5 分 03 秒</td>
<td><strong>約 5〜5.5 分</strong></td>
</tr>
</tbody>
</table>
<p>トラフィックスパイク時にスケールアウトが発動しても、5 分間は新規 Pod を利用できません。SUZURI ではセール開始などをきっかけにトラフィックが急増するため、このスケールアウト遅延は売上機会の取りこぼしに直結します。</p>
<p>さらに問題があります。lens から lens2 への移行が進むと lens2 の assets も最終的に 8.6 GB 相当まで増える見込みでした。放置すれば状況はさらに悪化します。</p>
<hr />
<h2 id="s3-files-という選択肢">S3 Files という選択肢</h2>
<h3 id="当初の候補-efs--datasync">当初の候補: EFS + DataSync</h3>
<p>assets を コンテナイメージから切り離し、ネットワークファイルシステム経由でマウントする構成を検討していました。当初の有力候補は <strong>Amazon EFS + AWS DataSync</strong> の組み合わせです。S3 を source of truth として、DataSync で定期同期した EFS ボリュームを EKS Pod に NFS マウントする構成です。</p>
<p>ただし、この方式には課題がありました。S3 への push が EFS に反映されるまで同期ラグが生じるため、assets の鮮度管理が複雑になります。また DataSync パイプライン自体の構築・運用コストも無視できません。</p>
<h3 id="2026-年-4-月-7-日-ga-amazon-s3-files">2026 年 4 月 7 日 GA: Amazon S3 Files</h3>
<p>アーキテクチャを検討していたタイミングで、AWS が <a href="https://aws.amazon.com/blogs/aws/launching-s3-files-making-s3-buckets-accessible-as-file-systems/">Amazon S3 Files</a> を GA リリースしました。</p>
<p><strong>S3 バケットをバックエンドとして、NFS 4.1/4.2 でマウントできるファイルシステムを提供する</strong>サービスです。</p>
<p><img src="/blog/2026/05/13/s3-files-suzuri-lens/s3-files-concept.png" alt="図2: S3 Files の仕組み。S3 バケットが直接 NFS エンドポイントになる。DataSync 不要" /></p>
<table>
<thead>
<tr>
<th>特性</th>
<th>値</th>
</tr>
</thead>
<tbody>
<tr>
<td>プロトコル</td>
<td>NFS 4.1 / 4.2(POSIX セマンティクス準拠 ※ハードリンク等は非対応)</td>
</tr>
<tr>
<td>小ファイルの読み取り(キャッシュ済み)</td>
<td>数 ms 以下</td>
</tr>
<tr>
<td>キャッシュの読み取りスループット</td>
<td>4.7 GB/s</td>
</tr>
<tr>
<td>大ファイル(≥ 128 KB)</td>
<td>S3 から直接ストリーム(サービス全体の集約スループット テラバイト/秒)</td>
</tr>
<tr>
<td>対応コンピュート</td>
<td>EC2 / EKS / ECS / Lambda</td>
</tr>
</tbody>
</table>
<p><strong>決め手は DataSync が不要になる点でした。</strong> S3 が source of truth を維持したまま NFS マウントできるため、同期ラグも余分なパイプラインも生じません。また Lambda でも S3 Files マウントが使えるため、将来的な Lambda 化の道筋も確保されます。</p>
<p>これならやろうとしていたことがスマートに実現できる、作り込もうとしていたDataSync部分をまるごと捨てて、S3 Filesへの切り替えを決めました。</p>
<hr />
<h2 id="アーキテクチャ設計">アーキテクチャ設計</h2>
<h3 id="移行後の全体像">移行後の全体像</h3>
<p><img src="/blog/2026/05/13/s3-files-suzuri-lens/after-architecture.png" alt="図3: 移行後アーキテクチャ。assets は S3 Files 経由で Pod に NFS マウントされ、コンテナイメージには含まれない" /></p>
<h3 id="設計のポイント">設計のポイント</h3>
<p><strong>assets をコードリポジトリから完全分離する</strong></p>
<p>lens は以前から assets 専用のリポジトリが分離されており、デザイナーはそこに PR を出す運用でした。lens2 では assets がコードリポジトリに直接コミットされていたため、専用の assets リポジトリを新設してデザイナーの PR 先を切り替えました。</p>
<p>どちらも assets リポジトリへのマージ時に CI が <code>aws s3 sync</code> を実行し S3 に同期します。S3 Files 経由で Pod への反映はほぼリアルタイムです。</p>
<p><strong>ゼロダウンタイムの段階的移行</strong></p>
<p><img src="/blog/2026/05/13/s3-files-suzuri-lens/migration-phases.png" alt="図4: 段階的移行フェーズ。Phase 2a で既存イメージと S3 Files を並存させ、安全を確認してから切り替える" /></p>
<div class="highlight"><pre class="highlight plaintext"><code>Phase 1: S3 バケット作成 + S3 Files 有効化
Phase 2a: 既存 Pod に S3 Files を追加マウントして動作確認
Phase 2b: assets の参照先を S3 Files に切り替え
Phase 2c: Dockerfile から assets を除外 → イメージ軽量化
</code></pre></div>
<p>Phase 2a がこの設計の安全弁です。既存イメージを稼働させたまま別パスに S3 Files をマウントし、ファイルの内容とレイテンシを確認します。マウントに失敗しても Kubernetes の rolling update により旧 ReplicaSet がそのまま動き続けるため、本番トラフィックへの影響はゼロです。なお Phase 2c 以降はイメージから assets が除去されるため、fat image への即時ロールバックは現実的ではありません。S3 Files の可用性に依存した設計となる点は後述します。</p>
<hr />
<h2 id="実装-lens2rustで先行検証">実装: lens2(Rust)で先行検証</h2>
<h3 id="eks-への-s3-files-マウント">EKS への S3 Files マウント</h3>
<p>EFS CSI Driver v3.0 以降が S3 Files に対応しています。通常の EFS ボリュームと同じ CSI ドライバーを使いますが、設定にいくつか注意点があります(後述の「ハマりどころ」を参照)。</p>
<div class="highlight"><pre class="highlight yaml"><code><span class="c1"># PersistentVolume</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">PersistentVolume</span>
<span class="na">metadata</span><span class="pi">:</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">lens2-assets</span>
<span class="na">spec</span><span class="pi">:</span>
<span class="na">capacity</span><span class="pi">:</span>
<span class="na">storage</span><span class="pi">:</span> <span class="s">100Gi</span>
<span class="na">accessModes</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s">ReadWriteMany</span>
<span class="na">mountOptions</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s">iam</span>
<span class="na">csi</span><span class="pi">:</span>
<span class="na">driver</span><span class="pi">:</span> <span class="s">efs.csi.aws.com</span>
<span class="na">volumeHandle</span><span class="pi">:</span> <span class="s2">"</span><span class="s">s3files:fs-xxxxxxxxxxxxxxxxx"</span> <span class="c1"># s3files: プレフィックス必須</span>
<span class="na">readOnly</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div>
<div class="highlight"><pre class="highlight yaml"><code><span class="c1"># Deployment への追加</span>
<span class="na">volumeMounts</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">s3-assets</span>
<span class="na">mountPath</span><span class="pi">:</span> <span class="s">/mnt/s3-assets</span>
<span class="na">readOnly</span><span class="pi">:</span> <span class="no">true</span>
<span class="na">volumes</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">s3-assets</span>
<span class="na">persistentVolumeClaim</span><span class="pi">:</span>
<span class="na">claimName</span><span class="pi">:</span> <span class="s">lens2-assets</span>
<span class="na">readOnly</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div>
<h3 id="phase-2a-動作確認並列マウント">Phase 2a: 動作確認(並列マウント)</h3>
<p>Phase 2a では <code>mountPath: /mnt/s3-assets</code> に S3 Files を追加マウントするだけです。アプリの参照先(<code>ASSETS_DIR</code>)はまだ変更しません。Pod に入って <code>find /mnt/s3-assets | wc -l</code> でファイル数を確認し、イメージ内の assets とファイル数が一致することを確かめます。</p>
<h3 id="phase-2b-assets_dir-切り替え">Phase 2b: ASSETS_DIR 切り替え</h3>
<p>動作確認が取れたら、ConfigMap で <code>ASSETS_DIR=/mnt/s3-assets</code> に切り替えます。staging → production の順に適用し、各ステップで Datadog APM のレイテンシに異常がないことを確認します。</p>
<h3 id="phase-2c-dockerfile-から-assets-を除外">Phase 2c: Dockerfile から assets を除外</h3>
<p><code>ASSETS_DIR</code> の参照先が S3 Files に切り替わったことを確認してから、Dockerfile を変更します。</p>
<div class="highlight"><pre class="highlight docker"><code><span class="c"># Before(削除する行)</span>
<span class="k">COPY</span><span class="s"> ./assets ./assets</span>
<span class="k">COPY</span><span class="s"> --from=builder /lens2/assets /lens2/assets</span>
<span class="c"># After: 上記 2 行を削除するだけ</span>
</code></pre></div>
<p>これでイメージから assets が消え、ビルドと pull が劇的に速くなります。</p>
<hr />
<h2 id="ハマりどころ-efs-csi-driver-を動かしてわかったこと">ハマりどころ: EFS CSI Driver を動かしてわかったこと</h2>
<p>ドキュメントに記載のない制約が複数ありました。同じ問題に直面した方の参考になれば幸いです。</p>
<h3 id="1-inline-ephemeral-csi-volume-は非対応">1. inline (Ephemeral) CSI volume は非対応</h3>
<div class="highlight"><pre class="highlight plaintext"><code>Error: volume mode 'Ephemeral' not supported by driver efs.csi.aws.com
</code></pre></div>
<p>S3 Files のマウントには <strong>PersistentVolume / PersistentVolumeClaim</strong> が必要です。Pod spec へのインライン定義(Ephemeral volume)はサポートされていません。</p>
<h3 id="2-accessmodes-readonlymany-が非対応">2. <code>accessModes: ReadOnlyMany</code> が非対応</h3>
<p><code>ReadOnlyMany</code> と書くとマウントに失敗します。<code>ReadWriteMany</code> で宣言し、<strong>PV の <code>csi</code> セクション・PVC 参照(<code>volumes</code> セクション)・<code>volumeMount</code> の3か所すべてに <code>readOnly: true</code> を設定する</strong>のが実際に動作した方法です。上のコード例ではすべての箇所に記載されています。</p>
<h3 id="3-volumehandle-に-s3files-プレフィックスが必須">3. <code>volumeHandle</code> に <code>s3files:</code> プレフィックスが必須</h3>
<p>通常の EFS では <code>fs-xxxxxxxxxxxxxxxxx</code> のみ指定しますが、S3 Files では必ず <code>s3files:fs-xxxxxxxxxxxxxxxxx</code> の形式にしなければなりません。プレフィックスがないと通常の EFS ボリュームとして解釈されてマウントに失敗します。</p>
<h3 id="4-mountoptions-iam-の明示指定が必要">4. <code>mountOptions: [iam]</code> の明示指定が必要</h3>
<p><strong><code>mountOptions</code> への <code>iam</code> の明示指定は省略できません。</strong> ドキュメント(<a href="https://github.com/kubernetes-sigs/aws-efs-csi-driver/blob/master/docs/parameters.md"><code>docs/parameters.md</code></a>)では optional な mountOption の一例として列挙されているだけで、デフォルト適用はされません。省略すると mount 時に <code>access denied by server</code> が返ります。</p>
<h3 id="5-controller-sa-と-node-sa-に別々の-iam-ロールが必要">5. controller SA と node SA に別々の IAM ロールが必要</h3>
<p>EFS CSI Driver の controller pod と node plugin pod はそれぞれ異なる ServiceAccount を使います。<strong>両方に S3 Files へのアクセス権を持つ IAM ロールを割り当てる</strong>必要があります。Pod Identity を使う場合も同様です。片方だけ設定してもマウントが通りません。</p>
<h3 id="補足-s3-files-障害時の挙動と監視">補足: S3 Files 障害時の挙動と監視</h3>
<p>Phase 2c 以降、コンテナイメージから assets が除去されるため、S3 Files が停止すると lens/lens2 の画像合成機能が利用不可になります。fat image への即時ロールバックは assets をリポジトリから削除済みのため現実的ではなく、S3 Files の可用性に依存した設計です。</p>
<p>この点への対策を整理します。</p>
<ul>
<li><strong>可用性</strong>: S3 Files は EFS の基盤上に構築されており、データは S3 に保存されます。S3 の SLA は月次稼働率 99.9% で、複数 AZ に冗長化されています。S3 Files が停止しても S3 バケット内のデータは保全されるため、S3 Files の復旧後に自動復旧します</li>
<li><strong>監視</strong>: Datadog APM・外形監視・レイテンシ監視により異常を検知できる体制を整えています</li>
<li><strong>NFS オプション</strong>: soft マウントやタイムアウト設定で NFS I/O ブロック時の挙動を調整することもできますが、lens2 での本番運用で pod 起動やレイテンシに支障がなかったため、今回はデフォルト設定のまま運用しています。詳細は <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-files-mounting-eks.html">AWS 公式ドキュメント</a> を参照してください</li>
</ul>
<hr />
<h2 id="計測結果">計測結果</h2>
<h3 id="lens2rust本番実測">lens2(Rust)本番実測</h3>
<p>Before の値は assets をイメージに焼き込んでいた移行前の実測値です。</p>
<table>
<thead>
<tr>
<th>指標</th>
<th>Before</th>
<th>After</th>
<th>改善</th>
</tr>
</thead>
<tbody>
<tr>
<td>ECR image size(圧縮後)</td>
<td>1.75 GB</td>
<td><strong>86 MB</strong></td>
<td>約 1/20</td>
</tr>
<tr>
<td>Build wall-clock</td>
<td>19m 19s</td>
<td><strong>3m 43s</strong></td>
<td>約 80% 削減</td>
</tr>
<tr>
<td>Pod Scheduled→Ready(cold)</td>
<td>86〜102 s</td>
<td><strong>~39s</strong></td>
<td>約 60% 削減</td>
</tr>
<tr>
<td>レンダリングレイテンシ(avg)</td>
<td>158.4 ms</td>
<td><strong>153.8 ms</strong></td>
<td>劣化なし(微改善)</td>
</tr>
</tbody>
</table>
<h3 id="lensjavascript本番実測">lens(JavaScript)本番実測</h3>
<p>Before の値は S3 Files 導入前の実測値です。</p>
<table>
<thead>
<tr>
<th>指標</th>
<th>Before</th>
<th>After</th>
<th>改善</th>
</tr>
</thead>
<tbody>
<tr>
<td>ECR image size(圧縮後)</td>
<td>10.3 GB</td>
<td><strong>507 MB</strong></td>
<td>約 1/20</td>
</tr>
<tr>
<td>Build wall-clock</td>
<td>14m 31s</td>
<td><strong>1m 48s</strong></td>
<td>約 88% 削減</td>
</tr>
<tr>
<td>Image pull(cold)</td>
<td>5m 03s</td>
<td><strong>11〜34s</strong></td>
<td>約 1/9〜1/27</td>
</tr>
<tr>
<td>Pod Scheduled→Ready(cold)</td>
<td>5〜5.5 分</td>
<td><strong>~45s</strong></td>
<td>約 85% 削減</td>
</tr>
<tr>
<td>レンダリングレイテンシ(avg)</td>
<td>~5.1s</td>
<td><strong>~4.9s</strong></td>
<td>劣化なし</td>
</tr>
</tbody>
</table>
<h3 id="s3-files-の読み取りレイテンシlens-本番実測">S3 Files の読み取りレイテンシ(lens 本番実測)</h3>
<table>
<thead>
<tr>
<th>ファイル</th>
<th>サイズ</th>
<th>初回アクセス</th>
<th>warm(2 回目以降)</th>
</tr>
</thead>
<tbody>
<tr>
<td>NotoSansJP-Medium.otf</td>
<td>4.6 MB</td>
<td>272 ms</td>
<td><strong>~2.7 ms</strong></td>
</tr>
<tr>
<td>watermark.png</td>
<td>15 KB</td>
<td>3.7 ms</td>
<td><strong>~1.9 ms</strong></td>
</tr>
<tr>
<td>checkerboard.png</td>
<td>14 KB</td>
<td>10.4 ms</td>
<td><strong>~2.0 ms</strong></td>
</tr>
<tr>
<td>item 画像(1.2 MB)</td>
<td>1.2 MB</td>
<td>247 ms</td>
<td><strong>~2.4 ms</strong></td>
</tr>
<tr>
<td>item 画像(15.4 MB)</td>
<td>15.4 MB</td>
<td>343 ms</td>
<td><strong>~3.4 ms</strong></td>
</tr>
</tbody>
</table>
<p>初回アクセスはファイルサイズに比例して数 ms〜数百 ms かかります。2 回目以降はキャッシュに乗り 2〜3 ms 台に収まります。</p>
<p><strong>アプリレイヤーへの影響はほぼゼロです。</strong> lens のレンダリング処理は ImageMagick の CPU 処理がボトルネックで 1 リクエストあたり平均 5 秒程度かかります。warm 状態での 2〜3 ms という読み取りレイテンシは、処理時間全体に対して誤差の範囲です。Datadog APM でも本番レンダリングレイテンシに劣化は確認されませんでした。</p>
<hr />
<h2 id="lensjavascriptへのロールアウト">lens(JavaScript)へのロールアウト</h2>
<p>lens2 での実装・検証を経て、lens への展開は設計の差分を埋めるだけで済みました。</p>
<h3 id="lens-ならではの差異-上書きマウント方式">lens ならではの差異: 上書きマウント方式</h3>
<p>lens2 では <code>ASSETS_DIR</code> 環境変数で assets パスを切り替えられましたが、lens は <code>path.resolve('assets/...')</code> + PM2 の <code>cwd: '/suzuri-lens'</code> でパスがハードコードされているため、環境変数による切り替えができません。</p>
<p>そこで S3 Files を <strong><code>/suzuri-lens/assets</code> に直接マウント(既存パスを shadow するボリュームマウント)</strong> する方式を採用しました。アプリ側のコード変更は一切不要で、Kubernetes マニフェストの <code>mountPath</code> をアプリの参照パスに合わせるだけです。</p>
<div class="highlight"><pre class="highlight yaml"><code><span class="c1"># lens の場合: アプリの参照パスに直接マウントして shadow</span>
<span class="na">volumeMounts</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">s3-assets</span>
<span class="na">mountPath</span><span class="pi">:</span> <span class="s">/suzuri-lens/assets</span> <span class="c1"># アプリの参照先パスと一致させる</span>
<span class="na">readOnly</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div>
<p>Phase 2b では「既存イメージ(assets 焼き込み)+ S3 Files の上書きマウント」の並存状態になります。どちらのソースから読んでも内容は同一なので、切り替えは透過的です。Phase 2c で軽量イメージに更新すると、S3 Files が唯一の assets ソースになります。</p>
<h3 id="lens2-のノウハウがそのまま活きた">lens2 のノウハウがそのまま活きた</h3>
<p>EFS CSI Driver の制約はすべて lens2 の試行錯誤で解決済みでした。PV/PVC 設定・IAM ロール構成・マウントオプションを lens2 のマニフェストからほぼそのまま流用できたため、lens の展開は短期間で完了しました。</p>
<hr />
<h2 id="まとめ">まとめ</h2>
<h3 id="成果サマリ">成果サマリ</h3>
<table>
<thead>
<tr>
<th>指標</th>
<th>lens2</th>
<th>lens</th>
</tr>
</thead>
<tbody>
<tr>
<td>コンテナイメージ(ECR 圧縮後)</td>
<td>1.75 GB → <strong>86 MB</strong>(約 1/20)</td>
<td>10.3 GB → <strong>507 MB</strong>(約 1/20)</td>
</tr>
<tr>
<td>Build time</td>
<td>19m 19s → <strong>3m 43s</strong>(80% 削減)</td>
<td>14m 31s → <strong>1m 48s</strong>(88% 削減)</td>
</tr>
<tr>
<td>Pod 起動(cold)</td>
<td>86〜102s → <strong>~39s</strong>(60% 削減)</td>
<td>5〜5.5 分 → <strong>~45s</strong>(85% 削減)</td>
</tr>
<tr>
<td>レンダリングレイテンシ</td>
<td><strong>劣化なし</strong></td>
<td><strong>劣化なし</strong></td>
</tr>
</tbody>
</table>
<p>上記はすべて <strong>月間 1.1 億リクエストを処理する本番サービスでの実測値</strong>です。</p>
<h3 id="s3-files-を選んでよかった点">S3 Files を選んでよかった点</h3>
<ol>
<li><strong>DataSync が不要</strong>: S3 が source of truth を維持したまま NFS マウントができる。同期ラグの問題が根本的に存在しない</li>
<li><strong>assets が増えてもイメージサイズが変わらない</strong>: 今後 lens2 の assets が増えても コンテナイメージは変わらない</li>
<li><strong>EKS / ECS / Lambda に横展開できる</strong>: 将来の Lambda 化へのパスが確保されている</li>
<li><strong>GA 直後でも実用レベルに達している</strong>: 2026 年 4 月 7 日 GA、5 月 7 日には lens/lens2 両方で本番稼働中</li>
</ol>
<h3 id="さいごに">さいごに</h3>
<p>正直、GA直後のサービスを本番に入れることにはリスクがありました。GA当日は2026年4月7日で、lens/lens2両方がそろったのがゴールデンウィークを挟んだ5月7日です。実質3週間ほどでここまでできたのは、lens2でスモールスタートしてからlensへ展開するという段階的な設計が効いていたと思います。lens2のほうがトラフィックが少ないため、仮に問題が起きてもロールバックできる状態で実績を積み、ゴールデンウィーク中にレイテンシの計測実績を蓄積してからlensへ投入するという順序があったからこそ、自信を持って進められました。「新しいものを本番に入れることにリスクがある」のは、裏を返せば計測と段階的な設計で解決できる問題でした。この取り組みがどなたかの背中を押せたら嬉しいです。</p>
</content>
</entry>
<entry>
<title>GitHub Actionsの実行遅延をCloud SchedulerとCloud Workflowsで解消する</title>
<link rel="alternate" href="https://tech.pepabo.com/2026/05/11/cloud-workflows-github-actions-trigger/"/>
<id>https://tech.pepabo.com/2026/05/11/cloud-workflows-github-actions-trigger/</id>
<published>2026-05-11T00:00:00+09:00</published>
<updated>2026-06-23T00:06:10+00:00</updated>
<author>
<name>zaimy</name>
</author>
<content type="html"><p>こんにちは、技術部データ基盤チームの <a href="https://x.com/hirokazaitsu">zaimy</a> です。</p>
<p>GitHub Actionsの <code>schedule:</code> トリガーが大幅に遅延する問題を、Cloud SchedulerとCloud Workflowsで解消した話を書きます。最終的に採用した構成だけでなく、検討して棄却した構成と棄却理由も合わせて紹介します。</p>
<ol id="markdown-toc">
<li><a href="#背景" id="markdown-toc-背景">背景</a></li>
<li><a href="#検討した構成" id="markdown-toc-検討した構成">検討した構成</a> <ol>
<li><a href="#案a-cloud-schedulerからgithub-apiを直接叩く" id="markdown-toc-案a-cloud-schedulerからgithub-apiを直接叩く">案A: Cloud SchedulerからGitHub APIを直接叩く</a></li>
<li><a href="#案b-cloud-schedulerから起動するcloud-workflowsでgithub-actionsを置き換える" id="markdown-toc-案b-cloud-schedulerから起動するcloud-workflowsでgithub-actionsを置き換える">案B: Cloud Schedulerから起動するCloud WorkflowsでGitHub Actionsを置き換える</a></li>
<li><a href="#案c-採用-cloud-schedulerから起動するcloud-workflowsがgithub-actionsをdispatchする" id="markdown-toc-案c-採用-cloud-schedulerから起動するcloud-workflowsがgithub-actionsをdispatchする">案C (採用): Cloud Schedulerから起動するCloud WorkflowsがGitHub Actionsをdispatchする</a></li>
</ol>
</li>
<li><a href="#なぜcloud-kmsが必要なのか" id="markdown-toc-なぜcloud-kmsが必要なのか">なぜCloud KMSが必要なのか</a></li>
<li><a href="#実装" id="markdown-toc-実装">実装</a> <ol>
<li><a href="#cloud-kms-asymmetric-keyの作成-terraform" id="markdown-toc-cloud-kms-asymmetric-keyの作成-terraform">Cloud KMS asymmetric keyの作成 (terraform)</a></li>
<li><a href="#github-appのprivate-keyをcloud-kmsにimport" id="markdown-toc-github-appのprivate-keyをcloud-kmsにimport">GitHub Appのprivate keyをCloud KMSにimport</a></li>
<li><a href="#cloud-workflowsのyaml" id="markdown-toc-cloud-workflowsのyaml">Cloud WorkflowsのYAML</a></li>
</ol>
</li>
<li><a href="#ハマりどころ" id="markdown-toc-ハマりどころ">ハマりどころ</a> <ol>
<li><a href="#import-methodの選択--aes-256-の有無" id="markdown-toc-import-methodの選択--aes-256-の有無">Import methodの選択 (<code>-aes-256</code> の有無)</a></li>
<li><a href="#pem--pkcs8-derへの変換" id="markdown-toc-pem--pkcs8-derへの変換">PEM → PKCS#8 DERへの変換</a></li>
</ol>
</li>
<li><a href="#結果" id="markdown-toc-結果">結果</a></li>
<li><a href="#その他の構成案" id="markdown-toc-その他の構成案">その他の構成案</a></li>
<li><a href="#まとめ" id="markdown-toc-まとめ">まとめ</a></li>
</ol>
<h2 id="背景">背景</h2>
<p>データ基盤チームでは、毎日の昼会で「前日のチーム内アップデート」を共有しています。GitHub Projectsのカードを更新者やステータスに基づいてアップデート種別 (例: 話題にすべき / 軽く触れる / ステータス移動) を自動分類してラベル付けするGitHub Actionsを、昼会の少し前に走らせる運用にしていました。このAction自体はラベル付与にClaudeを使う仕組みになっていて中身も面白いのですが、本記事の本題はそこではなく、起動タイミングの話です。</p>
<p>このActionは <code>schedule:</code> トリガー (cron) で平日11:55に実行するよう設定していましたが、実際の実行時刻は以下の通り、毎日60分以上の遅延がある状態でした。</p>
<table>
<thead>
<tr>
<th>日付</th>
<th>cron 設定 (UTC)</th>
<th>実際の実行時刻 (UTC)</th>
<th>遅延</th>
</tr>
</thead>
<tbody>
<tr>
<td>4/22 Wed</td>
<td>02:55</td>
<td>03:55</td>
<td>+60min</td>
</tr>
<tr>
<td>4/21 Tue</td>
<td>02:55</td>
<td>03:57</td>
<td>+62min</td>
</tr>
<tr>
<td>4/20 Mon</td>
<td>02:55</td>
<td>04:02</td>
<td>+67min</td>
</tr>
</tbody>
</table>
<p>GitHub Actionsの <code>schedule:</code> はベストエフォートで、混雑時は遅延・スキップされる仕様です。これは <a href="https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule">GitHub公式ドキュメント</a> にも明記されています。</p>
<blockquote>
<p>Note: The <code>schedule</code> event can be delayed during periods of high loads of GitHub Actions workflow runs. High load times include the start of every hour. If the load is sufficiently high enough, some queued jobs may be dropped.</p>
</blockquote>
<p>そこで定刻で実行できるよう、スケジューラを別の仕組みに移すことにしました。</p>
<h2 id="検討した構成">検討した構成</h2>
<h3 id="案a-cloud-schedulerからgithub-apiを直接叩く">案A: Cloud SchedulerからGitHub APIを直接叩く</h3>
<p><a href="https://cloud.google.com/scheduler">Cloud Scheduler</a> は秒単位で定刻実行する信頼性の高いサービスです。HTTPターゲットを設定できるので、<code>POST https://api.github.com/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches</code> を直接叩けばよいのでは、というアイデアです。</p>
<p>棄却理由: Cloud SchedulerのHTTPターゲットの認証オプションは、</p>
<ul>
<li>なし (公開エンドポイント向け)</li>
<li>OAuth2トークン (Google APIs専用)</li>
<li>OIDCトークン (Cloud Run / Cloud Functions向け)</li>
</ul>
<p>の3つだけで、<code>Authorization: Bearer &lt;token&gt;</code> のような任意ヘッダの値をSecret Manager等から動的に解決する仕組みがありません。カスタムヘッダ機能はあるものの、ヘッダ値はジョブ定義に平文ハードコードになります。つまりこの構成だと認証に必要な情報をterraform stateに平文で書くことになるため、棄却しました。</p>
<h3 id="案b-cloud-schedulerから起動するcloud-workflowsでgithub-actionsを置き換える">案B: Cloud Schedulerから起動するCloud WorkflowsでGitHub Actionsを置き換える</h3>
<p><a href="https://cloud.google.com/workflows">Cloud Workflows</a> はYAMLで処理を記述するワークフローエンジンで、HTTPコール、Secret Manager / KMS / BigQueryなどのGoogle Cloudサービス連携、リトライ、分岐、ループを書けます。</p>
<p><a href="https://tech.pepabo.com/2026/05/11/cloud-composer-retirement/">データ基盤のワークフロー構成変更によるコスト84%削減とCI 34倍高速化</a> で紹介している通り、ペパボのデータ基盤ではすでにCloud SchedulerとCloud Workflowsの利用実績があるため、Cloud Workflowsで既存のActionを置き換えられるのでは、というアイデアです。</p>
<p>棄却理由: 既存のラベル付けスクリプトは500行程度のPythonで、</p>
<ul>
<li>GitHub GraphQL APIのページネーション</li>
<li>Vertex AI ClaudeでのJSON分類</li>
<li>Issue / PRの差分計算 (前回のラベルと今回の分類差分だけを更新)</li>
</ul>
<p>を含みます。これらをCloud Workflows YAMLで書き直すと、可読性と保守性が大きく落ちます。スクリプトをコンテナに入れて、Cloud Workflowsから呼び出すCloud Runで実行する構成もあり得ますが、本件の本質は定刻実行なので、ラベル付け本体の実装はGitHub Actions側に残すのが妥当と判断しました。</p>
<h3 id="案c-採用-cloud-schedulerから起動するcloud-workflowsがgithub-actionsをdispatchする">案C (採用): Cloud Schedulerから起動するCloud WorkflowsがGitHub Actionsをdispatchする</h3>
<p>最終的に採用したのはこの構成です。Cloud WorkflowsからGitHub AppのJWT署名を行い、installation tokenを取得して <code>dispatches</code> を叩きます。</p>
<div class="highlight"><pre class="highlight plaintext"><code>Cloud Scheduler (Asia/Tokyo, 55 11 * * 1-5)
└─ Cloud Workflows
├─ Secret ManagerからGitHub App ID / Installation IDを取得
├─ Cloud KMS asymmetricSignでJWT (RS256) を署名
│ └─ GitHub App private keyはCloud KMSにasymmetric keyとしてimport済み
├─ POST https://api.github.com/app/installations/{id}/access_tokens
│ └─ installation tokenを取得
└─ POST https://api.github.com/repos/{org}/{repo}/actions/workflows/{workflow}.yml/dispatches
└─ GitHub Actionsを起動 (既存のラベル付けスクリプトが走る)
</code></pre></div>
<p>各レイヤの責務がきれいに分かれます。</p>
<table>
<thead>
<tr>
<th>レイヤ</th>
<th>責務</th>
</tr>
</thead>
<tbody>
<tr>
<td>Cloud Scheduler</td>
<td>定刻実行</td>
</tr>
<tr>
<td>Cloud Workflows</td>
<td>GitHub認証 + dispatch (JWT署名 + token取得 + API呼び出し)</td>
</tr>
<tr>
<td>GitHub Actions</td>
<td>ラベル付け本体 (Vertex AI Claude / GraphQL / 差分計算)</td>
</tr>
</tbody>
</table>
<h2 id="なぜcloud-kmsが必要なのか">なぜCloud KMSが必要なのか</h2>
<p>GitHub Appのトークン取得フローはこうです。</p>
<ol>
<li>App ID + private keyでJWT (RS256) を作る</li>
<li>JWTをBearerに付けて <code>POST /app/installations/{id}/access_tokens</code> → installation token (1時間有効) を取得</li>
<li>installation tokenをBearerに付けて <code>dispatches</code> を叩く</li>
</ol>
<p>問題は、1.のRS256署名です。Cloud Workflowsの標準ライブラリ (<code>base64</code>, <code>text</code>, <code>json</code> など) には暗号系のライブラリが含まれておらず、RSA秘密鍵で署名する関数が無いため、秘密鍵をSecret Managerから取り出してもJWTを組み立てられません。</p>
<p>回避策は以下の通りで、今回はCloud KMSを利用しました。</p>
<table>
<thead>
<tr>
<th>案</th>
<th>署名する場所</th>
</tr>
</thead>
<tbody>
<tr>
<td>Cloud KMS asymmetricSign</td>
<td>KMS (鍵をimportして署名APIを呼ぶ)</td>
</tr>
<tr>
<td>Cloud Run や Cloud Run Functionsを挟む</td>
<td>Python <code>pyjwt</code> 等で署名</td>
</tr>
</tbody>
</table>
<h2 id="実装">実装</h2>
<h3 id="cloud-kms-asymmetric-keyの作成-terraform">Cloud KMS asymmetric keyの作成 (terraform)</h3>
<div class="highlight"><pre class="highlight hcl"><code><span class="nx">resource</span> <span class="s2">"google_kms_key_ring"</span> <span class="s2">"workflows"</span> <span class="p">{</span>
<span class="nx">name</span> <span class="p">=</span> <span class="s2">"workflows"</span>
<span class="nx">location</span> <span class="p">=</span> <span class="nx">var</span><span class="err">.</span><span class="nx">region</span>
<span class="p">}</span>
<span class="nx">resource</span> <span class="s2">"google_kms_crypto_key"</span> <span class="s2">"github_app"</span> <span class="p">{</span>
<span class="nx">name</span> <span class="p">=</span> <span class="s2">"github-app-private-key"</span>
<span class="nx">key_ring</span> <span class="p">=</span> <span class="nx">google_kms_key_ring</span><span class="err">.</span><span class="nx">workflows</span><span class="err">.</span><span class="nx">id</span>
<span class="nx">purpose</span> <span class="p">=</span> <span class="s2">"ASYMMETRIC_SIGN"</span>
<span class="c1"># 既存の GitHub App private key を後から import するため初期バージョンは作らない</span>
<span class="nx">skip_initial_version_creation</span> <span class="p">=</span> <span class="kc">true</span>
<span class="nx">version_template</span> <span class="p">{</span>
<span class="nx">algorithm</span> <span class="p">=</span> <span class="s2">"RSA_SIGN_PKCS1_2048_SHA256"</span>
<span class="nx">protection_level</span> <span class="p">=</span> <span class="s2">"SOFTWARE"</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="nx">resource</span> <span class="s2">"google_kms_crypto_key_iam_member"</span> <span class="s2">"github_app_signer"</span> <span class="p">{</span>
<span class="nx">for_each</span> <span class="p">=</span> <span class="nx">toset</span><span class="err">(</span><span class="p">[</span>
<span class="s2">"prod-serviceaccount@{project}.iam.gserviceaccount.com"</span><span class="p">,</span>
<span class="s2">"test-serviceaccount@{project}.iam.gserviceaccount.com"</span><span class="p">,</span>
<span class="p">]</span><span class="err">)</span>
<span class="nx">crypto_key_id</span> <span class="p">=</span> <span class="nx">google_kms_crypto_key</span><span class="err">.</span><span class="nx">github_app</span><span class="err">.</span><span class="nx">id</span>
<span class="nx">role</span> <span class="p">=</span> <span class="s2">"roles/cloudkms.signerVerifier"</span>
<span class="nx">member</span> <span class="p">=</span> <span class="s2">"serviceAccount:${each.value}"</span>
<span class="p">}</span>
</code></pre></div>
<h3 id="github-appのprivate-keyをcloud-kmsにimport">GitHub Appのprivate keyをCloud KMSにimport</h3>
<p>GitHub Appは外部公開鍵の登録を許さない (常にGitHub側で鍵を生成する) ので、既にSecret Manager上に登録してあるprivate keyをCloud KMSにimport jobで取り込みます。</p>
<div class="highlight"><pre class="highlight shell"><code><span class="c"># 1. import job を作成する。AES wrapping を含む -aes-256 つきを使う。</span>
gcloud kms import-jobs create import-github-app <span class="se">\</span>
<span class="nt">--location</span><span class="o">=</span>us-central1 <span class="nt">--keyring</span><span class="o">=</span>workflows <span class="se">\</span>
<span class="nt">--import-method</span><span class="o">=</span>rsa-oaep-3072-sha256-aes-256 <span class="nt">--protection-level</span><span class="o">=</span>software
<span class="c"># 2. import job が ACTIVE になるまで数分待つ。</span>
gcloud kms import-jobs describe import-github-app <span class="se">\</span>
<span class="nt">--location</span><span class="o">=</span>us-central1 <span class="nt">--keyring</span><span class="o">=</span>workflows <span class="se">\</span>
<span class="nt">--format</span><span class="o">=</span><span class="s1">'value(state)'</span>
<span class="c"># 3. private key を Secret Manager から取得して PKCS#8 DER に変換する。</span>
<span class="c"># Secret Manager 上は GitHub から download した PKCS#1 PEM 形式だが、</span>
<span class="c"># --target-key-file は PKCS#8 DER binary を要求するため変換が必要。</span>
gcloud secrets versions access latest <span class="se">\</span>
<span class="nt">--secret</span><span class="o">=</span>github-app-private-key-secret <span class="se">\</span>
<span class="nt">--out-file</span><span class="o">=</span>/tmp/gh_app.pem
openssl pkcs8 <span class="nt">-topk8</span> <span class="nt">-nocrypt</span> <span class="nt">-inform</span> PEM <span class="nt">-in</span> /tmp/gh_app.pem <span class="nt">-outform</span> DER <span class="nt">-out</span> /tmp/gh_app.der
<span class="c"># 4. private key を import する。</span>
gcloud kms keys versions import <span class="se">\</span>
<span class="nt">--import-job</span><span class="o">=</span>import-github-app <span class="se">\</span>
<span class="nt">--location</span><span class="o">=</span>us-central1 <span class="nt">--keyring</span><span class="o">=</span>workflows <span class="se">\</span>
<span class="nt">--key</span><span class="o">=</span>github-app-private-key <span class="se">\</span>
<span class="nt">--algorithm</span><span class="o">=</span>rsa-sign-pkcs1-2048-sha256 <span class="se">\</span>
<span class="nt">--target-key-file</span><span class="o">=</span>/tmp/gh_app.der
<span class="c"># 5. 一時ファイルを削除する。</span>
<span class="nb">rm</span> /tmp/gh_app.pem /tmp/gh_app.der
</code></pre></div>
<h3 id="cloud-workflowsのyaml">Cloud WorkflowsのYAML</h3>
<p>JWT構築 → Cloud KMS署名 → installation token取得 → dispatchの流れをそのまま書き下します。</p>
<div class="highlight"><pre class="highlight yaml"><code><span class="na">main</span><span class="pi">:</span>
<span class="na">params</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">args</span><span class="pi">]</span>
<span class="na">steps</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">init</span><span class="pi">:</span>
<span class="na">assign</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">project_id</span><span class="pi">:</span> <span class="s">${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}</span>
<span class="pi">-</span> <span class="na">github_app_id_secret</span><span class="pi">:</span> <span class="s">${args.github_app_id_secret}</span>
<span class="pi">-</span> <span class="na">github_app_installation_id_secret</span><span class="pi">:</span> <span class="s">${args.github_app_installation_id_secret}</span>
<span class="pi">-</span> <span class="na">kms_key_version_name</span><span class="pi">:</span> <span class="s">${args.kms_key_version_name}</span>
<span class="pi">-</span> <span class="na">target_repo</span><span class="pi">:</span> <span class="s">${args.target_repo}</span>
<span class="pi">-</span> <span class="na">target_workflow_file</span><span class="pi">:</span> <span class="s">${args.target_workflow_file}</span>
<span class="pi">-</span> <span class="na">target_ref</span><span class="pi">:</span> <span class="s">${args.target_ref}</span>
<span class="pi">-</span> <span class="na">get_github_app_id_secret</span><span class="pi">:</span>
<span class="na">call</span><span class="pi">:</span> <span class="s">googleapis.secretmanager.v1.projects.secrets.versions.access</span>
<span class="na">args</span><span class="pi">:</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">${"projects/" + project_id + "/secrets/" + github_app_id_secret + "/versions/latest"}</span>
<span class="na">result</span><span class="pi">:</span> <span class="s">app_id_secret_response</span>
<span class="pi">-</span> <span class="na">decode_github_app_id</span><span class="pi">:</span>
<span class="na">assign</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">github_app_id</span><span class="pi">:</span> <span class="s">${int(text.decode(base64.decode(app_id_secret_response.payload.data)))}</span>
<span class="pi">-</span> <span class="na">get_github_app_installation_id_secret</span><span class="pi">:</span>
<span class="na">call</span><span class="pi">:</span> <span class="s">googleapis.secretmanager.v1.projects.secrets.versions.access</span>
<span class="na">args</span><span class="pi">:</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">${"projects/" + project_id + "/secrets/" + github_app_installation_id_secret + "/versions/latest"}</span>
<span class="na">result</span><span class="pi">:</span> <span class="s">installation_id_secret_response</span>
<span class="pi">-</span> <span class="na">decode_github_app_installation_id</span><span class="pi">:</span>
<span class="na">assign</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">github_app_installation_id</span><span class="pi">:</span> <span class="s">${text.decode(base64.decode(installation_id_secret_response.payload.data))}</span>
<span class="pi">-</span> <span class="na">build_jwt_header_and_payload</span><span class="pi">:</span>
<span class="na">assign</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">now_seconds</span><span class="pi">:</span> <span class="s">${int(sys.now())}</span>
<span class="pi">-</span> <span class="na">jwt_header_obj</span><span class="pi">:</span>
<span class="na">alg</span><span class="pi">:</span> <span class="s2">"</span><span class="s">RS256"</span>
<span class="na">typ</span><span class="pi">:</span> <span class="s2">"</span><span class="s">JWT"</span>
<span class="pi">-</span> <span class="na">jwt_payload_obj</span><span class="pi">:</span>
<span class="na">iat</span><span class="pi">:</span> <span class="s">${now_seconds - 60}</span>
<span class="na">exp</span><span class="pi">:</span> <span class="s">${now_seconds + 480}</span>
<span class="na">iss</span><span class="pi">:</span> <span class="s">${github_app_id}</span>
<span class="pi">-</span> <span class="na">jwt_header_json</span><span class="pi">:</span> <span class="s">${json.encode_to_string(jwt_header_obj)}</span>
<span class="pi">-</span> <span class="na">jwt_payload_json</span><span class="pi">:</span> <span class="s">${json.encode_to_string(jwt_payload_obj)}</span>
<span class="pi">-</span> <span class="na">jwt_header_b64_std</span><span class="pi">:</span> <span class="s">${base64.encode(text.encode(jwt_header_json))}</span>
<span class="pi">-</span> <span class="na">jwt_payload_b64_std</span><span class="pi">:</span> <span class="s">${base64.encode(text.encode(jwt_payload_json))}</span>
<span class="pi">-</span> <span class="na">jwt_header_b64</span><span class="pi">:</span> <span class="s">${text.replace_all(text.replace_all(text.replace_all(jwt_header_b64_std, "+", "-"), "/", "_"), "=", "")}</span>