Kubernetes: マニフェストファイルに記述されたコンテナイメージを安全に更新する

## TL;DR

`kubectl set image` は、クラスタ上のコンテナイメージを更新するだけでなく、`--local` フラグを使うことで、ローカルのマニフェストファイルの更新も行うことができます。

```
$ kubectl set image -f ./myapp-deployment.yaml myapp=myapp:2.0.0 --local -o yaml
```

## はじめに

開発したアプリケーションを Kubernetes クラスタ上にデプロイするためには、まずコンテナイメージをビルドしたのち、Deployment などのマニフェストファイルに記述されているコンテナイメージのタグを更新する必要があります。本番環境へのデプロイは頻度が少ないこともあるかもしれませんが、master ブランチでビルドしたイメージを開発環境にビルドした都度デプロイしたいことはあると思います。頻繁にデプロイするのに、その都度わざわざタグを手動で更新してはいられません。

マニフェストファイルに記述されたコンテナイメージを差し替える方法には、さまざまな選択肢があります。例えば Helm でもいいかもしれないし、sed などの Linux 古来の方法も取ることができます。コンテナイメージの更新のためだけに Helm を使うのは大袈裟過ぎるでしょう。かといって、sed はどうかというと、正規表現の記述を誤ればマニフェストファイルが壊れるかもしれませんし、そうでなくても壊れないことを保証できません。

ここでは、`kubectl set image --local` コマンドによる方法を提案します。

## kubectl set image とは

`kubectl set image` コマンドは、オブジェクトのコンテナイメージを更新します。`kubectl set` コマンドは、クラスタ上に作成されているオブジェクトの更新しか出来なさそうにみえますが、実は `--local` フラグを使うことでローカルのマニフェストファイルに対して更新を行ない、その結果を標準出力に出力することができます。

まず次のようなマニフェストファイルを用意します。

```:myapp-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: myapp:latest
        ports:
        - containerPort: 80
```

次に、`kubectl set image --local` コマンドを使い、このマニフェストファイルに含まれる myapp コンテナのタグを `2.0.0` に更新すると、次のように出力されます。

```

$ kubectl set image -f ./myapp-deployment.yaml myapp=myapp:2.0.0 --local -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  name: myapp
  namespace: default
spec:
  progressDeadlineSeconds: 600
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app: myapp
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: myapp
    spec:
      containers:
      - image: myapp:2.0.0
        imagePullPolicy: Always
        name: myapp
        ports:
        - containerPort: 80
          protocol: TCP
        resources: {}
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30
status: {}
```

myapp コンテナは指定したとおり `2.0.0` に更新されていることが見てわかると思いますが、なにやらマニフェストファイルが大きくなっています。これは、Deployment オブジェクトのデフォルティングが行われた結果が出力されているためです。例えば `spec.replicas` フィールドは値を指定していなかったので、デフォルトの `1` が設定されています。

これをみると、`kubectl scale` や HorizontalPodAutoscaler (HPA) などを利用してレプリカ数が変更された後に、改めてマニフェストを反映する (`kubectl apply`) と、レプリカ数が `1` に戻ってしまうように思われるかもしれません。ですが、ここではそれらによって更新されたレプリカ数は維持されたまま、期待したとおりにクラスタ上のオブジェクトが更新されます。なぜレプリカ数が更新されないのかをここでは詳細に説明しませんが、もし知りたければ [Kubernetes: kubectl apply の動作](https://qiita.com/tkusumi/items/0bf5417c865ef716b221) で詳しく解説されているため、そちらを参照してください。

また `metadata.namespace` フィールドは、デフォルトの Namespace である `default` が明示的に指定されています。この状態で他の Namespace にデプロイしようとすると、「オブジェクトに明示的に Namespace が指定されているから変更できない」と怒られてしまいます。そのため、この方法を取る場合、Namespace の指定は、デプロイ時ではなく `kubectl set image` コマンドの実行時に指定するようにしてください。

```
$ kubectl set image -f ./myapp-deployment.yaml myapp=myapp:2.0.0 --local -o yaml --namespace production
```

さいごに、`kubectl set image --local` コマンドでコンテナイメージを更新したマニフェストファイルをそのままデプロイするには、次のように標準入力でマニフェストを受け取るようにします。

```
$ kubectl set image -f ./myapp-deployment.yaml myapp=myapp:2.0.0 --local -o yaml | kubectl apply -f -
```

## 注意すること

前述したとおり、この方法でマニフェストファイルを更新するとデフォルティングが行われます。もしこのデフォルティングされたマニフェストファイルを `kubectl apply` で反映しているクラスタに対して、デフォルティングされていないマニフェストファイルを反映してしまうと、HPA で更新されたレプリカ数が `1` に戻ってしまうような意図しない動作が起きてしまいます。

そのため、この方法を利用する場合は、常にデフォルティングされたマニフェストファイルを利用するようにしなければなりません。十分に注意してください。

## まとめ

ここでは、`kubectl set image` コマンドの `--local` フラグを使うことで、ローカルのマニフェストファイルのコンテナイメージを安全に更新できることを紹介しました。CI でコンテナイメージがビルドされるたびにそのイメージのダイジェスト値（`myapp@sha256:c34ce3c1fcc0...`）を使い、開発環境は最新を維持しながら、本番環境の更新は手動で行うなども考えられます。地味ですが、利用できるシーンは多いと思うので、ぜひ活用してみてください。