🎏

zx で Makefile をリプレースする試みで得たあれこれ

2022/08/07に公開

これは、昨年後半くらいからちらほら聞くようになってきた google/zx を、TypeScript ナニもワカラナイ自分が使ってみて、随所に複製して置いてきている Makefile をいい感じにまとめてみようと試みたときにわかったりつまずいたりしたことを伝えて、こわくないから使ってみよう、というのを広めたい、という記事です。
https://github.com/google/zx

小規模だしちょっとちゃんと動かないところがあるのですが、サンプル的にコードを公開してますのでそれをなぞりながら、知り得たあれこれを記述していきます。
https://github.com/sogaoh/sso-redirect-examples/tree/develop/zx-launcher

移行元の Makefile でやっていること

Makefile といえば make をするものなのですが、自分の場合はやや煩雑なコマンド操作を簡素化するという使い方が中心です。
applications/run/Makefile のように、docker-compose のラッパーが主でした。docker-compose up -d --buildmake upb で済ませる、とか。  
慣れてくると、applications/Makefile にも置いて、AWS
Fargate や GCP Cloud Run で利用できる形に docker build する場合にも使っていきました。渡すパラメータが増えるたびに打ち込むのが面倒になるので、make build みたいにやるだけで済むように。

commander・TypeScript の採用

正直に書きますが、これは「先生のお導き」です。ほんとに JavaScript や Node.js ナニもワカラナイマンだったので、始め方に注目して盗ませてもらいました。
自分が身につけた作っていく流れとしては、なんとなくですが以下です。

  • npm init で package.json を作り
  • zx と tj/commander をとりあえず突っ込み
  • package.json の "script" を追加していく

ここで package.json に関して補足しますと、

  • "devDependencies" の "eslint" "prettier" "ts-node" "typescript" は今もなぜここにあるのかちゃんと理解してなくて、先生の作ってくれたお手本なのでそのままつかってます。(良くないけど、まあいい、ということにしてます)
  • 先生が書いているところを見て、
    • コマンドの土台が "_runner_": "node --loader ts-node/esm src/tasks/main.ts" なんだな、と雰囲気でわかり、
    • この main.ts には環境変数の決定やどういう処理(Action)をするのかを Option で渡すように手短に実装して、
      • このへんは src/etc にまとめておいて、
    • たとえば、 "docker:ps": "npm run _runner_ -- -da ps" みたいな感じで打ち込みやすい別名にしてあげる
      • 処理の実体は、src/modules にまとめて実装
        • parallel.ts はどこかから拾ったものらしい w

という感じで、「雰囲気理解」しました。思ったのは、tj/commander たいへん便利ですね。

CLI引数を渡す際の工夫

基本は配列に投入して await $'${array}'.nothrow() [1] のような感じでOKですが、自分か気づいた工夫ポイントを書きます。

--- で始まるオプションを直接渡せない

"local:up": "npm run _runner_ -- -ca up -co u_d" を例に説明します。

npm run local:up を実行すると applications/run ディレクトリで docker-compose [-f docker-compose.yml] up -d が起動するようになっているのですが、
https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/tasks/main.ts#L34-L36

-ca up : composeAction が up なのでここに入ってきて、

https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/modules/compose.ts#L17-L33

-co u_d : composeOptions が u_d なわけですが、

https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/etc/config.ts#L49-L51

わざわざオプションをマッピングしています。これは、tj/commander のおそらく制限で、先頭が --- で始まるオプションを直接渡せないことに気がついてこうしました。

複数のオプションの渡し方

複数のオプションをスペース区切りで渡しているのも工夫箇所です。たとえば↓

https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/etc/config.ts#L80

これを、以下のようにして分割して渡してます。

https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/modules/docker.ts#L156
https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/modules/docker.ts#L163-L165
https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/modules/docker.ts#L172-L174

dockerOptions の処理を複雑にしたくないと考えたので、1つの文字列で一旦まとめて受け取るようにしてスペース区切りで分割するようにしました。

複数処理の順次実行/並列実行

順次実行パターン

https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/package.json#L34

npm-run-all ライブラリの力を借りました。

Makefile で↓だったのを
https://github.com/sogaoh/sso-redirect-examples/blob/develop/applications/run/Makefile#L26-L28

zx-launcher では↓のように表現できます。
https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/package.json#L10

並列実行パターン

複数処理のコマンドで、並列実行で妥当そうな処理は並列にしました。
https://github.com/sogaoh/sso-redirect-examples/blob/develop/applications/run/Makefile#L7-L9

↑これが、こう↓ (折り畳んでるので展開してください)

  chmod = async () => {
    const chmodStorage = [
      'docker-compose',
      '-f',
      `${process.env.WORK_DIR}/docker-compose.yml`,
      'exec',
      'client',
      'chmod',
      '-R',
      'a+w',
      'storage'
    ]
    const chmodBootstrapCache = [
      'docker-compose',
      '-f',
      `${process.env.WORK_DIR}/docker-compose.yml`,
      'exec',
      'client',
      'chmod',
      '-R',
      'a+w',
      'bootstrap/cache'
    ]

    const p8s = Parallels<void>()
    p8s.add(
      new Promise(async _ => {
        await $`${chmodStorage}`.nothrow()
      }),
    )
    p8s.add(
      new Promise(async _ => {
        await $`${chmodBootstrapCache}`.nothrow()
      }),
    )
    p8s.all()
  }

https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/modules/compose.ts#L58-L94

順次実行を並列実行にしたパターン

この記事でいちばん言いたいところです。
https://github.com/sogaoh/sso-redirect-examples/blob/develop/applications/Makefile#L15-L27

↑これが、こう↓ (折り畳んでるので展開してください)

interface DockerIF {
  build: (prop: DockerProps) => string[]
  tag: (prop: DockerProps) => string[]
}

const nginx: DockerIF = {
  build: prop => {
    const dockerOptionString = DockerOptionsMap.get(prop.opts.dockerOptions)
    const nginxBuild = [
      //'DOCKER_BUILDKIT=1',
      'docker',
      'build',
    ]
    dockerOptionString?.split(' ').forEach(opt => {
      nginxBuild.push(opt)
    })
    nginxBuild.push(
      '-t',
      `${ContainerNameMap.get(Container.WEB)}:${process.env.IMG_TAG}`,
      '-f',
      `${prop.contextBaseDir}/ship/docker/nginx-node/Dockerfile`,
      `${prop.contextBaseDir}`
    )
    return nginxBuild
  },

  tag: prop => {
    const nginxTag = [
      //'DOCKER_BUILDKIT=1',
      'docker',
      'tag',
    ]
    nginxTag.push(
      `${ContainerNameMap.get(Container.WEB)}:${process.env.IMG_TAG}`,
      `${process.env.AWS_ACCOUNT_ID}.dkr.ecr.${process.env.AWS_REGION}.amazonaws.com/${ContainerNameMap.get(Container.WEB)}:${process.env.IMG_TAG}`,
    )
    return nginxTag
  }
}

const laravel: DockerIF = {
  build: prop => {
    const dockerOptionString = DockerOptionsMap.get(prop.opts.dockerOptions)
    const laravelBuild = [
      //'DOCKER_BUILDKIT=1',
      'docker',
      'build',
    ]
    dockerOptionString?.split(' ').forEach(opt => {
      laravelBuild.push(opt)
    })
    laravelBuild.push(
      '-t',
      `${ContainerNameMap.get(Container.CLN)}:${process.env.IMG_TAG}`,
      '-f',
      `${prop.contextBaseDir}/ship/docker/php/Dockerfile`,
      `${prop.contextBaseDir}`
    )
    const buildArgs = `${process.env.BLDARG_NODEV_OPT}`
    buildArgs.split(' ').forEach(buildArg => {
      laravelBuild.push(buildArg)
    })
    return laravelBuild
  },

  tag: prop => {
    const laravelTag = [
      //'DOCKER_BUILDKIT=1',
      'docker',
      'tag',
    ]
    laravelTag.push(
      `${ContainerNameMap.get(Container.CLN)}:${process.env.IMG_TAG}`,
      `${process.env.AWS_ACCOUNT_ID}.dkr.ecr.${process.env.AWS_REGION}.amazonaws.com/${ContainerNameMap.get(Container.CLN)}:${process.env.IMG_TAG}`,
    )

    return laravelTag
  }
}

const containers = new Map(Object.entries({ nginx, laravel }))

export class Docker {
  private props: DockerProps
  constructor(props: DockerProps) {
    this.props = props
  }

  async parallelBuild(): Promise<void> {
    const containerIdentifiers = [
      ContainerIdentifierMap.get(Container.WEB) ?? '',
      ContainerIdentifierMap.get(Container.CLN) ?? ''
    ]
    const p8s = Parallels<void>()
    containerIdentifiers.forEach(async identifier => {
      p8s.add(
        new Promise(async _ => {
          await $`${containers.get(identifier)?.build({
            opts: this.props.opts,
            contextBaseDir: this.props.contextBaseDir
          })}`.nothrow()
        })
      )
    })
    p8s.all()
  }

  async parallelTag(): Promise<void> {
    const containerIdentifiers = [
      ContainerIdentifierMap.get(Container.WEB) ?? '',
      ContainerIdentifierMap.get(Container.CLN) ?? ''
    ]
    const p8s = Parallels<void>()
    containerIdentifiers.forEach(async identifier => {
      p8s.add(
        new Promise(async _ => {
          await $`${containers.get(identifier)?.tag({
            opts: this.props.opts,
            contextBaseDir: this.props.contextBaseDir
          })}`.nothrow()
        })
      )
    })
    p8s.all()
  }

https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/modules/docker.ts#L12-L136

うまくいってないところや注意事項

docker-compose exec でコンテナに入れてない

https://github.com/sogaoh/sso-redirect-examples/blob/develop/applications/run/Makefile#L30-L38

たとえば "local:exec:cln": "npm run _runner_ -- -ca exec -tc client" すると、動くには動くんですがコンテナ内であれこれしたいのにスッと出てきちゃって何もできませんでした。
追いかける元気がなくて、コンテナ内に入る用のコマンドを提示するように細工して撤退。。

https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/modules/compose.ts#L34-L40

ループを回している中で変数を使い回して配列に push すると使用時に全部同じ中身(最後に投入したもの)になっていた

何を言っているかよくわからないかもしれないしサンプルソースの中に実例がないのですが、↓のようなことをしてたら先に投入したのが一切現れてこなかった、ということです

NGケース
    const yamls: Map<string, string>[] = []
    const yamlMap = new Map<string, string>()
    serviceNames.forEach(serviceName => {
        yamlMap.set('serviceYml', serviceName.toLowerCase())
        switch (serviceName) {
        case 'backend':
            yamlMap.set('featureYml', 'api')
            break
        case 'frontend':
            yamlMap.set('featureYml', 'web')
            break
        default:
            break
        }
        yamls.push(yamlMap)
    })
OKケース
    const yamls: Map<string, string>[] = []
    const yamlMap = new Map<string, string>()
    serviceNames.forEach(serviceName => {
      yamlMap.set('serviceYml', serviceName.toLowerCase())
      switch (serviceName) {
        case 'backend':
          yamlMap.set('featureYml', 'api')
          break
        case 'frontend':
          yamlMap.set('featureYml', 'web')
          break
        default:
          break
      }
      const clonedYamlMap = deepCopy(yamlMap)   // <- これが対策
      yamls.push(clonedYamlMap)
    })

SetEnv の途中で await かけると以降の環境変数が投入されない

https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/etc/env.ts#L9-L17

これの process.env.IMG_TAG = pe.imgTag ですが、最初は

https://github.com/sogaoh/sso-redirect-examples/blob/develop/zx-launcher/src/tasks/main.ts#L16

(今はコメントアウトしてますが、)当初こんな感じでダイレクトに突っ込んでいて、そうしてるとその行以降の環境変数が「入ってなくて」、思うように後続処理が動かなかったです。
外側に出してあげて、渡すようにしたら万事成功しました。

今後の展望など

  • エラーや ProcessOutput のハンドリングを特に考えてない(記事化のため「できる」を優先した)のでちゃんとしていきたい
  • CI/CD で使っていけるように必要なコマンドの追加
  • 強力そうな zx の機能の利用
    • retry とか、Markdown scripts とか
    • に限らず...
  • 散り散りの Makefile を集約する My zx-launcher の構築

備考

TypeScript でわからないことを検索したらだいたい↓に助けてもらえた。
https://twitter.com/sogaoh/status/1552626786382802949

脚注
  1. ' は表記のためにシングルクォートにしてますが、正しくは ` (バッククォート) です ↩︎

Discussion