June 5, 2022

Kubernetes에서 AWS ECR 인증 정보를 자동 갱신하기

AWS ECR이 불편한 점

ecr

도커 이미지를 저장하기 위해 여러 레지스트리를 사용중이다. docker hub 도 사용하지만 private저장소는 일반 유저에게 한 개만 주므로 편안히 사용하기가 쉽지 않다. 그래서 최근에는 직접 구축해서 사용할까 했지만, 구축하는 것도 일이고 저장소에 문제가 생기면 그건 또 그것대로 골치아프므로 그냥 AWS ECR을 더 적극적으로 사용하기로 했다.

그동안 ECR을 사용하지 않았던 이유는 별 건 아니고, ECR의 인증이 꽤 번거롭기 때문이다. AWS CLI와 억세스/시크릿 키를 사용하여 도커 저장소의 인증 정보를 가져와야 하고, 이를 통해 docker에 로그인해야 한다. 더구나 이 인증 정보는 12시간 후에는 더이상 사용할 수 없게 되므로 이 작업을 매번 반복해야 하는 것이다. 이게 이럴리가 없다고 생각했는데 아무리 찾아도 안나오는게 어떻게 된건지 도무지 이해가 가지 않는다.

An authorization token’s permission scope matches that of the IAM principal used to retrieve the authentication token. An authentication token is used to access any Amazon ECR registry that your IAM principal has access to and is valid for 12 hours. To obtain an authorization token, you must use the GetAuthorizationToken API operation to retrieve a base64-encoded authorization token containing the username AWS and an encoded password. The AWS CLI get-login-password command simplifies this by retrieving and decoding the authorization token which you can then pipe into a docker login command to authenticate.

빌드시에는 이런 과정에 인증에 관련한 큰 문제가 없다. 개인적으로 사용중인, 새롭게 구축된 파이프라인은 tekton + argocd 기반인데 tekton에는 ECR 인증 처리를 해주는 태스크가 있어서 매 빌드 시에 이를 사용해서 갱신해 주면 되기 때문이다. 문제는 앱이 급작스럽게 죽었을 때다. 어플리케이션이 재시작을 해야 하므로 이미지를 다시 다운로드하는데, 이 때 레지스트리에 접근하지 못하면 어플리케이션이 다시 배포되지 못한다. 결함에서 장애로 등급이 올라가는 것이다. 따라서 이를 피하기 위해서는 ECR로의 인증 상태가 항상 유지되어야 한다.

ECR 인증 정보를 자동 갱신하기

인증 상태를 매번 유지하기 위해서는 인증된 키가 만료되기 전에 다시 인증을 수행하고 이를 Kubernetes의 Secret에 업데이트하면 된다. 만료는 12시간이므로 대략 6시간에 한 번씩 갱신하면, 어플리케이션이 자동 배포될 때 절대로 인증이 만료된 상태를 접할 일이 없을 것이다. 6시간에 한 번씩 갱신하기 위해 어떤 방법을 쓸까 하다가, Kubernetes의 CronJob 을 사용하기로 했다.

CronJob은 Job을 Cron에 맞추어 자동 실행하도록 하는 컨트롤러다. 이를 사용하면 리눅스에서 그냥 cron을 등록하듯 자동으로 팟이 실행되는데, 이제 이 팟은 kubernetes 안에서 실행되어 자신이 속해 있는 클러스터의 secret을 업데이트하도록 동작하게 될 것이다.

팟에는 Secret을 조작할 수 있는 권한이 있어야 한다. 팟의 권한은 그 팟이 사용하는 ServiceAccount에 의한다. ServiceAccount에 부여된 권한이 곧 팟이 할 수 있는 일이 된다. 그런 ServiceAccount에 직접 권한을 부여할 수는 없고, 어떤 권한과 ServiceAccount를 하나로 묶는 어떤 리소스를 통해 이를 부여할 수 있는데, 권한은 Role이 되고 묶는 역할을 하는 리소스는 RoleBinding이 된다. 따라서 RoleServiceAccount를 만들고 이를 RoleBinding을 통해 연결하면 된다.

rbachttps://www.cncf.io/blog/2020/08/28/kubernetes-rbac-101-authorization/

AWS ECR의 인증 정보는 AWS CLIecr get-login-password로 얻는다.

$ aws ecr get-login-password --region ap-northeast-2                                          
emdrcWhraUc5dzBCQndhZ2J6QnRl...YUtUc3FUQV9LRVkiLCJleHBpcmF0aW9uIjoxNjU0NDcwNDIxfQ==

여기서 나오는 출력을 쉘 파이프를 통해 docker login으로 보내면 docker에서도 사용할 수 있다. 이 cli를 실행할 때는 반드시 AWS의 ACCESS_KEY_IDSECRET_ACCESS_KEY가 필요하다. 이는 설정 파일로 cli에 제공할 수 있고 환경 변수로도 전달하는 것이 가능한데, 환경 변수를 제공한다면 kubernetes의 configmap이나 secret을 고려할 수 있다. 키 정보는 굉장히 민감한 정보이므로 여기서는 configmap이 아닌 secret으로 만들기로 했다.

따라서 필요한 리소스를 정리해 보면 다음과 같다.

  • cronjob: 주기적으로 ECR에 로그인하여 새로운 키 정보를 받는다.
  • serviceaccount, role, rolebinding: cronjob에서 만든 job이 스스로 secret을 업데이트할 수 있도록 권한을 부여한다.
  • secret: secret(type opaque)이며 AWS cli를 사용할 수 있도록 access/secret 정보를 저장한다.
  • imagepullsecret: secret(type dockerconfigjson)이며, 이를 imagePullSecret으로 사용한다.

image 사실 이미지는 그리지 않아도 됐는데 심심해서 한번 그려 봤다.

차트 작성하기

계획대로 그냥 각 리소스를 yaml로 직접 작성해서 배포해도 되는데 차트를 만들기로 했다. 최근에는 매우 귀찮더라도 차트를 매번 작성하는 편인데, 그냥 즉석에서 yaml을 통해 계속 이것저것 배포했더니 현황 유지가 잘 되지 않기 때문이다. 나중에 수습이 안된다. 그래서 억지로라도 차트로 만들어 두고 value를 관리하려고 한다. 더구나 고생해서 어쨌든 차트로 만들어 두면 argocd같은 곳에서도 편하게 쓸 수 있어서 여러 장점이 생긴다. 그 장점보다 차트를 만드는 수고가 더 드는것 같긴 하지만, 어쨌든 차트를 생성하기 위해서는 helm create를 통해 기본적인 템플릿을 만들 수 있다.

$ helm create ecr-refresher
Creating ecr-refresher

$ tree   
.
├── Chart.yaml
├── templates
│   ├── NOTES.txt
│   ├── _helpers.tpl
│   ├── deployment.yaml
│   ├── hpa.yaml
│   ├── ingress.yaml
│   ├── service.yaml
│   ├── serviceaccount.yaml
│   └── tests
│       └── test-connection.yaml
└── values.yaml

4 directories, 10 files

원래 기본적으로 helm create를 사용하지는 않는데, 이게 왜냐하면 간단한 배포를 하기에는 그 구조가 너무나 복잡하기 때문이다. 단순히 몇 개의 값만 바꾸고 관리하려고 하는 것에는 매우 과한 편. 필요없는 파일을 지우고 몇 개의 파일을 수정하고 나서 다음과 같은 형태로 정리되었다.

$ tree
.
├── Chart.yaml
├── README.md
├── templates
│   ├── _helpers.tpl
│   ├── cronjob.yaml
│   ├── imagepullsecret.yaml
│   ├── role.yaml
│   ├── rolebinding.yaml
│   ├── secret.yaml
│   ├── serviceaccount.yaml
│   └── tests
└── values.yaml

values.yaml은 다음과 같은 정보를 담는다.

image:
  repository: amazon/aws-cli
  tag: 2.7.6
  pullPolicy: Always
  pullSecrets: []

cronJob:
  failedJobsHistoryLimit: 2
  successfulJobsHistoryLimit: 4
  period: "* */4 * * *"
  suspend: false

ecrRegistry:
  registry: YOUR_ECR_REGISTRY
  awsDefaultRegion: YOUR_ECR_REGION
  awsAccessKeyID: YOUR_ECR_ACCESS_KEY_ID
  awsSecretAccessKey: YOUR_ECR_SECRET_ACCESS_KEY

resources:
  limits:
    cpu: 50m
    memory: 16Mi
  requests:
    cpu: 100m
    memory: 32Mi
nodeSelector: {}
tolerations: []
affinity: {}

리소스를 간략히 정리해 보자. secret.yaml은 AWS CLI에 환경 변수로 AWS 관련 정보를 제공한다. 따라서 다음과 같은 data를 가진다.

apiVersion: v1
kind: Secret
type: Opaque
metadata:
  name: {{ include "ecr-refresher.secretName" . }}
data:
  AWS_ACCESS_KEY_ID: {{ .Values.ecrRegistry.awsAccessKeyID | b64enc }}
  AWS_SECRET_ACCESS_KEY: {{ .Values.ecrRegistry.awsSecretAccessKey | b64enc }}
  AWS_DEFAULT_REGION: {{ .Values.ecrRegistry.awsDefaultRegion | b64enc }}

imagepullsecret.yaml은 다른 컨트롤러에서 사용할 image secret이다. 최초 배포되었을 때는 어떤 데이터가 있을지 모르므로 그냥 빈 값을 주도록 했다. 추후 어플리케이션이 배포되고 나서 인증 과정을 거치면 이 데이터를 업데이트할 것이다.

apiVersion: v1
metadata:
  name: {{ include "ecr-refresher.imagePullSecretName" . }}
data:
  .dockerconfigjson: {{ "{}" | b64enc }}
kind: Secret
type: kubernetes.io/dockerconfigjson

rolebinding과 serviceaccount는 크게 중요한 건 없어서 굳이 쓰지 않아도 되겠고, role만 보면 다음과 같다.

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: {{ include "ecr-refresher.roleName" . }}
rules:
- apiGroups: [""] # "" indicates the core API group
  resources: ["secrets"]
  resourceNames: [{{ include "ecr-refresher.imagePullSecretName" . }}]
  verbs: ["patch"]

이 차트를 통해 어플리케이션이 정상적으로 배포되었다면 항상 imagePullSecret이 존재하므로 여기서 부여하는 권한은 리소스 타입 secrets중 특정한 이름을 가지는 것만 PATCH할 수 있도록 하였다. 따라서 리소스를 지우거나 타 리소스에 대한 조회 등은 불가능하다.

마지막으로 _helper.tpl 파일을 확인하자.

$ cat templates/_helpers.tpl 
{{/*
Expand the name of the chart.
*/}}
{{- define "ecr-refresher.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "ecr-refresher.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "ecr-refresher.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Common labels
*/}}
{{- define "ecr-refresher.labels" -}}
helm.sh/chart: {{ include "ecr-refresher.chart" . }}
{{ include "ecr-refresher.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels
*/}}
{{- define "ecr-refresher.selectorLabels" -}}
app.kubernetes.io/name: {{ include "ecr-refresher.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/*
Create the name of the service account to use
*/}}
{{- define "ecr-refresher.serviceAccountName" -}}
{{- printf "%s-%s" (include "ecr-refresher.fullname" .) "serviceaccount" }}
{{- end }}

{{- define "ecr-refresher.secretName" -}}
{{- printf "%s-%s" (include "ecr-refresher.fullname" .) "env-secret" }}
{{- end }}

{{- define "ecr-refresher.imagePullSecretName" -}}
{{- printf "%s-%s" (include "ecr-refresher.fullname" .) "credentials" }}
{{- end }}

{{- define "ecr-refresher.roleName" -}}
{{- printf "%s-%s" (include "ecr-refresher.fullname" .) "update-role" }}
{{- end }}

{{- define "ecr-refresher.roleBindingName" -}}
{{- printf "%s-%s" (include "ecr-refresher.fullname" .) "rolebinder" }}
{{- end }}

조금 줄여서 해도 되는데 일부러 전부 다 붙여넣었다. 내용 자체는 자동 생성된 내용을 약간 수정하고 몇 개의 내용을 추가하였다.

이 차트는 하나의 imagePullSecret을 업데이트하기 때문에 여러 시크릿을 업데이트하기 위해서는 차트를 다른 이름으로 여러번 배포해야 한다. 따라서 많은 리소스에 대해 이름이 잘 관리되어야 해서 특별히 define을 통해 여러 리소스의 이름을 관리하도록 하였다. 따라서 배포시 fullnameOverride를 통해서 완전히 다른 이름을 만들더라도 리소스의 이름이 일관적으로 배포된다.

Kubernetes REST API 호출하기

cronjob에서는 AWS CLI를 사용하여 인증 정보를 받아오기 때문에 amazon/aws-cli를 써야 한다. 그런데 여기서 Secret을 업데이트해야 하므로 kubectl이 필요한데, 최초에는 이를 그대로 다운로드해서 사용했으나 한 번 호출하고 끝인데다가 사람이 쓰는 것도 아니라서 굳이 이 큰 바이너리를 받아야 할까 싶어, 여기서 바로 curl을 통해 Kubernetes API를 호출하기로 했다. AWS CLI 이미지에는 base64라던가 curl은 이미 포함되어 있어서 특별히 뭔가를 준비할 필요 없이 그대로 써도 된다.

Pod 안에서 REST API 호출하기

Pod을 describe하면 다음과 같은 정보를 확인할 수 있다.

$ kubectl describe pod
...
...
Containers:
  task-00002:
	...
    State:          Terminated
      Reason:       Completed
      Exit Code:    0
      Started:      Thu, 02 Jun 2022 07:00:04 +0900
      Finished:     Thu, 02 Jun 2022 07:20:11 +0900
	...
	...
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-bjj7g (ro
	...
	...
	...

팟은 serviceaccount에 대한 정보를 /var/run/secrets/kubernetes.io/serviceaccount에 항상 마운트하여 실행하도록 되어 있는데, 이를 사용하면 REST API를 호출할 수 있다. 이 경로에 대한 정보는 다음과 같다.

$ ls -la /var/run/secrets/kubernetes.io/serviceaccount/
total 4
drwxrwxrwt    3 root     root           140 Jun  5 11:39 .
drwxr-xr-x    3 root     root          4096 May 20 15:13 ..
drwxr-xr-x    2 root     root           100 Jun  5 11:39 ..2022_06_05_11_39_01.4056193682
lrwxrwxrwx    1 root     root            32 Jun  5 11:39 ..data -> ..2022_06_05_11_39_01.4056193682
lrwxrwxrwx    1 root     root            13 May 20 15:13 ca.crt -> ..data/ca.crt
lrwxrwxrwx    1 root     root            16 May 20 15:13 namespace -> ..data/namespace
lrwxrwxrwx    1 root     root            12 May 20 15:13 token -> ..data/token
$ cat /var/run/secrets/kubernetes.io/serviceaccount/token 
eyJhbG...3skuBdVDkEvTYA/
$ cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
cublr
$ cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
-----BEGIN CERTIFICATE-----
MIIBdjCCAR2...
...
...
-----END CERTIFICATE-----
  • ca.crt: 클러스터 API를 호출하기 위한 ca certificate, curl에서 --insecure옵션을 줄 거라면 안 써도 된다.
  • token: API를 호출하기 위해 필요한 토큰 정보
  • namespace: 이 팟이 속한 네임스페이스

이를 사용하면 단순 curl을 통해 kubernetes API를 호출할 수 있다. endpoint정보는 Kubernetes REST API Reference 를 확인하면 된다. 어쨌든 pod 리스트를 보고 싶다면 다음과 같은 형태로… curl을 호출하면 되겠다. Kubernetes의 API Endpoint는 기본 kubernetes.default이다. 아래에서는 kubernetes.default.svc로 호출하였다. 어차피 서비스 디스커버리에 다 포함된다.

export CA_CERTIFICATE=`/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`
export BEARER_TOKEN=`cat /var/run/secrets/kubernetes.io/serviceaccount/token`
export NAMESPACE=`cat /var/run/secrets/kubernetes.io/serviceaccount/namespace`
curl -X GET --cacert=$CA_CERTIFICATE --header "Authorization: Bearer $BEARER_TOKEN" https://kubernetes.default.svc/api/v1/namespaces/$NAMESPACE/pods

호출하면 다음과 같은 응답이 나온다. 당연히 권한이 없으므로 리스트는 못 본다.

$ curl -X GET --cacert $CA_CERTIFICATE --header "Authorization: Bearer $BEARER_TOKEN" https://kubernetes.default.svc/api/v1/namespaces/${NAMESPACE}/pods
{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {},
  "status": "Failure",
  "message": "pods is forbidden: User \"system:serviceaccount:cublr:default\" cannot list resource \"pods\" in API group \"\" in the namespace \"cublr\"",
  "reason": "Forbidden",
  "details": {
    "kind": "pods"
  },
  "code": 403
}

이는 kubectl에서는 kubectl get pod을 호출한 것과 같다. kubectl도 다 이 rest api를 호출하게 되어 있다.

PATCH 호출하기

REST API가 이젠 오래 전부터 쓰이는 기술이 되면서 이제 HTTP Method를 굉장히 다양하게 쓰기 시작했는데, 이 PATCH라는 것은… URL이 가리키는 리소스에 대해 어떤, partially update를 위해 주로 사용한다. kubectl에서는 kubectl patch명령을 호출한 것과 같다.

Secret의 PATCH는 PATCH /api/v1/namespaces/{namespace}/secrets/{name}로 호출하면 된다. 단 변경할 내용의 부분만을 HTTP Body로 전송하면 되는데, dockerconfigjson 타입 시크릿은 데이터가 data 밑에 있으므로 data아래 모든 내용을 준다. 그러면 데이터를 어떻게 만들어주냐 하면 이게 좀 번거로운데, helm의 Tip/Trick을 소개하는 페이지에서는 다음과 같은 형태로 설명한다.

{{- define "imagePullSecret" }}
{{- with .Values.imageCredentials }}
{{- printf "{\"auths\":{\"%s\":{\"username\":\"%s\",\"password\":\"%s\",\"email\":\"%s\",\"auth\":\"%s\"}}}" .registry .username .password .email (printf "%s:%s" .username .password | b64enc) | b64enc }}
{{- end }}
{{- end }}

이 내용에 써있는대로 조합해서 적당히 b64enc를 사용해서 만들면 된다. 여기서 .username은 AWS ECR에서는 AWS가 된다.

어쨌든 이렇게 ECR에 로그인 후 가져온 정보를 통해 secret의 내용을 만들었다면 다음과 같이 Kubernetes API를 호출할 수 있다. --header를 제대로 명시하지 않으면 HTTP 415 메시지가 등장한다.

$ /bin/curl -v \
  -X PATCH \
  --cacert ${CACERT}  \
  --header "Authorization: Bearer ${TOKEN}" \
  --header "Content-Type: application/merge-patch+json" \
  --data "$SECRETDATA" \
  ${APISERVER}/api/v1/namespaces/${NAMESPACE}/secrets/{{ include "ecr-refresher.imagePullSecretName" .}}

제대로 어플리케이션이 배포되었다면 cronjob을 수동으로 한 번 실행하는 것으로 secret이 업데이트되는 것을 확인할 수 있다.

$ kubectl create job -n cublr --from=cronjob/cublr-ecr-refresher 00001               
job.batch/00001 created

$ kubectl get secret -o yaml -n cublr cublr-ecr-refresher-credentials
apiVersion: v1
data:
  .dockerconfigjson: eyJhdXRocyI6I...FOVBRbz0ifX19Cg==
kind: Secret
metadata:
  annotations:
    meta.helm.sh/release-name: cublr-ecr-refresher
    meta.helm.sh/release-namespace: cublr
  creationTimestamp: "2022-06-05T10:12:41Z"
  labels:
    app.kubernetes.io/managed-by: Helm
  name: cublr-ecr-refresher-credentials
  namespace: cublr
  resourceVersion: "132513433"
  uid: 7ad5e38b-ec4c-4f44-8da9-3dd2f9ceb531
type: kubernetes.io/dockerconfigjson

이제 value에 명시된 period마다 secret을 업데이트하게 될 것이다.