古いコードに向き合い、未来に何を遺すか

ここ数年は仕事で「最後のコミットが10年前」みたいなコードを触ることが多く、古いコードに対してどのように向き合うかと同時に、 コードを長く維持していく上でどのいう振る舞いをするとよいかを考えることが多くなった。 年末なので、自分が特に最近意識していることをを紹介する。

要らないコードはさっさと消す

年末といえば大掃除、ということで年末らしい話題。普段仕事をしている中で「これは使われてなさそうだけど、消していいかわからないな」とか、「これは今は使わなくなったけど、残しておいたらあとで使うかもしれないし残しておく」という場面がある。 消すためにもちょっと調べないといけないし、消すより残しておいたほうが安全だし、面倒なので残しておくか・・・ということをやったことはないだろうか。僕はある。 しかし必要のないコードなのであればさっさと消したほうがよい。現代だと大抵gitなりなんなりで管理されているのだから、必要だったら過去コミットから拾ったらいい。

事情を知っているメンバーがいるうちはいいが、気がついたらメンバー全員が入れ替わっているということもある。もしかしたら2周くらいしてることもあるかもしれない。 そうなった時に事情を知る人がいなくなり、あるいは残っているけど忘れてしまったということが起こり、より消しにくくなって、気がついたら最初に想像していた以上にコードが増えてしまうということもあるだろう。 コード量の多いリポジトリは新メンバーへの導入の心理的なハードルが高くなる。部屋を片付けてくださいと言われ、物が少ない部屋と、仮に整理されていたとしても物が多い部屋だと、後者に感じると思う。

消すことで得られるものは大きい。消すことでコードの見通しがよくなるし、全体のボリュームをできるだけ小さく保つ方向に力が働いて、より把握しやすくなる。 しかし使わなくなったと判断したその場で消すのが一番簡単だと思う。消していきましょう。

コミットログには変更の理由を残す

VCSで管理されているコードに対してなんらか修正したあとに「fix」みたいなログでコミットすることないですか。僕も昔はそういうことを結構やっていた。 しかし10年後にそういうログが残ったコミットを見たら「修正したのはわかるんよ」ってツッコミたくなると思う。ログを追っているのは変更した理由を知りたいからで、10年後にfixとだけ書いてあったらものすごくがっかりするだろう。 よほど単純な修正じゃない限り、ちゃんと理由を残すとすると、どの順番で修正するかというのを先に考えるとか、どういう粒度でコミットするとかを最初に検討することになり、結果としてコードの品質も上がるだろうと思う。

とはいえ試行錯誤の過程で雑なコミットが増える、みたいなことも普通に起こる。gitを使っているなら後から改めて修正することもできる。自分では後で全部の修正を見返して、コミットをまとめたり入れ替えたり、みたいなことをやっている。 あとうっかり同じファイルに対して複数の修正を一つのコミットに含めそうになってしまう場合とかはgit add -pでコミットする範囲を選んでコミットするとかして、粒度を調整するとよい。

【余談】サービスの寿命よりもチームの人員の入れ替わりのほうが早いことがある

これは振る舞いの話ではないけど、サービスが未来永劫同じメンバーで開発・運用され続けるとは限らない。メンバーが入ってくることもあるし、何らかの事情でいなくなることもあるだろう。 気がついたらメンバーが全員入れ替わっているということも起こる。当然引継ぎはされていくだろう。常に100%完璧に引継ぎされているのであれば問題ない。実際には良くて8割〜9割くらいで、残りはコードや残されたドキュメントから読み取ることになる。そうして何年かすると、考古学のように様々な資料から過去の経緯を推察する、ということになる。長く続けばそうなっていくのは仕方ないと思う。

だからこそ、ドキュメントにしてもコミットログにしても、あるいはコードのコメントにしても、今いる人や直近でチームに入ってくるだろう人向けにはもちろん、 今のチームメンバーが関わることがない未来のチームに対して残す、という意識で書くのが良いと思う。方法論ではなく意識の話になってしまうが、これは明日、つまり新年からでもできる。

ここまで技術的な話というよりは振る舞いの話に終始したけど、自分が大事だと考えている振る舞いについて紹介した。こんなの当然でしょという人たちはそのまま継続してほしいし、 もしもこんなこと意識してなかったという人がいれば参考にしてほしい。ここで紹介した振る舞いでもしかしたら10年後の誰かを助けることができるかもしれない。

ということで

無理やりエンディングって感じだけど、このエントリははてなエンジニア Advent Calendar 2023 - Hatena Developer Blogの31日目のエントリだったのでした。ちなみに昨日はid:stefafafanエンジニアリングマネージャーの4領域はEM以外のメンバーでも濃淡はあれど意識する必要がある - stefafafan の fa は3つですというエントリでした。

2023年の大晦日だから今日で最後かと思ったけど、もうちっとだけ続くんじゃということで 2024年になってももう少し続くようです。明日はid:nakatakiさんとのこと。お楽しみに。

ぼく個人の挨拶としては「来年もよろしくお願いします」ということで。

YAPC::Kyoto 2023にスタッフとして参加した

ということでスタッフとして参加した。その後も個人的にやることがたくさんあってエントリかけてなかったけど...(これは言い訳です)

京都でYAPCをやると聞いた時からスタッフとして参加したいと考えていた。自分が住んでいる京都で開催されるなら、せっかくならイベントをつくる側に回りたいと思っていて、YAPC::Kyoto 2020のスタッフを募集しているのを見かけて応募したのが始まり。YAPC::Kyoto 2020は結局延期されてしまったけど、YAPC::Kyoto 2023としてrebootするときに改めて声を掛けてもらって、スタッフとして参加することができた。無事に当初の願いが叶ったのだった。

色々やらせてもらったけど、目にみえるところだと個人スポンサー向けのノベルティの企画立てたりした。

blog.yapcjapan.org

裏話だけど、最初はタンブラーではなくて、全然違うものを電電宮で祈祷してもらってノベルティにしようということを企画していた。ただ企画を進める途中でタンブラーを見つけて、この方が普段使いできそうだなと思ってタンブラーに変えさせてもらった。せっかくなら普段から使えるものがいいだろうし、今回のようなタンブラーならコード書いてるときに横に置いてもらえるんじゃないかと思う。キラキラステッカーの方は電電宮で祈祷してもらったけど、祈祷するにあたり電電宮にメールしたりして調整を進めたりした。寺社仏閣にメールする経験なんてないので毎回妙に緊張していた。結果としていい感じのYAPC::Kyoto 2023のOP動画も撮れたので良かったと思う。と書いているけど、実は祈祷当日は別の用事があって行けなかったのでちょっとざんねん。まあそのうち自分で行こうと思う。

今回はLTでも話すことができた。会社関連のイベントで登壇したことは何度かあるけど、ああいうイベントで、あれくらいの人数の前で話すのは実は初めてで、今までにないくらい緊張した。一つ経験できたのは良かったと思う。次はトークにも応募できたら良い。

正直なことを書くと、実は始まるまで「オフライン開催」自体に懐疑的というか、オンラインとどういう違いがあるかがあんまり想像できていなかったというか、遠くの人も参加できるという点ではオンラインにまだ分があると考えていた。でも、集まった人々があちこちでコミュニケーションとっているのを見て、ああこういうイベントはオフラインでやるべきだなと思い直した。久々に会ったとか、ネットではお見かけするけど実際に話すのは初めてみたいなコミュニケーションがあちこちで起こっているのは良かった。自分はそういう中に入っていくのは苦手で、今回は多少頑張ったと思うけど、次はもっと積極的に行きたい。

イベント準備から当日まで、自分も十分楽しかったけど、今回来てくれた人たちが楽しいと思ってくれたのであれば、自分にとってはそっちの方が成功と言える。来て良かったと思う人がたくさんいると嬉しい。

なんかだいぶ雑なエントリになったけど、とにかく楽しかった。実はトークは全然見られてないので、後でアーカイブでゆっくり見ようと思う。

GolangでSMTPを使ってメールを送る処理を書く

最近Golangでメール送信処理を書くことがあったのだけど、あまり事情を知らなかったのでまとめた。

golang+SMTPでメールを送る

Goには標準ライブラリでnet/smtpというのがある。

smtp package - net/smtp - Go Packages

これは名前の通りGoからSMTPでメールを送信するためのライブラリなのだけど、例えばヘッダとボディの間には空行を一行自分で挟まないといけないとか、素朴すぎて結構辛い。 さすがに2023年にもなってさすがにそういうことはやりたくないので、もう少しいいやつないかなと探して、今回は以下のライブラリを使った。

github.com

これはそこそこ高機能だと思う。少なくとも自分でヘッダ部とボディの間に空行を入れる、みたいなことをしなくてもいい。middlewareを差し込めるようになっていて、middlewareによって挙動を少し変える、みたいな最近ぽいこともできるのもよい(そういう場面がどれくらいあるかは置いといて)。あと最近もメンテされているという点もよい。

SMTPでメール送るコードはこういう感じ。

package main

import (
    "log"
    "mime"
    "os"
    "strconv"

    "github.com/wneessen/go-mail"
)

func main() {
    msg := mail.NewMsg()
    host := os.Getenv("SMTP_HOST")
    if host == "" {
        log.Fatal("SMTP_HOST required")
    }

    port, err := strconv.Atoi(os.Getenv("SMTP_PORT"))
    if err != nil {
        log.Fatal(err)
    }
    if err := msg.From("hoge@example.test"); err != nil {
        log.Fatal(err)
    }
    if err := msg.To("fuga@example.test"); err != nil {
        log.Fatal(err)
    }

    msg.Subject(mime.BEncoding.Encode("UTF-8", "こんにちはこんにちは"))
    msg.SetBodyString(mail.TypeTextPlain, "ようこそこんにちは")

    c, err := mail.NewClient(host, mail.WithPort(port))
    if err != nil {
        log.Fatal(err)
    }
    if err := c.DialAndSend(msg); err != nil {
        log.Fatal(err)
    }
}

こういう感じで動く。Subjectはmime.BEncodingしたりする必要がある。あとSMTPのホストとかポートも環境変数で渡せるようにしておくほうが使いやすいけど、この辺はそれぞれの事情による。

送信されるメールを確認する

メールを送信するコードを書いたり、送信用のメールのテンプレートを追加したような時に、見た目を確認するために実際に自分なりクローズドな何かにメールを送る、というのをやったことがある人は結構いると思う。こういう作業でミスしないように慎重に送信テストするぞ・・・とか言ってストレスMAXになったりしたことありませんか。僕はある。ローカルの環境にメールサーバーを立てておいて、そこに送って確認したらまあいいのだけど、そんな面倒なことはしたくない・・・という人におすすめなのがMailhogというテスト用のSMTPサーバーを立てる方法。

github.com

これを自前でビルドするとかではなくて、DockerHubに既にイメージが存在するのでそれを使う。

registry.hub.docker.com

これがいいのはWebUIがついている点で、これで立てられたテスト用のSMTPサーバーにメールを送ると、送られたメールをそのWebUIで確認できる。 開発時にはdocker composeでアプリケーションと同時に立てると、アプリケーションのメール送信処理をテストする仕組みを簡単につくることができる。 docker-compose.ymlの該当部分はこういう感じになる。

version: '3.8'
services:
  app:
    build:
      context: .
    command: [  ] # 任意のコマンド
    env:
      SMTP_HOST: 'mail'
      SMTP_PORT: '1025'
  mail:
    image: mailhog/mailhog:latest
    ports:
      - "8025:8025"
      - "1025:1025"

mailhogはデフォルトだとSMTPサーバーは1025番で、WebUIは8025番で受けているので、必要であればポートフォワードするとよい。 前述のメール送信のコードにも書いたけど、アプリケーションにはSMTP_HOSTSMTP_PORT環境変数で渡すようにしておけば、ローカル開発ではmailコンテナの1025番にメールを送信して、本番環境では任意のホスト/ポートを設定する、というようにできる。 あとはメールを送信する仕組みを実際に動かして、WebUIで確認したらよい。個人的には、本当に送信されることがないよう、RFC2606で予約済みのTLDである.testみたいなドメインにテストメールを送るようにしている(これが正しい使い方かは微妙なラインかもしれない)

https://tex2e.github.io/rfc-translater/html/rfc2606.html

まとめ

近年だとメールを送ることあまりないだろうし、あってもAWS SNSみたいなマネージドサービスを使ってSDK経由でメール送信する、みたいなことの方が多いと思うけど、もしもSMTP経由でメール送信することになったら参考にしてほしい。あとMailhogは便利。

ISUCON12に出て予選敗退した

id:yashigani_wさん, id:wtatsuruさんと、「デジタルトランスフォーメーションズ」というチームでISUCON12に出たけど、振り返ってみたらほぼ初期データを弄って終了してしまったな、という振り返りエントリ。何を振り返ったらええねんと思いながら過ごしていたら時間が経ってしまった。 そういえばチーム名の最後に2022ってつけたいと思ってたのを今思い出した。

当日やったこと

我々のチームでは15分スプリントというのをやっていて、「15分作業 → 5分スプリント会」みたいなサイクルを回していた。これはwtaturuさんが去年のチームでやっていたらしく、今年もやることにした。ということでスプリント会の記録がissueに残っている

https://github.com/tatsuru/isucon12-yosen/issues/3

終盤少しグダッとした場面もあったが、それでも18スプリント回せていたのはよかった

初期データをつくる

手を動かしていた場面はほとんどこれで終わってしまった...

  • 初回のベンチを回した結果を眺めていて、rankingのエンドポイントが遅いので、そこから直そうとなった
  • とはいえどういう作戦にするかをパッと決めることはできなくて、どうやって手を入れるかの材料を集めるのに結構時間がかかってしまった。最終的には以下の作戦でよかろうという話になる。
    • 初期データではplayer_scoreから一つのユーザーが複数のスコアをもっていたので、これを各ユーザー最新の一件のみになるようにデータを作り直す
    • データを入れる時も最新の一件だけになるように
    • player_scoreをひく時は単純にORDER BY scoreでソートするように
  • rankingが遅いのでranking作るところだけ直すか、と話していたところ、結局データを入れるところも直さないと意味がないよねとなり、セットで修正することに
  • 最初は/initializeでこの辺りの処理全部やってしまうつもりで進めていたが、一度ベンチマークを試したら初期化処理に落ちてしまって、作戦を少し変える
    • 振り返ってみるとこの時どういう理由で落ちたかあんまり見てなかったのはよくなかった
  • スコアに関するデータはSQLiteのDBに乗っていて、実体としてはテナントごとに分割されたファイルなので、初期データを手元で作り直してそれをホストに置く作戦にした
  • 各ユーザーの最新のスコアだけ取り出すのは以下のSELECT文で取ってきていた(たぶん合ってると思う)
SELECT * from player_score WHERE competition_id = ? GROUP BY player_id HAVING MAX(row_num);
  • ちなみに初期データを作り直すのもGoで書いた。と言っても元データ取り出してSELECTでフィルタリングして別のDBに書き込むだけなので難しいことはしていない
  • これでplayer_scoreのレコード数が大幅に減った
    • 一番多いところで160万レコードくらいあったのが8万レコードくらいになった
  • 一方でこの辺りでミスが増え始める
    • player_scoreだけ新しいDBに移してしまい、その他のテーブルを移し忘れて/initializeに落ちる
    • 100テナントあることはわかっていたけど、forの条件普通にミスって99テナント分しか移しておらず、やはり/initializeに落ちる
      • 全テーブル移したのになんで落ちるんとか思っていたけど、ちゃんとログ出したら原因判明したし、わかった後はあまりにも恥ずかしくてすまん・・・すまん・・・となっていた
    • 他にもあった気がするけど忘れてしまった。ただ修正→確認のサイクルの時間が長くてだいぶ時間使ってしまった

メモリリークの修正

  • 初期データを作っている横でメモリ使用量が膨らんでパフォーマンスが落ちる問題が起きる
    • 完全に勘だったけど、メモリを使いまくるような処理はcsvの読み込みじゃないのと思ったので、アクセスの傾向を改めて眺めたところ、csvによるスコア登録のリクエスト数が初期実装と比較して増えていることに気づく
    • ここを少し修正してベンチが最後まで通るようになったあたりで概ね時間終了
    • github.com

ふりかえり

  • id:wtatsuruさんも書いていたが、とにかく手が遅い。去年も遅いなと思っていたけど改めて痛感した
  • 与えられた環境で勝負しすぎた。仕事でやったとしてもさっさとSQLiteからMySQLに移行しましょうと言うと思う
    • DB移行は頭には浮かんだものの時間かかりそうだからって後回しにしてしまった
    • 最終的にはどうせやるだろう作業は最初にやるのがよい 
  • ログ出すのも困ったらすぐにやるべきだった
  • 三人で集まって会話しながら作業できたのはよかった
    • ディスプレイを2台置いて、常に片方がペアプロしてるような状況を作れた
    • ホワイトボードで絵を描いて意見を擦り合わせたりがやりやすかった
    • 15分スプリント、5分振り返りで18スプリント回したけど、そうすると会話の時間は90分あることになるが、これは少し長いかもしれないし、そんなことないかもしれない
  • 悔しい結果とはなったけどシンプルに楽しかった
    • 今回は一番ネックになっていそうな部分を順に直していきましょうというのだけ決めていて、その通りに動けたのはよかった

来年また出直します...運営の皆様お疲れ様でした & ありがとうございました。 

メンバーの振り返り

yashigani.hatenablog.com

wtatsuru.hatenadiary.com

2021年

良くも悪くも子供が中心だった一年だったように思う。子供が楽しそうにしてるのを見たくて休みにあれこれ計画したり、子供が体調を崩すと仕事ができなくて家の中がめちゃくちゃになったりというのを繰り返していたら一年が終わった。

本当は住む場所を変えたりしたかったのだけど、これも子供の都合で少し先送りすることにした。つぎに引っ越すときは地元へ帰ることも検討しているけど、まあもう少し先になりそう。

2022年にはなにか新しいことも始めてみたい。

AWS S3バッチオペレーションのちょっとしたtipsなどのご紹介

このエントリは、はてなエンジニアAdvent Calendarの9日目の記事としてかかれました。

AWS S3にはバッチオペレーションというマネージドサービスがあって、これは指定したバケット/オブジェクトに対して一括で何かしらの操作ができる。例えば「バケット内のすべてのオブジェクトを別バケットにコピーしたい」とかそういう時に使うと便利。

aws.amazon.com

その一括操作ではLambdaを利用することもできる。Lambdaを使うとかなり柔軟な操作ができるようになるが、ドキュメントを見ただけでは最初どうしたらいいかわからなかった上に、利用する機会もそんなに無いので覚えられない。その他にも最初に知ってたらよかったみたいなのが細々とあるので、そういうのを少しまとめておく。

なお、このエントリではS3 バッチオペレーション自体のジョブの登録のやり方自体は割愛する。まずS3バッチオペレーションがどういうものか知りたいという人には、以下のエントリが雰囲気を掴むのに良いと思う。

dev.classmethod.jp

マニフェストファイル

バッチオペレーションのジョブを登録するにはどのオブジェクトを操作するのかを指定するためのマニフェストファイルが必要になる。マニフェストファイルにはS3インベントリレポートというのを使うと良いというのをよく見るけど、実は自分で作ったCSVを使うこともできる。フォーマットとしては以下のような感じ。

{bucket},{object_key}

つまりバケットと操作したいオブジェクトのキーの一覧だけでよい。最初から操作したいオブジェクトがわかっている場合はインベントリレポートを出力せずとも上のフォーマットのCSVを作ればよいし、インベントリレポートを全部チェックした上で、一部の操作対象を出力する、とかでもよい。初回のインベントリレポートの出力には少し時間がかかる(24h程度?)ので、操作対象が完全にわかっている場合は自分でCSVファイルを作ったほうが実は手早いなどはありそう。

S3 バッチオペレーションからLambdaへのリクエス

Lambdaへは以下のようなJSONがリクエストされる(ドキュメント から引用)

{
    "invocationSchemaVersion": "1.0",
    "invocationId": "YXNkbGZqYWRmaiBhc2RmdW9hZHNmZGpmaGFzbGtkaGZza2RmaAo",
    "job": {
        "id": "f3cc4f60-61f6-4a2b-8a21-d07600c373ce"
    },
    "tasks": [
        {
            "taskId": "dGFza2lkZ29lc2hlcmUK",
            "s3Key": "customerImage1.jpg",
            "s3VersionId": "1",
            "s3BucketArn": "arn:aws:s3:us-east-1:0123456788:awsexamplebucket1"
        }
    ]
}

tasksは配列になっているが、実際には要素は一つしかない。Lambdaでどういう操作をするかにもよるが、操作するにあたって重要なのはs3Keyとなる。これを利用して、例えばObjectをGetしてきて加工して別のバケットにPUTしたりする。Lambdaからのレスポンスは以下のようなものを返す(こちらもドキュメントから引用)

{
  "invocationSchemaVersion": "1.0",
  "treatMissingKeysAs" : "PermanentFailure",
  "invocationId" : "YXNkbGZqYWRmaiBhc2RmdW9hZHNmZGpmaGFzbGtkaGZza2RmaAo",
  "results": [
    {
      "taskId": "dGFza2lkZ29lc2hlcmUK",
      "resultCode": "Succeeded",
      "resultString": "[\"Mary Major", \"John Stiles\"]"
    }
  ]
}

こちらもresultsは配列となっているが、実際には要素を一つだけ返せばよい。results内のresultCodeにはSucceeded/TemporaryFailure/PermanentFailureのどれかを指定する。ジョブのレポートを出力して、オブジェクトごとに処理の成否を確認したいような場合は成否に合わせてSucceeded/PermanentFailureを指定し、resultStringに成功した場合は成功メッセージを、失敗した場合は失敗理由がわかるようなメッセージを埋めておき、レポートを精査するのが良い。

個人的にはresultStringにはJSON文字列を埋めておくのがよいと考えている。大量のオブジェクトを操作した結果について精査する場合、レポートのCSVを何かしらのスクリプトで処理した上でresultStringを確認することになるはずで、その時にJSONであれば扱いやすいと思う。

また、レスポンスの以下のフィールドにはリクエストのJSONに含まれる値をそのまま利用するとよい。あと特に詳細には触れなかったけど、バッチオペレーションからLambdaを実行するとか、LambdaからS3にアクセスするための権限設定はそれぞれ必要になる。

  • invocationSchemaVersion
  • invocationId
  • results.taskId

特にresults.taskIdはバッチオペレーションのジョブで一意になっていないと失敗してしまうので、リクエストされたJSONに含まれている値をそのまま使うのが一番安全。 これ以外にtreatMissingKeysAsフィールドがあり、これは特にドキュメントに記載されていないが、例えば先程のresults.taskIdがジョブで一意になっておらず失敗した場合、ここで指定されたレスポンスコードが利用される。下のメッセージはresults.taskIdが一意ではなかった場合にレポートに出力されていたもの。

"Lambda function didn't return the tasks/keys in the function response, using ""treatMissingKeysAs"" (default to TemporaryFailure) as error code: PERMANENT_FAILURE"

ここにあるように、デフォルトだとTemporaryFailureが使われるけど、ここにはPermanentFailureを指定しておけば良いと思う。

任意の値をLambdaに渡したい

任意の値をLambdaに渡す方法も用意されている。バッチオペレーションのジョブを登録する時に操作するオブジェクトのバケットとキーを指定するためのCSVファイルを渡すことになるのだけど、そのCSVのキーの位置にURLエンコードしたJSON文字列を入れるとよい。以下のドキュメントに記載されている。

docs.aws.amazon.com

例えば次のようなJSONをLambdaに渡したいとする。

{
  "s3Key": "1aaaa.txt",
  "newKey": "new-1aaaa.txt"
}

この場合、バッチオペレーションのジョブに渡すCSVファイルは以下のような感じになる。

tkzwtks-s3,%7B%22s3Key%22%3A%221aaaa.txt%22%2C%22newKey%22%3A%22new-1aaaa.txt%22%7D
tkzwtks-s3,%7B%22s3Key%22%3A%222aaaa.txt%22%2C%22newKey%22%3A%22new-2aaaa.txt%22%7D
tkzwtks-s3,%7B%22s3Key%22%3A%223aaaa.txt%22%2C%22newKey%22%3A%22new-3aaaa.txt%22%7D
tkzwtks-s3,%7B%22s3Key%22%3A%224aaaa.txt%22%2C%22newKey%22%3A%22new-4aaaa.txt%22%7D
tkzwtks-s3,%7B%22s3Key%22%3A%229aaaa.txt%22%2C%22newKey%22%3A%22new-9aaaa.txt%22%7D

このCSVは1つ目のフィールドにバケット名、二つ目のフィールドにURLエンコードしたJSON文字列が入っている。これをジョブの登録時に渡すと、Lambdaには以下のようなJSONがリクエストされる。

{
    "invocationSchemaVersion": "1.0",
    "invocationId": "YXNkbGZqYWRmaiBhc2RmdW9hZHNmZGpmaGFzbGtkaGZza2RmaAo",
    "job": {
        "id": "f3cc4f60-61f6-4a2b-8a21-d07600c373ce"
    },
    "tasks": [
        {
            "taskId": "dGFza2lkZ29lc2hlcmUK",
            "s3Key": "%7B%22s3Key%22%3A%221aaaa.txt%22%2C%22newKey%22%3A%22new-1aaaa.txt%22%7D",
            "s3VersionId": "1",
            "s3BucketArn": "arn:aws:s3:us-east-1:0123456788:awsexamplebucket1"
        }
    ]
}

JSON内のs3KeyにはCSVファイルの2つ目のフィールドのURLエンコードされたJSON文字列が入っているため、これをデコードすることで外から任意の値を渡すことができる。実際のコードは以下の通り(このコードではオブジェクトに対して実際に何か操作をしているわけではない点には注意)。

console.log('Loading function');

const _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
exports.handler = async (event, context) => {

    var date = new Date();
    console.log('invoke: ' + date.toISOString());

    const task = event.tasks[0];
    const payload = JSON.parse(decodeURIComponent(task.s3Key));
    await _sleep(3000);

    const message = payload.newKey;

    return {
        invocationSchemaVersion: event.invocationSchemaVersion,
        treatMissingKeysAs: 'PermanentFailure',
        invocationId: event.invocationId,
        results: [{
            taskId: task.taskId,
            resultCode: 'Succeeded',
            resultString: message
        }]
    };
};

使い所が難しいが、例えばオブジェクトを全然違う名前にリネームしたい場合に、元のkeyと新しいkeyを渡してリネームする、みたいな場合に使うと良いと思う(実際そういう場面で使った)。リネームに近いけど、オブジェクトを整理するために、移動先のディレクトリを指定する、みたいな使い方もできると思う。

余談

ここまで試していて気づいたのだけど、実はS3 バッチオペレーションでLambdaを使うような場合、(このエントリを書いている時点では)実際にオブジェクトが存在しなくてもよく、マニフェストファイルさえ作れたらよい。つまりS3オブジェクトとは全く関係のない、任意のJSONをURLエンコードしてマニフェストファイルを作ってバッチオペレーションのジョブを登録するだけで大量にLambdaを実行させまくることができる。どういう場面で使えるかは全然わからないけど・・・

Lambdaの同時実行数

実際にLambdaを使うようなジョブを実行する場合、Lambdaの同時実行数には注意が必要。Lambdaからレスポンスが返って来たら次のを実行するという感じではなく、レスポンスが返る前に次から次にLambdaを実行していくため、油断していたらLambdaの同時実行数の制限に引っかかってしまう、ということも起こりそう。特にS3 バッチオペレーション以外でもLambdaを普通に使っているようなサービスの場合、通常のサービスに影響しうるので注意したほうが良いと思う。ちなみに上のコードで実際に10000件のオブジェクトに対してバッチオペレーションを実行した場合、LambdaのConcurrent executionsがMAX250くらいまで行った。

バッチオペレーションの使用感

「大量のオブジェクト操作をできるだけ素早く終わらせたい」というのがこれを使う一番のモチベーションだったため、実際に使ったときの処理時間をざっくり書く。

  • 27000件のオブジェクトコピーはほぼ一瞬で終わる
  • 2000万件のオブジェクトコピーは7時間弱で終わる
  • 500万件のLambdaを利用したオブジェクトのリネームは2時間程度で終わる

ということで、何をやるにも結構早く終わらせることができる。大量のオブジェクトを操作する時に自分でバッチ処理を書いたりすると考えることが多い。並列実行の仕組みを用意しない場合、量が多ければ多いほど処理時間は増えていくだし、失敗した場合のリカバー方法も検討する必要がある。S3バッチオペレーションを利用する場合、そういう心配が完全には消えないものの、バッチ処理を書かなくてもよくなるため、だいぶ緩和されると思う。特に処理時間が短くなれば精神的にもだいぶ楽になる。レポートを出力しておけば、後からは失敗したものだけ再実行する、というのも簡単にできる。標準でできる処理は少ないが、Lambdaを使って様々操作できるのはいい。準備は若干面倒な部分もあるし、常用するのには向いていないが、いざというときには検討してみてもいいと思う。

まとめ

ということでS3バッチオペレーションを使う上で最初に知ってたらよかったみたいなポイントをまとめた。S3バッチオペレーション自体は大技って感じで使うタイミングはなかなか無いけど、大量のS3オブジェクトに何かしたい場合にはぜひ検討してみてほしい。

明日のはてなエンジニアアドベントカレンダーの担当はid:stefafafanさんです。よろしくおねがいします!