home

Github Action을 좀 더 잘 써봅시다.

안녕하세요. 아이들나라 SRE 송호훈입니다.
Github Action… 너무 유명합니다. 구글 검색만 해봐도 국내에 수많은 기업들이 이미 사용하고 자랑하고 있죠. 그런데, 아이들나라에서는 이걸 좀 더 잘쓰고 싶다는 생각을 했습니다. 그래서 어떻게 하면 잘썼다고 소문이 날 지, 진지하지만 가볍게 고민을 시작했습니다. 오늘은 그 고민의 과정들을 여러분께 소개하고자 합니다.

우선, 왜 써야 하나 라는 고민으로 시작을 했습니다.

익숙한 Jenkins도 있고, AWS를 사용하니 code 시리즈도 있고. 사실 저희에게 선택지는 많았습니다. 그런데 굳이 왜 Github Action을 써야 할지, 근본적인 고민을 시작했습니다.
1.
우선 우리는 코드 저장소로 Github을 사용하고 있다.
2.
Github을 사용하니 Github action이 무료다.
3.
우린 사람이 없어서 CI/CD 플랫폼 구축하고 운영을 효율적으로 해야 한다. => 가장 중요

그리고 어떻게 쓰는 지 찾아봤습니다.

먼저 공식 문서부터 확인합니다. (GitHub Actions 설명서 - GitHub Docs )
Github Action에는 몇 가지 규칙이 있는데요!
1.
action의 위치는 .github/workflows 밑에 넣어야 합니다.
2.
action은 yaml 문법으로 작성해야 합니다.
근데 더 읽어보기에는 너무 많았습니다. 🥹

TL;DR; 일단 만든 사람들은 어떻게 쓰는 지도 확인했습니다.

github docs에 참고할 만한 액션이 넘쳐납니다. (GitHub - github/docs: The open-source repo for docs.github.com )
역시 빌형.. (저희도 조종해주세요)
(|member-dev:user) hohoon.song  ~/Workspace/app/docs   main  tree .github .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml ... │ └── partner-contributed-documentation.yml ├── PULL_REQUEST_TEMPLATE.md ├── actions │ ├── clone-translations │ │ └── action.yml ... │ └── setup-elasticsearch │ └── action.yml ├── actions-scripts │ ├── check-for-enterprise-issues-by-label.js │ ├── compress-large-files.js ... │ ├── rendered-content-link-checker.js │ └── what-docs-early-access-branch.js ├── config.yml ├── dependabot.yml ├── labeler.yml ├── review-template.md └── workflows ├── add-review-template.yml ├── auto-close-dependencies.yml ... ├── update-graphql-files.yml └── validate-asset-images.yml 10 directories, 100 files
Shell
복사
무려 파일이 100개
근데, 형 이건 너무 많아서 뭘 봐야 할지 모르겠어요! 좀 더 쉬운 거로 알려주세요!
오호 이거다!!
(|member-dev:user) hohoon.song  ~/Workspace/app/actions-cheat-sheet   master  tree .github .github └── workflows ├── build.yml └── experiment-with-label.yml
Shell
복사
이중에 build.yml이 뭔가 있어보입니다.
(⎈ |member-dev:user) hohoon.song  ~/Workspace/app/actions-cheat-sheet   master  cat .github/workflows/build.yml name: Generate Cheat Sheet on: push: branches-ignore: - 'master' paths: - '**.adoc' - 'theme/**' jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@0b496e91ec7ae4428c3ed2eeb4c3a40df431f2cc - name: Install fonts run: | mkdir ~/.fonts cp theme/fonts/*.otf ~/.fonts fc-cache -f -v - name: Setup Node uses: actions/setup-node@v1 - name: Cache node modules uses: actions/cache@v1 with: path: node_modules key: modules-${{ hashFiles('**/package-lock.json') }} restore-keys: | modules- - name: NPM Install run: npm install env: CI: true - name: AsciiDoctor-PDF run: npm run generate-pdf - name: Publish run: | git config --global user.email "actions@github.com" git config --global user.name "Actions" git add actions-cheat-sheet.* git commit -m 'AsciiDoctor-PDF build from Actions' git push --force origin HEAD:${GITHUB_REF#refs/heads/}
YAML
복사

언제나 인생은 실전이니까! 실제로 써봅니다.

우선 배포해도 타격이 적은(내꺼 아닌) 패키지 소스부터 작업을 시작합니다. 배포는 ‘main’ 기준으로 나갈 거니까 main push 이벤트를 트리거로 잡습니다.
테스트하면서 또는 급하면 다시 돌릴 필요도 있으니 workflow_dispatch 도 넣었습니다.
name: build-and-deploy on: workflow_dispatch: push: branches: - 'main' paths-ignore: - '**.md' - 'doc/**' - '.gitignore'
YAML
복사
그럼 이제 진짜로 빌드/배포 로직을 적어봅니다.
jobs: build-and-deploy: runs-on: ubuntu-latest steps: # 우선 checkout을 받고 - uses: actions/checkout@v3 # nodejs를 위해 준비 - uses: actions/setup-node@v3 with: node-version: '16' # 패키지 땡겨올때랑 배포할때 registry가 다르니까 스위칭용으로 깔아줍니다. - run: npm i npmrc -g # npm 설정 # 여기서 group은 npm-registry와 npm-private을 group으로 묶은 nexus repository입니다. # private은 사내 패키지 배포할 때 사용할 nexus repository입니다. # NX_USERNAME, NX_PASSWORD는 github 그룹이나 repository에 설정해줍니다. - run: | npmrc -c default npmrc -c group npm config set registry https://nexus.pf.i-nara.in/repository/npm-group/ TOKEN=`echo -n "${{ secrets.NX_USERNAME }}:${{ secrets.NX_PASSWORD }}" | base64` npm config set _auth="$TOKEN" npmrc -c private npm config set registry https://nexus.pf.i-nara.in/repository/npm-private/ npm config set _auth="$TOKEN" # 우선 group profile로 스위칭해서 빌드 - run: | npmrc group npm install -g yarn yarn yarn run build # 배포는 private에 해야되니 다시 스위칭하고 배포 - run: | npmrc private npm publish
YAML
복사

누구보다 빠르게 남들과는 다르게 써보고 싶다는 생각이 들었습니다.

쓰다 보니 빌드가 너무 느립니다. 가끔은 좀 자주 수십 분이 걸리기도 하고..
다들 ubuntu-latest 쓰길래 썼던 것뿐인데, 기본적으로 2core 7GB.
이제 나만의 빌드 머신을 구축하고 싶다는 생각이 들었습니다. 그런데, 우리는 AWS를 쓰고 있어서 머신을 고정적으로 쓰기가 뭔가 좀 어려운 감도 있었습니다.
그래서 kubernetes에서 빌드가 필요할 때만 pod 형태로 띄우는 것을 생각해보았습니다. 사실은 kubernetes가 중요한 게 아니라, 항상 깨끗한 환경에서 빌드하고 싶다는 마음이 더 크긴 했습니다.
역시 내가 생각하는 것은 누군가 이미 만들어놓은 것이 있었습니다. (GitHub - actions/actions-runner-controller: Kubernetes controller for GitHub Actions self-hosted runners )
이걸 사용해서 직접 구성을 해봅니다.
(|shared:actions-runner-system) hohoon.song  ~/Workspace/app  helm list NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION actions-runner-controller actions-runner-system 8 2022-12-28 09:37:49.573466 +0900 KST deployed actions-runner-controller-0.21.1 0.26.0 self-hosted-runner-for-org actions-runner-system 32 2023-03-06 11:26:56.97291 +0900 KST deployed self-hosted-runner-1.0.0 v2 self-hosted-runner-for-org-mem-intensive actions-runner-system 5 2023-03-06 11:27:05.830191 +0900 KST deployed self-hosted-runner-1.0.0 v2 (|shared:actions-runner-system) hohoon.song  ~/Workspace/app  k get po -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES actions-runner-controller-698cbd5b67-x4x5q 2/2 Running 0 15d 10.40.66.222 ip-10-40-51-85.ap-northeast-2.compute.internal <none> <none> actions-runner-controller-github-webhook-server-6867559f74w879r 2/2 Running 0 15d 10.40.66.215 ip-10-40-51-85.ap-northeast-2.compute.internal <none> <none> actions-runner-controller-github-webhook-server-6867559f74xx28p 2/2 Running 0 15d 10.40.66.220 ip-10-40-51-85.ap-northeast-2.compute.internal <none> <none> shared-self-hosted-runner-on-default-pkpl6-25j9g 2/2 Running 0 15h 10.40.80.81 ip-10-40-57-168.ap-northeast-2.compute.internal <none> <none> shared-self-hosted-runner-on-mem-intensive-gswfw-qd4p6 2/2 Running 0 3h18m 10.40.81.48 ip-10-40-56-148.ap-northeast-2.compute.internal <none> <none>
Shell
복사
이제 우리는 나만의 runner를 갖게 되었습니다. 물론 한대로는 부족하니 hpa로 필요에 따라 scale out도 합니다.

문제가 없을 때가 제일 불안하다

이제 좀 익숙해져서 좀 많이 쓰고 있었는데 왠지 빌드 할 때마다 불길한 느낌이 듭니다. deprecated…. nodejs12… set-output… 잘 쓰고 있었는데…
이걸 한 땀 한 땀 리포를 다 찾아가면서 바꾸려고 하니 내가 너무 멍청하게 일했나 자괴감까지도 듭니다.
‘괜찮아.. 괜찮아.. 그땐 이게 최선이었다고…’
deprecate 날짜는 정해져있고, 할 사람은 나밖에 없고, 또 한 번 어떻게 할지 고민에 빠지게 되었습니다.
그러던 와중 무언가를 찾게 되었습니다! organization 내에서는 workflow 공유가 되네?? github은 역시 글로벌넘버원   Sharing actions and workflows with your organization - GitHub Docs

그럼 이제 치울 차례입니다.

2가지 작업을 병행합니다.
1.
custom-action을 중앙 리포지토리로 모은다.
2.
action에 사용된 deprecate 되는 문법을 수정한다.
어떻게 모을 수 있을지 찾아봅니다.
github action은 3가지 custom action 타입을 제공하네요.
1.
docker
2.
javascript
3.
composite action
아이들나라는 대부분 composite action을 많이 사용하고, 일부는 javascript action을 사용하고 있습니다.
composite action을 위한 리포지토리를 하나 만듭니다.
이 때, 중요한 것은 repository의 settings → actions → general에서 권한을 Accessible from repositories in the 'kidsworld-service' organization 이상으로 설정해주는 것입니다. 이렇게 해야 같은 org내에 다른 repository에서 접근이 가능합니다.
만드는 것은 간단합니다. 짜잔~! 참 쉽죠. 프로젝트 루트에 action.yaml만 생성해 주면 됩니다.
내용도 간단합니다.
name: "Set AWS environment with Assuming" description: "Serve AWS CLI environment" inputs: # ------------------------------ # ! common # ------------------------------ aws-region: description: "AWS Region" required: false default: "ap-northeast-2" prefix-id: description: "Prefix string to consist AWS assumed session name" required: true unique-id: description: "Unique string to consist AWS assumed session name" required: true assume-duration-seconds: description: "Valid session duration on IAM Role Assuming (unit: second / range: 900 ~ 43200, default: 3600)" required: false default: "3600" # ------------------------------ # ! for ACT in local running # ------------------------------ act-role-arn: description: "IAM Role ARN (for ACT in local running)" required: true act-session-name: description: "Prefix string to verify assumed session (for ACT in local running)" required: false # ------------------------------ # ! for self-hosted runner # ------------------------------ federated-role-name: description: "IAM Role name federated on IRSA (for self-hosted runner in EKS)" required: true web-identity-token-file: description: "IRSA issued token path to use AssumeRoleWithWebIdentity API (for self-hosted runner in EKS)" required: false default: /var/run/secrets/eks.amazonaws.com/serviceaccount/token runs: using: composite steps: - name: 🏃 [AWS] Check inputs shell: bash run: | cat <<EOF 🔎 aws-region : ${{ inputs.aws-region }} 🔎 prefix-id : ${{ inputs.prefix-id }} 🔎 unique-id : ${{ inputs.unique-id }} 🔎 assume-duration-seconds : ${{ inputs.assume-duration-seconds }} 🔎 act-role-arn : ${{ inputs.act-role-arn }} 🔎 act-session-name : ${{ inputs.act-session-name }} 🔎 federated-role-name : ${{ inputs.federated-role-name }} 🔎 web-identity-token-file : ${{ inputs.web-identity-token-file }} EOF - name: 🏃 [AWS] Check 'aws-cli' exist id: check-aws-cli-exist shell: bash run: aws --version # In ACT using IAM User - if: ${{ env.ACT && !inputs.act-role-arn }} name: 🏃 [AWS] Assume (for ACT in local running) uses: aws-actions/configure-aws-credentials@v2 with: aws-region: "${{ inputs.aws-region }}" # In ACT using IAM Role - if: ${{ env.ACT && inputs.act-role-arn }} name: 🏃 [AWS] Assume (for ACT in local running) uses: aws-actions/configure-aws-credentials@v2 with: aws-region: "${{ inputs.aws-region }}" role-to-assume: "${{ inputs.act-role-arn }}" role-session-name: "${{ inputs.act-session-name }}-${{ inputs.prefix-id }}-${{ inputs.unique-id }}" role-duration-seconds: ${{ inputs.assume-duration-seconds }} # In Self-hosted Runner using IRSA - if: ${{ !env.ACT && inputs.federated-role-name }} name: 🏃 [AWS] Assume using IRSA # Ref. https://github.com/aws-actions/configure-aws-credentials#self-hosted-runners uses: aws-actions/configure-aws-credentials@v2 with: aws-region: "${{ inputs.aws-region }}" role-to-assume: "${{ inputs.federated-role-name }}" role-session-name: "${{ inputs.prefix-id }}-${{ inputs.unique-id }}" role-duration-seconds: ${{ inputs.assume-duration-seconds }} web-identity-token-file: "${{ inputs.web-identity-token-file }}" - if: ${{ env.ACT }} shell: bash name: 🏃 [AWS] Check identify (for ACT in local running) run: aws sts get-caller-identity --output json | jq
YAML
복사
이걸 가져다 쓰는 쪽에서는 마켓플레이스에 있는 action을 쓰듯이 그냥 쓰면 됩니다. 참 편한 세상입니다.
# ------------------------------ # > Set: AWS # ------------------------------ - name: 🚀 [SET] AWS CLI with Assuming uses: kidsworld-service/set-aws-cli-action@main with: # common aws-region: "${{ needs.fetch.outputs.AWS_REGION }}" prefix-id: "${{ needs.fetch.outputs.APP_NAME }}-${{ matrix.project }}" unique-id: "${{ needs.fetch.outputs.GIT_BRANCH_SHA }}-runner-${{ github.run_number }}" # for ACT in local running act-role-arn: "${{ env.AWS_ROLE_ARN }}" act-session-name: "${{ env.AWS_ROLE_SESSION_NAME }}" # for self-hosted runner in EKS federated-role-name: "${{ needs.fetch.outputs.FEDERATED_IR_NAME }}"
YAML
복사

그럼, 이제 javascript action입니다.

javascript는 뭔가 더 많은 것을 할 수 있는 대신 살짝 복잡합니다.
1.
일단 repository를 하나 만든다. (access 권한은 composite action과 동일하게 열어줘야 합니다.)
2.
npm init -y 을 실행해서 package.json 파일을 하나 만들어줍니다.
3.
이제 action.yaml 을 하나 만들고 짧게 자바스크립트를 쓸 것임을 적어줍니다.
name: 'Generate slack message' description: 'Generate slack message for build result' inputs: token: # id of input description: 'github token' required: true default: '' runs: using: 'node16' main: 'index.js'
YAML
복사
4.
필요한 패키지를 설치해 줍니다.
npm install @actions/core npm install @actions/github
Shell
복사
5.
이제 이걸 이용해서 index.js 파일을 적어줍니다.
const core = require('@actions/core'); const github = require('@actions/github'); const context = github.context async function generate({github, context, core}) { //////////////////////////////////// // retrieve workflow run data //////////////////////////////////// console.log("get workflow run") const token = core.getInput('token'); const octokit = github.getOctokit(token) const wf_run = await octokit.rest.actions.getWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, run_id: context.runId }) console.log(wf_run.data) console.log("get jobs for workflow run:", wf_run.data.jobs_url) const jobs_response = await octokit.rest.actions.listJobsForWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, run_id: context.runId }) //////////////////////////////////// // build slack notification message //////////////////////////////////// // some utility functions var date_diff_func = function(start, end) { var duration = end - start // format the duration var delta = duration / 1000 var days = Math.floor(delta / 86400) delta -= days * 86400 var hours = Math.floor(delta / 3600) % 24 delta -= hours * 3600 var minutes = Math.floor(delta / 60) % 60 delta -= minutes * 60 var seconds = Math.floor(delta % 60) var format_func = function(v, text, check) { if (v <= 0 && check) { return "" } else { return v + text } } return format_func(days, "d", true) + format_func(hours, "h", true) + format_func(minutes, "m", true) + format_func(seconds, "s", false) } var status_icon_func = function(s) { switch (s) { case "w_success": return ":white_check_mark:" case "w_failure": return ":no_entry:" case "w_cancelled": return ":white_check_mark:" case "success": return "\u2713" case "failure": return "\u2717" default: return "\u20e0" } } const commit = context.sha.substr(0, 6) var pr = "" for (p of wf_run.data.pull_requests) { pr += ",<"+ p.url + "|#" + p.number + ">" } if (pr != "") { pr = "for " + pr.substr(1) } // build the message var is_wf_success = true var is_wf_failure = false var build_count = 0 var max_block = 10 var fetch_fields = [] var build_fields = [] var finish_fields = [] var fetch_message = "" var build_message = "" var finish_message = "" for (j of jobs_response.data.jobs) { console.log(j.name, ":", j.status, j.conclusion, j.started_at, j.completed_at) // ignore the current job running this script if (j.status != "completed") { continue } if (j.conclusion != "success") { is_wf_success = false } if (j.conclusion == "failure") { is_wf_failure = true } if (j.name.startsWith("build")) { build_count += 1 } if (j.name.startsWith("fetch")) { fetch_message += status_icon_func(j.conclusion) + " <" + j.html_url + "|" + j.name + ">\n \u21b3 completed in " + date_diff_func(new Date(j.started_at), new Date(j.completed_at)) + "\n" } else if (j.name.startsWith("build") && build_count <= max_block ) { build_message += status_icon_func(j.conclusion) + " <" + j.html_url + "|" + j.name + ">\n \u21b3 completed in " + date_diff_func(new Date(j.started_at), new Date(j.completed_at)) + "\n" } else if (!j.name.startsWith("build")) { finish_message += status_icon_func(j.conclusion) + " <" + j.html_url + "|" + j.name + ">\n \u21b3 completed in " + date_diff_func(new Date(j.started_at), new Date(j.completed_at)) + "\n" } } if (fetch_message != null) { fetch_fields.push({ type: "mrkdwn", text: fetch_message }) } build_message = build_message == "" ? "no_build" : build_message; if (build_message != null) { build_fields.push({ type: "mrkdwn", text: build_message }) } if (finish_message != null) { finish_fields.push({ type: "mrkdwn", text: finish_message }) } var workflow_status = "w_cancelled" if (is_wf_success) { workflow_status = "w_success" } else if (is_wf_failure) { workflow_status = "w_failure" } var slack_msg = { blocks: [ { type: "section", text: { type: "mrkdwn", text: "<" + context.payload.repository.html_url + "|*" + context.payload.repository.full_name + "*>\nfrom *" + context.ref + "@" + commit + "*" } }, { type: "section", text: { type: "mrkdwn", text: status_icon_func(workflow_status) + " *" + context.workflow + "* " + pr + "\nWorkflow run <" + wf_run.data.html_url + "|#" + wf_run.data.run_number + "> completed in " + date_diff_func(new Date(wf_run.data.created_at), new Date(wf_run.data.updated_at)) } }, { type: "divider" }, { type: "section", fields: fetch_fields }, { type: "divider" }, { type: "section", fields: build_fields }, { type: "divider" }, { type: "section", fields: finish_fields }, ] } core.exportVariable('SLACK_MSG', slack_msg) } function start() { return generate({github, context, core}); } (async() => { await start(); })();
JavaScript
복사
6.

그래서, 중요한 것은 결국 무엇이 더 좋아졌느냐!

일을 했다면 이전과는 더 나아지는 게 있어야 하는 게 맞습니다. 무엇이 좋아졌을까 생각을 해봅니다.
1.
self hosted runner를 사용
a.
장점 :
i.
좀 더 빠르고 안정적인 빌드 환경을 만들 수 있습니다.
ii.
필요한 패키지(yq, java, nodejs, android, xcode 등)을 미리 설치해둘 수 있습니다.
b.
단점 : 아무래도 빌드 머신 유지비용이 좀 발생합니다.
2.
custom action을 사용
a.
장점 : 원하는 동작을 작성해서 재사용 할 수 있습니다.
b.
단점 : 없음
3.
custom action을 중앙 리포지토리로 이동
a.
장점 : 수정사항이 발생하면 중앙에서 한 번만 수정하면 모두 적용되어서 좋습니다.
b.
단점 : 중앙에서 버그가 있다면 다 같이 빌드가 멈출 수 있습니다. 이 부분은 tag 별로 분리할 수도 있으니 관리를 잘하면 됩니다.
저희는 이렇게 github action을 잘 쓰고 있습니다.
그리고, 이렇게 재밌는 일을 함께할 분을 모시고 있습니다. 들어오시면 더 재밌는 일이 많아요.
공병삼님, 다음으로 클라우드엔지니어는 무엇인가 한번 보여주시죠.