GolangのジェネリクスでWriterDB/ReaderDBの型を分ける

アプリケーションからDBを使うときに読み込み専用のノードを作ってトランザクションの不要なクエリや重いクエリを読み込み専用のノードに投げるようにすることがよくある。

アプリケーションの全体の情報を保持する構造体があるとして、Writerノード・Readerノードの両方へのコネクションプールを持つ場合、以下のような構造になると思う。

type App struct {
  WriterDB *sql.DB
  ReaderDB *sql.DB
}

ジェネリクスを使わないで*sql.DBの型を分ける

ReaderDBしか使わない関数に対してWriterDBを渡されたくないので、WriterDB/ReaderDBの型を分けたい。 Embeddingすると同じインターフェースを持ちながら異なる型を定義できる。

type Writer struct {
  *sql.DB
}
type Reader struct {
  *sql.DB
}
type App struct {
  WriterDB *Writer
  ReaderDB *Reader
}

// `*Writer`は受け取れない
func procForReader(db *Reader) {
    // ...
}

WriterDB/ReaderDBの両方で使うような関数がある場合は、*sql.DBと同じメソッドを持つインターフェースを定義して引数の型にすれば*Writer*Readerの両方を渡すことができる。

type DB interface {
  Begin() (*sql.Tx, error)
  BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
  // ...
}

// `*Writer`と`*Reader`を受け取れる
func procForDB(db DB) {
  // ...
}

ReaderDBについては、読み込み処理しか行わないのでExec()Begin()は禁止したい。

QueryXXX()しか持たないインターフェースを定義すれば一応、制限することはできる。 しかしそれだと、*Writer*Reader両方を渡すことができてしまう。

type Queryer interface {
  Query(query string, args ...any) (*sql.Rows, error)
}

// `*Writer`も受け取れてしまう
func procForReader(db Queryer) {
  // ...
}

ジェネリクスで*sql.DBの型を分ける

…という要件をここ数日悶々と考えていて、ジェネリクスを使えばできそうなのでライブラリを書いてみた。

github.com

まずマーカーにする型を定義する。 型の実態はstruct{}でもintでも何でもいい。

type WriterDB struct{}
type ReaderDB struct{}

type MyDB interface {
  WriterDB | ReaderDB
}

そのマーカーを使って*dbtyp.DB[T]を生成する。

  writer, _ := dbtyp.New2[WriterDB](sql.Open("sqlite", "file::memory:"))
  reader, _ := dbtyp.New2[ReaderDB](sql.Open("sqlite", "file::memory:"))

*dbtyp.DB[T]*sql.DBを埋め込んでいるので同じメソッドを持つ。

type DB[T any] struct {
  *sql.DB
}

*dbtyp.DB[WriterDB]*dbtyp.DB[ReaderDB]は型が違うので当然、代入はできない。

  writer = reader // COMPILE ERROR!

DBを使う関数では型パラメーターで使うDBを制限できる。

// `*dbtyp.DB[WriterDB]`は受け取れない
func procForRaeder(db *dbtyp.DB[ReaderDB]) {
  // ...
}

もし、WriterDBとReaderDBの両方を受け取りたかったら型制約MyDBを使う。

// `*dbtyp.DB[WriterDB]`と`*dbtyp.DB[ReaderDB]`を受け取れる
func procForRW[T MyDB](db *dbtyp.DB[T]) {
  // ...
}

さらに*dbtyp.DB[T]はメソッドを制限した*dbtyp.Queryer[T]を生成できる。

*dbtyp.Queryer[T]を使えば、ReaderDBに対してQueryXXX()以外のメソッドの呼び出しを禁止することができる。

func main() {
   reader, _ := dbtyp.New2[ReaderDB](sql.Open("sqlite", "file::memory:"))
   q := reader.Queryer()
   procReader(q)
}

// `*dbtyp.Queryer[WriterDB]`は受け取れない
func procReader(q *dbtyp.Queryer[ReaderDB]) {
   q.Query("select 1") // QueryXXX()しか呼び出せない
}

既存の*sql.DB*sql.Txとの相互運用も踏まえて、双方に互換性のあるインターフェースも用意してみた。

iface package - github.com/winebarrel/dbtyp/iface - Go Packages

type DB interface {
    Begin() (*sql.Tx, error)
    BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
    Close() error
    Conn(ctx context.Context) (*sql.Conn, error)
    Driver() driver.Driver
    Exec(query string, args ...any) (sql.Result, error)
    ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
    Ping() error
    PingContext(ctx context.Context) error
    Prepare(query string) (*sql.Stmt, error)
    PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
    Query(query string, args ...any) (*sql.Rows, error)
    QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
    QueryRow(query string, args ...any) *sql.Row
    QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
    SetConnMaxIdleTime(d time.Duration)
    SetConnMaxLifetime(d time.Duration)
    SetMaxIdleConns(n int)
    SetMaxOpenConns(n int)
    Stats() sql.DBStats
}

GolangでそこそこのJSONパーサを楽に自作する

先日、json2goというツールを作ったが、構造体のフィールドの順番がJSONと同じにならないのがいやで、なんとかならないかと調べてみた。 ObjectのanyへのUnmarshalがmap[string]anyに決め打ちされているのが原因で、ルートのObjectの型をなんとかすることはできても不定型なObjectの末端の子要素まで型を変えるのは難しそうだった。

そうなるとJSONのパーサを書くしかなさそうで「runeでデータを取り出さないと」「lexer書くのめんどくさい」「Unicodeエスケープシーケンスどうしよう」などと考えていたが、json.DecoderToken()というメソッドがあってJSONトークンを順次返してくれるので、これをlexerとしてパーサにトークンを渡せば、そこそこの品質のJSONパーサが楽にかけそうだったので 書いてみた。

github.com

パーサのライブラリには participle を使っている。

パーサ部分だけ取り出すとこんな感じ。

// lexer.go
package parser

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"

    "github.com/alecthomas/participle/v2/lexer"
)

const (
    TokenTypeDelim  lexer.TokenType = iota // '[',']','{','}'
    TokenTypeFalse                         // false
    TokenTypeNull                          // null
    TokenTypeTrue                          // true
    TokenTypeNumber                        // number
    TokenTypeString                        // string
)

var jsonSymbols = map[string]lexer.TokenType{
    "[":      TokenTypeDelim,
    "]":      TokenTypeDelim,
    "{":      TokenTypeDelim,
    "}":      TokenTypeDelim,
    "false":  TokenTypeFalse,
    "null":   TokenTypeNull,
    "true":   TokenTypeTrue,
    "number": TokenTypeNumber,
    "string": TokenTypeString,
}

type JsonDefinition struct {
}

func (l *JsonDefinition) Symbols() map[string]lexer.TokenType {
    return jsonSymbols
}

func (l *JsonDefinition) Lex(filename string, r io.Reader) (lexer.Lexer, error) {
    buf := &bytes.Buffer{}
    decoder := json.NewDecoder(io.TeeReader(r, buf))
    decoder.UseNumber()

    lex := &JsonLexer{
        decoder: decoder,
        buf:     buf,
        pos: lexer.Position{
            Filename: filename,
            Line:     1,
            Column:   1,
        },
    }

    return lex, nil
}

type JsonLexer struct {
    decoder *json.Decoder
    buf     *bytes.Buffer
    pos     lexer.Position
}

func (l *JsonLexer) Next() (lexer.Token, error) {
    startOffset := l.decoder.InputOffset()
    rawTok, err := l.decoder.Token()
    span := make([]byte, l.decoder.InputOffset()-startOffset)
    tok := lexer.Token{}

    if _, err := l.buf.Read(span); err != nil {
        return tok, err
    }

    tok.Pos = l.pos
    l.pos.Advance(string(span))

    if err == io.EOF {
        tok.Type = lexer.EOF
        return tok, nil
    } else if err != nil {
        return tok, fmt.Errorf("%d:%d: %w", tok.Pos.Line, tok.Pos.Column, err)
    }

    switch v := rawTok.(type) {
    case json.Delim:
        tok.Type = TokenTypeDelim
        tok.Value = v.String()
    case bool:
        if v {
            tok.Type = TokenTypeTrue
            tok.Value = "true"
        } else {
            tok.Type = TokenTypeFalse
            tok.Value = "false"
        }
    case nil:
        tok.Type = TokenTypeNull
        tok.Value = "null"
    case json.Number:
        tok.Type = TokenTypeNumber
        tok.Value = v.String()
    case string:
        tok.Type = TokenTypeString
        tok.Value = v
    }

    return tok, nil
}
// parser.go
package parser

import "github.com/alecthomas/participle/v2"

var (
    jsonParser = participle.MustBuild[JsonValue](
        participle.Lexer(&JsonDefinition{}),
    )
)

type JsonValue struct {
    False  *string     `parser:"@false |"`
    Null   *string     `parser:"@null |"`
    True   *string     `parser:"@true |"`
    Object *JsonObject `parser:"@@ |"`
    Array  *JsonArray  `parser:"@@ |"`
    Number *string     `parser:"@number |"`
    String *string     `parser:"@string"`
}

type JsonObject struct {
    Members []*JsonObjectMember `parser:"'{' @@* '}'"`
}

type JsonObjectMember struct {
    Key   string     `parser:"@string"`
    Value *JsonValue `parser:"@@"`
}

type JsonArray struct {
    Elements []*JsonValue `parser:"'[' @@* ']'"`
}

func ParseJSON(filename string, src []byte) (*JsonValue, error) {
    v, err := jsonParser.ParseBytes(filename, src)

    if err != nil {
        return nil, err
    }
    return v, nil
}

参考: Go言語のorderedmapパッケージを改善した - プログラムモグモグ

追記

パーサだけ別ライブラリに切り出した。

github.com

json2goの作成といくつかの学び

元旦の手隙な時間にjson2goというJSONをGoの構造体に変換するツールを書いた。

github.com

$ echo '{"foo":"bar","zoo":[1,2,3],"baz":[{"hoge":10},{"fuga":20}]}' | json2go
struct {
    Baz []struct {
        Fuga int `json:"fuga"`
        Hoge int `json:"hoge"`
    } `json:"baz"`
    Foo string `json:"foo"`
    Zoo []int  `json:"zoo"`
}

オンラインで同様のサービスを提供するサイトはすでにいくつかあるが、業務のコードをWebサイトのフォームに貼り付けたくなかったのでCLIを作成した。ただ、よくよく見たら既存のCLIもそれなりにあった。

一応、特徴としては

  • オブジェクト・配列だけでなくプリミティブ型のルート値も変換できる
  • 無名の構造体として出力される
  • 数値は.があればfloat64、なければint
  • 複数の型が混じらない配列を[]anyにしない(e.g., [1,2,3][]int
  • オブジェクトの配列を和集合の配列にする
  • 数字始まりや記号のキーも変換する
  • 変換結果をさらにコンパイルしてjson.Unmarshal()するテストをしている
  • JSONの定義通りの順番で変換する

…といったところ。

テストケースを読めば仕様がわかると思う。

いくつかの学び

最初、JSONをany型にUnmarshalしたオブジェクトを再帰的にたどっていけば簡単に作れるだろうと思っていた。 実際、その通りの実装になっているがコーナーケースや仕様決めが必要な箇所が細々とあって、小さなツールの割にそれなりに考えて実装することになった。

※仕様についてはJSON-to-Goの振る舞いを踏襲している

  • numberを一律float64にしたくない
    • json.Numberという型があって使う側でfloat64 or int64を決められる。さらに元の文字列も保持している
    • func (*Decoder) UseNumberを呼ぶと、map[string]anyへのUnmarshalでもnumberをjson.Numberに変換してくれる
  • [1, 2, 3]という配列を[]anyにしたくない
    • →すべての型をなめてから型を決定
  • [{"foo":1},{"bar":2}]を和集合に変換
    • →最初、型が完全に一致しない場合は[]anyにしていたが、キーが省略されるパターンがありそうなので []struct{ Foo int ; Bar int }になるようにした
    • 和集合を作る際にメンバーの型が異なっていてもany[]anyに丸められるようにした
  • [[1],["str"]][][]anyにしない
    • →頑張ったらできそうな気もしたが[]int[]stringは違う型なので違和感を拭えなかった
  • map[string]anyでordered mapを使う手段を見つけられなかったので、あきらめてソートするようにした
  • 数値始まりや記号のキー
    • →構造体のフィールドしてvalidな名前に変換
  • テストで変換結果をコンパイルすることでGoのコードとしてvalidなことを保証している
  • Goコードのフォーマットにはformat.Sourceを利用

alecthomas/kongでユーザー定義型(Custom decoder)を使う

github.com

For more fine-grained control, if a field implements the MapperValue interface it will be used to decode arguments into the field.

package main

import (
    "fmt"
    "os"

    "github.com/alecthomas/kong"
)

type Foo struct {
    Value string
}

func (foo *Foo) Decode(ctx *kong.DecodeContext) error {
    var valueStr string

    // 第一引数はユーザー向けの補助情報のとのこと
    // see https://pkg.go.dev/github.com/alecthomas/kong#Scanner.PopValueInto
    // > "context" is used to assist the user if the value can not be popped, eg. "expected <context> value but got <type>"
    err := ctx.Scan.PopValueInto("foo", &valueStr)

    if err != nil {
        return err
    }

    foo.Value = valueStr

    return nil
}

func main() {
    var cli struct {
        Foo []Foo `short:"f"`
    }

    parser := kong.Must(&cli)
    _, err := parser.Parse(os.Args[1:])
    parser.FatalIfErrorf(err)

    fmt.Printf("%+v\n", cli)
}
$ go run main.go -f hello
{Foo:[{Value:hello}]}

GolangでDNS権威サーバを書いてfly.ioで動かす

任意のプラベートIPを返すような動的なDNS権威サーバが欲しくなったので調べたところ github.com/miekg/dns というライブラリでサクッとかけそうだったので書いてみた。

net/http を模したライブラリで、DNSサーバを簡単に書くことができる。

github.com

package main

import (
    "log"
    "net"

    "github.com/miekg/dns"
)

func main() {
    dns.HandleFunc("example.com", func(w dns.ResponseWriter, r *dns.Msg) {
        msg := &dns.Msg{}
        msg.SetReply(r)
        msg.Authoritative = true

        for _, q := range msg.Question {
            if q.Name == "hello.example.com." && q.Qtype == dns.TypeA {
                rr := &dns.A{
                    Hdr: dns.RR_Header{Name: q.Name, Rrtype: q.Qtype, Class: q.Qclass, Ttl: 300},
                    A:   net.ParseIP("127.0.0.1"),
                }
                msg.Answer = append(msg.Answer, rr)
            }
        }

        w.WriteMsg(msg)
    })

    s := &dns.Server{Addr: ":53", Net: "udp"}
    err := s.ListenAndServe()

    if err != nil {
        log.Fatal(err)
    }
}
$ dig @127.0.0.1 +short hello.example.com
127.0.0.1

ゾーンファイルのパースもできる。

func main() {
    buf := strings.NewReader(`
www.example.com.  300 IN A 127.0.0.1
www2.example.com. 300 IN A 127.0.0.2
`)
    z := dns.NewZoneParser(buf, "", "")

    for rr, ok := z.Next(); ok; rr, ok = z.Next() {
        fmt.Println(rr.String())
    }

    fmt.Println(z.Err())
}
$ go run main.go
www.example.com.    300    IN  A   127.0.0.1
www2.example.com.   300    IN  A   127.0.0.2
<nil>

作ったDNSサーバがこちら。

github.com

$ kodama example.com &
[1] 25270
$ dig @127.0.0.1 +short 127-0-0-1.example.com
127.0.0.1
$ dig @127.0.0.1 +short web-192-168-10-1.example.com
192.168.10.1

fly.ioでDNSサーバを動かす

作ったDNSサーバをどこかで動かしたい…と思っていたらなんと fly.io がUDP/TCPに対応していた。

fly.io

IPv4をアロケートして、以下のような fly.toml でアプリをデプロイしたら普通に動いた。

app = 'kodama'
primary_region = 'nrt'

[build]
  image = 'ghcr.io/winebarrel/kodama:v1.0.0'

[env]
  KODAMA_DOMAIN = 'foo.example.com'
  KODAMA_ZONE_DATA = """
; ...
bar.foo.example.com. 3600 IN NS xxx.xxx.xxx.xxx
"""

[[services]]
  protocol = 'udp'
  internal_port = 53

  [[services.ports]]
    port = 53

[[services]]
  protocol = 'tcp'
  internal_port = 53

  [[services.ports]]
    port = 53

[[vm]]
  memory = '1gb'
  cpu_kind = 'shared'
  cpus = 1

おまけ: Google Public DNSの挙動

せっかくなので動かしたDNSサーバでLet's Encryptの証明書を取得しようとしてみた。 以下のようなDNSチャレンジ用のTXTレコードを設定してcertbotを動かしたが、なぜかチャレンジに失敗する。

_acme-challenge.foo.example.com. 900 IN TXT xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

手元のdigでは値が返るのになぜ…と思ってログを仕込んでみたら、@8.8.8.8 @8.8.4.4 のリゾルバからは大文字小文字がランダムになったホスト名が問い合わせされてきた。

2025-12-XXTXX:XX:XXZ app[...] nrt [info]2025-12-XXTXX:XX:XX DEBUG NOT FOUND question=";_AcMe-chALlenGE.foo.eXamPle.com.\tIN\t A"

それで大文字の問い合わせに対応していないバグに気がついたが、なんでそうなっているのが調べたところセキュリティ対策とのこと。

developers.google.com

キャッシュポイズニングで不正なパケットが返されたときに、問い合わせのホスト名と応答のホスト名が違っていれば、キャッシュが汚染されたことがわかる、ということらしい。

ちなみに 1.1.1.1 はそのような挙動にはなっていなかった。

参考リンク

Pull Requestへのコメントでlambroll deployを実行する

AtlantisのようにPull Requestが作成されたタイミングでlambroll deploy --dry-runが実行され、/deployとコメントすることでlambroll deployが実行されるGitHub Actionsのワークフローを作ってみた。

デモ

  • デプロイされたら自動でマージされる
    • エラーになったらマージされない
  • ボットのコメントは最新のもの以外は自動で最小化される

実装

ファイル構成

/
├── .github/
│   └── workflows/
│       ├── deploy.yml
│       ├── dry-run.yml
│       └── lambroll.yml
├── .gitignore
├── .lambdaignore
├── function.jsonnet
├── index.mjs
└── option.jsonnet

deploy.yml

name: Deploy

on:
  issue_comment:
    types: [created]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  deploy:
    uses: ./.github/workflows/lambroll.yml
    if: startsWith(github.event.comment.body, '/deploy')
    secrets: inherit
    with:
      deploy: true
      pr_num: ${{ github.event.issue.number }}
  reaction:
    runs-on: ubuntu-latest
    if: startsWith(github.event.comment.body, '/deploy')
    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - name: Reaction
        run: |
          gh api --method=POST -H 'Accept: application/vnd.github+json' -H 'X-GitHub-Api-Version: 2022-11-28' \
            /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \
            -f 'content=+1'

dry-run.yml

name: Deploy (dry-run)

on:
  pull_request:
    branches: [main]
    paths:
      - function.jsonnet
      - index.mjs
      - option.jsonnet
    types: [opened, synchronize]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  deploy:
    uses: ./.github/workflows/lambroll.yml
    secrets: inherit
    with:
      deploy: false
      pr_num: ${{ github.event.number }}

lambroll.yml

name: lambroll

on:
  workflow_call:
    inputs:
      deploy:
        type: boolean
        required: true
      pr_num:
        type: number
        required: true
      checkout_ref:
        type: string
        default: ""

permissions:
  id-token: write
  contents: write
  issues: write
  pull-requests: write

jobs:
  lambroll:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ inputs.checkout_ref }}
      - uses: winebarrel/[email protected]
      - uses: aws-actions/configure-aws-credentials@v5
        with:
          aws-region: ap-northeast-1
          role-to-assume: arn:aws:iam::123456789012:role/lambroll-deploy
      - uses: fujiwara/lambroll@v1
        with:
          version: v1.4.1
      - name: Deploy
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          LAMBROLL_OPTION: option.jsonnet
          DEPLOY: ${{ inputs.deploy }}
          OPTS: ${{ !inputs.deploy && '--dry-run' || '' }}
          PR_NUM: ${{ inputs.pr_num }}
          LASTCMT_KEY: lambroll-deploy-${{ inputs.deploy }}
        run: |
          set -exo pipefail
          MERGEABLE=$(gh pr view $PR_NUM --json mergeable -q '.mergeable')
          if $DEPLOY && [ "$MERGEABLE" != "MERGEABLE" ]; then
            echo ':red_circle: Pull request is not mergeable.' | lastcmt $PR_NUM
            exit 1
          fi

          set +e
          lambroll deploy --no-color $OPTS 2>&1 | tee deploy.log
          RET=$?
          set -e

          ICON=$([ $RET -eq 0 ] && echo -n ':green_circle:' || echo -n ':red_circle:')
          echo "## $ICON lambroll deploy $OPTS" > comment.txt
          echo '```'     >> comment.txt
          cat deploy.log >> comment.txt
          echo '```'     >> comment.txt
          if $DEPLOY; then
            echo ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} >> comment.txt
          fi
          lastcmt $PR_NUM comment.txt

          if $DEPLOY && [ $RET -eq 0 ]; then
            gh pr merge $PR_NUM --merge --delete-branch
          fi

          exit $RET

IAM

resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"
  client_id_list = [
    "sts.amazonaws.com"
  ]
}

resource "aws_iam_role" "lambroll_deploy" {
  name = "lambroll-deploy"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = aws_iam_openid_connect_provider.github.arn
        }
        Action = [
          "sts:AssumeRoleWithWebIdentity",
          "sts:TagSession",
        ]
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "repo:foo/bar:*"
          }
        }
      },
    ]
  })
}

resource "aws_iam_role_policy" "lambroll_deploy" {
  role = aws_iam_role.lambroll_deploy.id
  name = "lambroll-deploy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "iam:PassRole"
        Resource = "arn:aws:iam::1234567890:role/lambda-role"
      },
      {
        Effect = "Allow"
        Action = [
          "lambda:Get*",
          "lambda:List*",
          "lambda:CreateAlias",
          "lambda:DeleteFunction",
          "lambda:UpdateAlias",
          "lambda:UpdateFunctionCode",
          "lambda:UpdateFunctionConfiguration",
        ]
        Resource = "*"
      },
      {
        Effect   = "Allow"
        Action   = "s3:GetObject"
        Resource = "arn:aws:s3:::my-bucket/terraform.tfstate"
      },
    ]
  })
}

resource "aws_iam_role" "lambda_hello" {
  name = "lambda-hello"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
        Action = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_hello" {
  role = aws_iam_role.lambda_hello.name
  arn  = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

関連リンク

Fine Software Writings ( www.aoky.net ) にアクセスできなくなっている

11月くらいから Fine Software Writings にアクセスできなくなっている。

閉鎖したという話は聞いてないし、まったく騒がれていないので、自分の環境の問題かもと考えたが自宅でもモバイルでもアクセスできない。

Waybach Machineを見るかぎり9/14にはアクセスできていた模様。

web.archive.org

著者のかたも心配だし、あれだけの良質な文章がインターネットの海に消えるかもしれないことも心配。