LabHub
学习 学习路径 课程

CI/CD 流水线

标签没有变,生产环境跑的却是另一个镜像

在 LabHub 中继续学习

한국어 원문으로 표시합니다.

목표

한 번 만든 산출물을 다시 만들지 않고 환경 사이로 옮기는 일을 손으로 합니다. 다이제스트로 지목해 승격하고, 태그가 움직인 것을 잡아내는 검사기와 기록에 없는 것을 막는 게이트를 세우고, 되돌리기를 옛 다이제스트의 재승격으로 실행하고, 어디에 무엇이 있는지 한 파일로 남깁니다.

왜 중요한가

환경마다 다시 빌드하면 환경마다 다른 것이 나옵니다. 베이스 이미지 태그가 움직이고, 의존성의 패치가 올라가고, 빌드 기계의 도구 판이 다릅니다. 그래서 개발계에서 통과한 시험은 운영 산출물에 대해서는 아무것도 말해 주지 않습니다 — 시험한 것과 배포한 것이 다른 물건이기 때문입니다. 한 번 만들고 그것을 옮기기만 하면 이 변수는 사라지는데, 그 원칙은 무엇을 옮기는지 가리키는 이름에서 새어 나갑니다. 태그는 사람이 붙인 이름표라 언제든 다른 것을 가리키게 만들 수 있고, 그 순간 어제 검증한 것과 오늘 배포되는 것이 갈라집니다. 다이제스트는 내용에서 계산된 주소라 옮겨지지 않습니다. 그래서 현장의 규칙은 하나로 모입니다 — 태그는 읽기 위해, 다이제스트는 지목하기 위해. 되돌리기도 같은 규칙 위에 섭니다. 옛 다이제스트가 레지스트리에 남아 있기만 하면 되돌리기는 다시 빌드하는 일이 아니라 옛 주소를 다시 가리키는 일이 되고, 되돌린 것이 예전과 같은 물건이라는 것을 바이트로 증명할 수 있습니다.

단계

  1. /root/promote/app 에 연습용 git 저장소를 만드세요 — 파일 main.py, orders.conf, README.md 를 두고 커밋을 두 개 이상 쌓은 뒤 마지막 커밋에 주석 없는 태그 v1.2.0 을 답니다. 그다음 /root/promote/release-name.sh <저장소경로> 를 만드세요. 저장소에서 판 이름 한 줄을 뽑아 표준출력에 내는 스크립트입니다. 규칙은 <가장 가까운 태그>-<그 태그 이후 커밋 수>-g<커밋 7자리> 이고, 커밋되지 않은 변경이 있으면 끝에 -dirty 를 붙입니다. 태그가 하나도 없는 저장소면 태그 자리에 v0.0.0 을, 커밋 수 자리에 전체 커밋 수를 씁니다. git 저장소가 아닌 경로를 받으면 표준출력에 아무것도 내지 말고 0 이 아닌 코드로 끝냅니다. 실행 권한을 주고 /root/promote/app 에 돌린 결과를 /root/promote/release.txt 에 한 줄로 저장하세요.
  2. 환경별 레지스트리를 OCI 이미지 레이아웃 디렉터리로 흉내 냅니다. /root/promote/reg 아래에 dev·staging·prod 를 둡니다. 빌드 기계가 이번 판의 산출물로 내놓은 것이 /opt/images/busybox_1.36.tar(oci-archive) 라고 봅니다. 이것을 개발계 레이아웃 /root/promote/reg/dev판 이름을 태그로 달아 한 번만 넣으세요(판 이름은 /root/promote/release.txt 의 값입니다). 그다음 /root/promote/resolve.sh <레이아웃> <태그> 를 만드세요 — 그 태그가 지금 가리키는 다이제스트를 sha256:<64자리 16진수> 한 줄로 내고 0 으로 끝냅니다. 그런 태그가 없으면 표준출력에 아무것도 내지 말고 3 으로, 레이아웃이 아닌 경로를 받으면 2 로 끝냅니다. 실행 권한을 준 뒤 그 다이제스트를 /root/promote/dev-digest.txt 에 한 줄로 저장하세요. 마지막으로 /root/promote/releases.tsv 를 만들고 첫 줄에 <다이제스트><탭><판이름><탭><커밋40자리> 를 적으세요. 커밋은 그 판을 만든 커밋의 전체 해시입니다.
  3. 누군가 같은 판 이름 태그로 다른 산출물을 개발계에 밀어 넣은 상황을 만듭니다. /opt/images/nginx_1.27-alpine.tar/root/promote/reg/dev같은 태그(판 이름)로 복사하세요. 그다음 /root/promote/tag-moved.txt 에 정확히 세 줄을 적으세요 — 첫 줄 before <다이제스트> 는 밀어 넣기 전에 그 태그가 가리키던 값, 둘째 줄 after <다이제스트> 는 지금 가리키는 값, 셋째 줄 orphan yes 또는 orphan no옛 다이제스트의 항목이 index.json 에 아직 남아 있는지입니다 (이름표를 잃었어도 항목과 블롭이 남아 있으면 yes). 값은 외워 적지 말고 그 자리에서 확인해 적으세요.
  4. /root/promote/promote.sh <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그> 를 만드세요. 원본에서 그 다이제스트를 찾아 대상 레이아웃에 그 태그로 올립니다. 다시 만들지 않습니다 — 옮긴 뒤 대상의 매니페스트 바이트가 원본과 한 바이트도 달라서는 안 됩니다. 원본에 그 다이제스트의 항목이나 블롭이 없으면 첫 줄에 NOTFOUND <다이제스트> 를 내고 3 으로, 성공하면 PROMOTED <다이제스트> 를 내고 0 으로, 그 밖의 잘못(원본이 레이아웃이 아니다, 다이제스트 꼴이 아니다, 복사가 실패했다)에는 ERROR 로 시작하는 줄을 내고 2 로 끝냅니다. 대상 레이아웃이 아직 없으면 만들어야 하고, 이름표가 없는(태그를 잃은) 다이제스트도 옮길 수 있어야 합니다. 실행 권한을 준 뒤 /root/promote/dev-digest.txt 의 다이제스트를 /root/promote/reg/dev 에서 /root/promote/reg/staging 으로 판 이름 태그를 달아 옮기고, 옮긴 뒤 스테이징에서 다시 읽은 다이제스트를 /root/promote/staging-digest.txt 에 한 줄로 저장하세요. 개발계에 적어 둔 값과 같아야 합니다.
  5. /root/promote/tag-drift.sh <레이아웃> <태그> <기대다이제스트> 를 만드세요. 그 태그가 지금 가리키는 값을 기대값과 대조합니다. 같으면 OK <다이제스트> 를 내고 0 으로, 다르면 MOVED <기대> <실제> 를 내고 3 으로, 그런 태그가 아예 없으면 MISSING <태그> 를 내고 4 로, 레이아웃이 아닌 경로면 ERROR 로 시작하는 줄을 내고 2 로 끝냅니다. 실행 권한을 준 뒤 /root/promote/dev-digest.txt 를 기대값으로 삼아 개발계에 돌린 결과를 /root/promote/drift-dev.txt 에, 스테이징에 돌린 결과를 /root/promote/drift-staging.txt 에 저장하세요. 개발계는 MOVED, 스테이징은 OK 가 나와야 합니다.
  6. /root/promote/approved.txt 를 만들고 스테이징에서 확인을 마친 다이제스트(개발계에 적어 둔 그 값)를 한 줄로 적으세요. # 로 시작하는 주석 줄과 빈 줄은 있어도 됩니다. 그다음 /root/promote/promote-gate.sh <승인목록> <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그> 를 만드세요. 승인 목록에 그 다이제스트가 한 줄 전체로 적혀 있을 때만 승격하고, 없으면 REFUSED <다이제스트> 를 내고 5 로 끝냅니다 — 이때 대상 레이아웃은 한 바이트도 달라지면 안 됩니다. 승격은 앞 단계의 promote.sh 에 넘기고 그 출력과 종료 코드를 그대로 전합니다(PROMOTED 0 · NOTFOUND 3 · ERROR 2). 승인 목록 파일이 없으면 ERROR 로 시작하는 줄을 내고 2 로 끝냅니다. 실행 권한을 준 뒤 승인된 다이제스트를 /root/promote/reg/staging 에서 /root/promote/reg/prod 로 태그 current 를 달아 승격하세요. 마지막으로 개발계의 태그가 지금 가리키는(승인되지 않은) 다이제스트로 같은 승격을 시도해 그 출력을 /root/promote/gate-refused.txt 에 저장하세요. 첫 줄은 REFUSED 로 시작해야 합니다.
  7. 새 판을 하나 더 내고 되돌려 봅니다. /root/promote/app 에 커밋을 하나 더 쌓고 태그 v1.3.0 을 단 뒤, 새 판 이름을 release-name.sh 로 뽑으세요. /opt/images/alpine_3.20.tar 를 그 판의 산출물로 보고 /root/promote/reg/dev 에 새 판 이름 태그로 넣고, /root/promote/releases.tsv 에 둘째 줄을 같은 형식으로 덧붙이고, /root/promote/approved.txt 에도 그 다이제스트를 한 줄 덧붙이세요. 그다음 게이트로 새 판을 /root/promote/reg/dev 에서 /root/promote/reg/prod 의 태그 current 로 승격하고, 바로 이어서 옛 판의 다이제스트를 같은 태그로 다시 승격해 되돌리세요. 마지막으로 /root/promote/rollback.txt 에 세 줄을 적으세요 — before <되돌리기 직전 current 가 가리키던 다이제스트>, after <되돌린 뒤의 다이제스트>, 그리고 identical yes 또는 identical no(되돌린 매니페스트의 바이트가 개발계에 있는 옛 판의 바이트와 같은지 cmp 로 확인한 결과).
  8. /root/promote/ledger.sh <레지스트리루트> <릴리스표> <출력파일> 을 만드세요. 레지스트리 루트 아래의 각 디렉터리를 환경 하나로 보고(그 안에 index.json 이 있는 것만), 이름표가 붙은 항목만 모아 탭으로 나눈 표를 출력 파일에 씁니다. 첫 줄은 머리글 env<탭>tag<탭>digest<탭>release<탭>commit 이고, 이어지는 줄은 <환경><탭><태그><탭><다이제스트><탭><판이름><탭><커밋> 입니다. 판 이름과 커밋은 릴리스 표에서 다이제스트로 찾고, 없으면 두 칸 모두 - 를 씁니다. 본문 줄은 LC_ALL=C sort 로 환경·태그·다이제스트 순서로 정렬합니다. 표준출력에는 LEDGER <본문 줄 수> 한 줄을 내고 0 으로 끝냅니다. 루트가 없거나 릴리스 표가 없으면 ERROR 로 시작하는 줄을 내고 2 로 끝냅니다. 출력 파일의 상위 디렉터리가 없으면 만듭니다. 실행 권한을 준 뒤 /root/promote/reg/root/promote/releases.tsv 로 돌려 /root/promote/ledger.tsv 를 남기세요.

참고

판 이름을 사람이 짓기 시작하자 어느 커밋인지 아무도 몰랐다

/root/promote/app 에 연습용 git 저장소를 만드세요 — 파일 main.py, orders.conf, README.md 를 두고 커밋을 두 개 이상 쌓은 뒤 마지막 커밋에 주석 없는 태그 v1.2.0 을 답니다. 그다음 /root/promote/release-name.sh <저장소경로> 를 만드세요. 저장소에서 판 이름 한 줄을 뽑아 표준출력에 내는 스크립트입니다. 규칙은 <가장 가까운 태그>-<그 태그 이후 커밋 수>-g<커밋 7자리> 이고, 커밋되지 않은 변경이 있으면 끝에 -dirty 를 붙입니다. 태그가 하나도 없는 저장소면 태그 자리에 v0.0.0 을, 커밋 수 자리에 전체 커밋 수를 씁니다. git 저장소가 아닌 경로를 받으면 표준출력에 아무것도 내지 말고 0 이 아닌 코드로 끝냅니다. 실행 권한을 주고 /root/promote/app 에 돌린 결과를 /root/promote/release.txt 에 한 줄로 저장하세요.

git describe--tags --long --abbrev=7 을 주면 태그가 없는 경우를 빼고 규칙 그대로의 문자열이 나옵니다. --dirty 도 함께 봅니다. 태그가 없으면 describe 자체가 실패하니 그때 쓸 갈래를 따로 두어야 합니다. set -e 를 걸면 그 실패에서 스크립트가 먼저 죽습니다. 커밋 수는 git rev-list --count, 짧은 해시는 git rev-parse --short=7 입니다.

산출물을 한 번만 만들고 그 다이제스트를 적어 두었다

환경별 레지스트리를 OCI 이미지 레이아웃 디렉터리로 흉내 냅니다. /root/promote/reg 아래에 dev·staging·prod 를 둡니다. 빌드 기계가 이번 판의 산출물로 내놓은 것이 /opt/images/busybox_1.36.tar(oci-archive) 라고 봅니다. 이것을 개발계 레이아웃 /root/promote/reg/dev판 이름을 태그로 달아 한 번만 넣으세요(판 이름은 /root/promote/release.txt 의 값입니다). 그다음 /root/promote/resolve.sh <레이아웃> <태그> 를 만드세요 — 그 태그가 지금 가리키는 다이제스트를 sha256:<64자리 16진수> 한 줄로 내고 0 으로 끝냅니다. 그런 태그가 없으면 표준출력에 아무것도 내지 말고 3 으로, 레이아웃이 아닌 경로를 받으면 2 로 끝냅니다. 실행 권한을 준 뒤 그 다이제스트를 /root/promote/dev-digest.txt 에 한 줄로 저장하세요. 마지막으로 /root/promote/releases.tsv 를 만들고 첫 줄에 <다이제스트><탭><판이름><탭><커밋40자리> 를 적으세요. 커밋은 그 판을 만든 커밋의 전체 해시입니다.

skopeo copy --insecure-policy oci-archive:<파일> oci:<디렉터리>:<태그> 가 레이아웃을 만듭니다 — 상위 디렉터리는 먼저 있어야 합니다. 레이아웃의 index.json 은 매니페스트 서술자(descriptor) 목록이고, 태그는 annotationsorg.opencontainers.image.ref.name 에 들어갑니다. jq 로 그 주석이 태그와 같은 항목의 .digest 를 꺼내면 됩니다. 값이 없을 때 jq 가 내는 null 을 그대로 흘려보내지 않도록 조심하세요. 탭은 printf '%s\t%s\t%s\n' 으로 넣습니다.

같은 태그에 다른 이미지를 밀어 넣자 이름표만 옮겨 갔다

누군가 같은 판 이름 태그로 다른 산출물을 개발계에 밀어 넣은 상황을 만듭니다. /opt/images/nginx_1.27-alpine.tar/root/promote/reg/dev같은 태그(판 이름)로 복사하세요. 그다음 /root/promote/tag-moved.txt 에 정확히 세 줄을 적으세요 — 첫 줄 before <다이제스트> 는 밀어 넣기 전에 그 태그가 가리키던 값, 둘째 줄 after <다이제스트> 는 지금 가리키는 값, 셋째 줄 orphan yes 또는 orphan no옛 다이제스트의 항목이 index.json 에 아직 남아 있는지입니다 (이름표를 잃었어도 항목과 블롭이 남아 있으면 yes). 값은 외워 적지 말고 그 자리에서 확인해 적으세요.

jq . reg/dev/index.json 을 밀어 넣기 전후로 한 번씩 보세요. 태그는 항목에 붙은 이름표일 뿐이고 실체는 blobs/sha256/<다이제스트> 라는 것이 여기서 드러납니다. 옛 항목이 어떻게 달라졌는지 annotations 를 눈으로 비교하세요. before 값은 앞 단계에서 이미 파일에 적어 두었습니다.

태그가 아니라 다이제스트를 지목해 스테이징으로 옮겼다

/root/promote/promote.sh <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그> 를 만드세요. 원본에서 그 다이제스트를 찾아 대상 레이아웃에 그 태그로 올립니다. 다시 만들지 않습니다 — 옮긴 뒤 대상의 매니페스트 바이트가 원본과 한 바이트도 달라서는 안 됩니다. 원본에 그 다이제스트의 항목이나 블롭이 없으면 첫 줄에 NOTFOUND <다이제스트> 를 내고 3 으로, 성공하면 PROMOTED <다이제스트> 를 내고 0 으로, 그 밖의 잘못(원본이 레이아웃이 아니다, 다이제스트 꼴이 아니다, 복사가 실패했다)에는 ERROR 로 시작하는 줄을 내고 2 로 끝냅니다. 대상 레이아웃이 아직 없으면 만들어야 하고, 이름표가 없는(태그를 잃은) 다이제스트도 옮길 수 있어야 합니다. 실행 권한을 준 뒤 /root/promote/dev-digest.txt 의 다이제스트를 /root/promote/reg/dev 에서 /root/promote/reg/staging 으로 판 이름 태그를 달아 옮기고, 옮긴 뒤 스테이징에서 다시 읽은 다이제스트를 /root/promote/staging-digest.txt 에 한 줄로 저장하세요. 개발계에 적어 둔 값과 같아야 합니다.

실측: 이 skopeo(1.13.3)의 oci: 전송은 다이제스트 참조를 받지 않습니다 — oci:<디렉터리>@sha256:...oci:<디렉터리>:sha256:... 도 거절합니다. 그런데 레이아웃의 index.json 은 그저 서술자 목록이고, 서술자 하나에 이름표를 붙이는 일은 jq 로 할 수 있습니다. 그 다이제스트 하나만 담은 index.json 을 임시 디렉터리에 만들고 blobs 는 원본을 가리키게 하면, skopeo 가 그 임시 레이아웃을 원본으로 읽습니다. 그 가리킴이 임시 디렉터리 안에서 풀리므로 원본 경로는 절대 경로여야 합니다. 임시 디렉터리는 mktemp -dtrap ... EXIT 으로 치웁니다.

검사기를 붙이자 개발계의 태그가 움직인 것이 드러났다

/root/promote/tag-drift.sh <레이아웃> <태그> <기대다이제스트> 를 만드세요. 그 태그가 지금 가리키는 값을 기대값과 대조합니다. 같으면 OK <다이제스트> 를 내고 0 으로, 다르면 MOVED <기대> <실제> 를 내고 3 으로, 그런 태그가 아예 없으면 MISSING <태그> 를 내고 4 로, 레이아웃이 아닌 경로면 ERROR 로 시작하는 줄을 내고 2 로 끝냅니다. 실행 권한을 준 뒤 /root/promote/dev-digest.txt 를 기대값으로 삼아 개발계에 돌린 결과를 /root/promote/drift-dev.txt 에, 스테이징에 돌린 결과를 /root/promote/drift-staging.txt 에 저장하세요. 개발계는 MOVED, 스테이징은 OK 가 나와야 합니다.

앞 단계에서 만든 resolve.sh 를 다시 쓰세요 — 같은 일을 두 번 구현하면 두 곳이 어긋납니다. 스크립트가 자기 옆의 스크립트를 부르려면 $(dirname "$0") 를 씁니다. 태그가 없는 경우와 태그가 다른 값을 가리키는 경우는 원인도 대응도 다르니 종료 코드를 섞지 마세요. 채점기는 자기가 만든 레이아웃으로 세 가지 결말을 모두 시험합니다.

기록에 없는 다이제스트가 운영 문턱에서 멈춰 섰다

/root/promote/approved.txt 를 만들고 스테이징에서 확인을 마친 다이제스트(개발계에 적어 둔 그 값)를 한 줄로 적으세요. # 로 시작하는 주석 줄과 빈 줄은 있어도 됩니다. 그다음 /root/promote/promote-gate.sh <승인목록> <원본레이아웃> <대상레이아웃> <다이제스트> <대상태그> 를 만드세요. 승인 목록에 그 다이제스트가 한 줄 전체로 적혀 있을 때만 승격하고, 없으면 REFUSED <다이제스트> 를 내고 5 로 끝냅니다 — 이때 대상 레이아웃은 한 바이트도 달라지면 안 됩니다. 승격은 앞 단계의 promote.sh 에 넘기고 그 출력과 종료 코드를 그대로 전합니다(PROMOTED 0 · NOTFOUND 3 · ERROR 2). 승인 목록 파일이 없으면 ERROR 로 시작하는 줄을 내고 2 로 끝냅니다. 실행 권한을 준 뒤 승인된 다이제스트를 /root/promote/reg/staging 에서 /root/promote/reg/prod 로 태그 current 를 달아 승격하세요. 마지막으로 개발계의 태그가 지금 가리키는(승인되지 않은) 다이제스트로 같은 승격을 시도해 그 출력을 /root/promote/gate-refused.txt 에 저장하세요. 첫 줄은 REFUSED 로 시작해야 합니다.

grep 으로 목록을 뒤질 때 -x(줄 전체 일치)와 -F(정규식으로 읽지 않기)를 함께 주세요. 둘 중 하나라도 빠지면 주석에 적힌 다이제스트나 더 긴 문자열의 일부가 승인으로 읽힙니다 — 채점기가 그 두 가지를 그대로 시험합니다. 거절할 때는 복사를 아예 시작하지 않아야 합니다. 종료 코드를 그대로 전하려면 부른 스크립트의 $? 를 받아 두었다가 마지막에 exit 하세요.

되돌리기는 다시 빌드하는 일이 아니라 옛 다이제스트를 다시 승격하는 일이었다

새 판을 하나 더 내고 되돌려 봅니다. /root/promote/app 에 커밋을 하나 더 쌓고 태그 v1.3.0 을 단 뒤, 새 판 이름을 release-name.sh 로 뽑으세요. /opt/images/alpine_3.20.tar 를 그 판의 산출물로 보고 /root/promote/reg/dev 에 새 판 이름 태그로 넣고, /root/promote/releases.tsv 에 둘째 줄을 같은 형식으로 덧붙이고, /root/promote/approved.txt 에도 그 다이제스트를 한 줄 덧붙이세요. 그다음 게이트로 새 판을 /root/promote/reg/dev 에서 /root/promote/reg/prod 의 태그 current 로 승격하고, 바로 이어서 옛 판의 다이제스트를 같은 태그로 다시 승격해 되돌리세요. 마지막으로 /root/promote/rollback.txt 에 세 줄을 적으세요 — before <되돌리기 직전 current 가 가리키던 다이제스트>, after <되돌린 뒤의 다이제스트>, 그리고 identical yes 또는 identical no(되돌린 매니페스트의 바이트가 개발계에 있는 옛 판의 바이트와 같은지 cmp 로 확인한 결과).

되돌리기에 새로운 기구는 필요 없습니다 — 앞 단계의 게이트를 옛 다이제스트로 한 번 더 부르는 것이 전부입니다. 이것이 성립하려면 옛 블롭을 지우지 않았어야 하고, 그 값이 releases.tsv 에 적혀 있어야 합니다. cmp 로 견줄 두 파일은 각 레이아웃의 blobs/sha256/<다이제스트의 16진수 부분> 입니다. cmp 는 다르면 0 이 아닌 값을 내니 set -e 를 걸어 두면 거기서 멈춥니다.

어디에 무엇이 떠 있는지 한 파일로 답하게 만들었다

/root/promote/ledger.sh <레지스트리루트> <릴리스표> <출력파일> 을 만드세요. 레지스트리 루트 아래의 각 디렉터리를 환경 하나로 보고(그 안에 index.json 이 있는 것만), 이름표가 붙은 항목만 모아 탭으로 나눈 표를 출력 파일에 씁니다. 첫 줄은 머리글 env<탭>tag<탭>digest<탭>release<탭>commit 이고, 이어지는 줄은 <환경><탭><태그><탭><다이제스트><탭><판이름><탭><커밋> 입니다. 판 이름과 커밋은 릴리스 표에서 다이제스트로 찾고, 없으면 두 칸 모두 - 를 씁니다. 본문 줄은 LC_ALL=C sort 로 환경·태그·다이제스트 순서로 정렬합니다. 표준출력에는 LEDGER <본문 줄 수> 한 줄을 내고 0 으로 끝냅니다. 루트가 없거나 릴리스 표가 없으면 ERROR 로 시작하는 줄을 내고 2 로 끝냅니다. 출력 파일의 상위 디렉터리가 없으면 만듭니다. 실행 권한을 준 뒤 /root/promote/reg/root/promote/releases.tsv 로 돌려 /root/promote/ledger.tsv 를 남기세요.

환경 목록을 스크립트에 적어 넣지 마세요 — 루트를 훑어야 환경이 늘어도 그대로 돕니다. jq 의 @tsv 가 탭으로 이어 줍니다. 이름표가 없는 항목은 annotations 가 아예 없거나 그 열쇠가 없습니다. 표 두 개를 다이제스트로 잇는 일은 awk -F'\t' 에 파일 두 개를 주고 NR == FNR 로 첫 파일을 기억하는 방식이 짧습니다. 정렬은 머리글을 붙이기 전에 해야 머리글이 가운데로 끼지 않습니다.