zx で Makefile をリプレースする試みで得たあれこれ
これは、昨年後半くらいからちらほら聞くようになってきた google/zx を、TypeScript ナニもワカラナイ自分が使ってみて、随所に複製して置いてきている Makefile をいい感じにまとめてみようと試みたときにわかったりつまずいたりしたことを伝えて、こわくないから使ってみよう、というのを広めたい、という記事です。
小規模だしちょっとちゃんと動かないところがあるのですが、サンプル的にコードを公開してますのでそれをなぞりながら、知り得たあれこれを記述していきます。
移行元の Makefile でやっていること
Makefile といえば make をするものなのですが、自分の場合はやや煩雑なコマンド操作を簡素化するという使い方が中心です。
applications/run/Makefile のように、docker-compose
のラッパーが主でした。docker-compose up -d --build
を make 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
- 処理の実体は、src/modules にまとめて実装
- コマンドの土台が
という感じで、「雰囲気理解」しました。思ったのは、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
が起動するようになっているのですが、
-ca up
: composeAction が up なのでここに入ってきて、
-co u_d
: composeOptions が u_d なわけですが、
わざわざオプションをマッピングしています。これは、tj/commander のおそらく制限で、先頭が -
や --
で始まるオプションを直接渡せないことに気がついてこうしました。
複数のオプションの渡し方
複数のオプションをスペース区切りで渡しているのも工夫箇所です。たとえば↓
これを、以下のようにして分割して渡してます。
dockerOptions の処理を複雑にしたくないと考えたので、1つの文字列で一旦まとめて受け取るようにしてスペース区切りで分割するようにしました。
複数処理の順次実行/並列実行
順次実行パターン
npm-run-all
ライブラリの力を借りました。
Makefile で↓だったのを
zx-launcher では↓のように表現できます。
並列実行パターン
複数処理のコマンドで、並列実行で妥当そうな処理は並列にしました。
↑これが、こう↓ (折り畳んでるので展開してください)
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()
}
順次実行を並列実行にしたパターン
この記事でいちばん言いたいところです。
↑これが、こう↓ (折り畳んでるので展開してください)
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()
}
うまくいってないところや注意事項
exec
でコンテナに入れてない
docker-compose
たとえば "local:exec:cln": "npm run _runner_ -- -ca exec -tc client"
すると、動くには動くんですがコンテナ内であれこれしたいのにスッと出てきちゃって何もできませんでした。
追いかける元気がなくて、コンテナ内に入る用のコマンドを提示するように細工して撤退。。
ループを回している中で変数を使い回して配列に 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 かけると以降の環境変数が投入されない
これの process.env.IMG_TAG = pe.imgTag
ですが、最初は
(今はコメントアウトしてますが、)当初こんな感じでダイレクトに突っ込んでいて、そうしてるとその行以降の環境変数が「入ってなくて」、思うように後続処理が動かなかったです。
外側に出してあげて、渡すようにしたら万事成功しました。
今後の展望など
- エラーや ProcessOutput のハンドリングを特に考えてない(記事化のため「できる」を優先した)のでちゃんとしていきたい
- CI/CD で使っていけるように必要なコマンドの追加
- ecspresso とか AWS CLI とか
- 強力そうな zx の機能の利用
- retry とか、Markdown scripts とか
- に限らず...
- 散り散りの Makefile を集約する My zx-launcher の構築
備考
TypeScript でわからないことを検索したらだいたい↓に助けてもらえた。
-
' は表記のためにシングルクォートにしてますが、正しくは ` (バッククォート) です ↩︎
Discussion