GitHub Release Asset 다운로드 403 이슈


- name: Get Assets from GHR
  uri:
	url: "https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
	headers:
	  authorization: "Bearer {{ github_token }}"
	return_content: true
  register: response
 
- name: Download Asset from GHR
  get_url:
    url: "https://api.github.com/repos/{owner}/{repo}/releases/assets/{asset_id}"
	# 위 라인은 사실 url: "{{ response.json.assets[0].url }}"
    headers:
      authorization: "Bearer {{ github_token }}"
      accept: "application/octet-stream"
    dest: "/tmp/{{ asset_filename }}"
    mode: "0644"

EC2 Linux Base Image 베이킹을 위해 위와 같이 공통 바이너리를 설치하는 과정을 Ansible Playbook 으로 진행하고 있었다.

amazon-ebs.ths: fatal: [default]: FAILED! => {"changed": false, "dest": "/tmp/{asset_filename}", "elapsed": 0, "msg": "Request failed", "response": "HTTP Error 403: Server failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature.", "status_code": 403, "url": "https://api.github.com/repos/{owner}/{repo}/releases/assets/{asset_id}"}

어느날 갑작스럽게 위와 같은 403 에러가 발생하여 해당 에러를 트러블슈팅하는 과정을 기록하려한다.

GitHub 에서 Release Asset 을 다운로드하려면 우선 Get a release by tag name API 를 통해 Asset 을 다운로드할 수 있는 API 경로를 JSON response 에서 assets property 에 명시된 url 을 통해 받아와야한다.

API 경로는 Get a release asset 형식으로 주어지는데, 바이너리를 직접적으로 다운로드받기 위해선 Accept: application/octet-stream 헤더를 추가해주어야한다. 이때 GitHub 는 200 을 반환하며 직접 stream 을 시도할 수도 있고, 302 을 반환해 stream 받을 수 있는 storage 경로로 redirect 해줄 수도 있다.

302 Redirect Response 의 Location 헤더에 포함된 경로는 보통 pre-signed URL 로 GitHub 가 제공하는 다양한 storage backend 로 부터 바이너리를 다운받을 수 있다. (Related GitHub Discussion)

- name: Get Assets from GHR
  uri:
	url: "https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
	headers:
	  authorization: "Bearer {{ github_token }}"
	return_content: true
  register: response
 
- name: Get Asset metadata response
  uri:
    url: "https://api.github.com/repos/{owner}/{repo}/releases/assets/{asset_id}"
    # 위 라인은 사실 url: "{{ response.json.assets[0].url }}"
    headers:
      authorization: "Bearer {{ github_token }}"
      accept: "application/octet-stream"
    status_code: [200, 302]
    return_content: true
    follow_redirects: no
  register: asset_response
 
- name: Save binary stream to file (200 OK)
  copy:
    content: "{{ asset_response.content }}"
    dest: "/tmp/{{ asset_filename }}"
    mode: '0644'
  when: asset_response.status == 200
 
- name: Download binary from pre-signed URL (302 Redirect)
  get_url:
    url: "{{ asset_response.location }}"
    dest: "/tmp/{{ asset_filename }}"
    mode: '0644'
  when: asset_response.status == 302

처음엔 Ansible 의 get_url 모듈이 302 Redirect 를 처리하지 못하는 줄 알았다. pre-signed URL 은 기본적으로 추가적인 인증이 필요하지 않기 때문에 Authorization Header 와 충돌로 인한 이슈로 생각했다. 때문에 위와 같이 uri 모듈을 활용하여 HTTP Response Status Code 에 따라 다르게 처리하도록 Ansible Playbook 을 수정했고, 302 Redirect 를 따로 처리하여 403 에러 없이 바이너리를 성공적으로 다운받을 수 있었다.

curl -L \
	-H "Authorization: Bearer {{ github_token }}" \
	https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}
 
curl -v -L \
	-H "Authorization: Bearer {{ github_token }}" \
	-H "Accept: application/octet-stream" \
	-o {{ asset_filename }} \
	https://api.github.com/repos/{owner}/{repo}/releases/assets/{asset_id}

통신과정을 더 자세히 들여다보기 위해 위와 같이 curl command 를 통해 API 를 호출한 결과, curl command 는 302 Redirect 를 문제없이 처리하고 바이너리 역시 성공적으로 다운로드 받을 수 있었다.

때문에 더더욱 Ansible 의 get_url 모듈을 의심하게 되었고, 직접 소스코드를 찾아보기로했다. get_url 모듈은 내부적으로 urls 모듈에서 제공하는 fetch_url 을 사용하고 있었고, 이미 내부엔 Redirect 를 handle 하는 HTTPRedirectHandler 가 포함되어있었다.

이를 통해 Ansible 의 get_url 모듈의 문제는 아니라는 것을 알게되었고, 403 에러를 reproduce 하기 위해 Ansible Playbook 을 재실행해본 결과 이번엔 문제없이 바이너리가 다운로드 되었다.

간헐적으로 403 에러가 발생하는 것이 의아해서 실패했던 실행과 성공했던 실행의 Response 를 분석한 결과, 실패한 302 Redirect 의 Location Header 는 바이너리를 objects.githubusercontent.com 에서 받아오고 있었고, 성공한 것은 release-assets.githubusercontent.com 에서 바이너리를 받아오고 있었다.

아무래도 GitHub 가 다양한 storage backend 로 부터 바이너리를 streaming 하기 때문에 objects.githubusercontent.com 서버에서 뭔가 이슈가 있었거나 인증 정책이 달랐던 모양이다.

결론적으론 302 Redirect 대상 도메인에 따라 간헐적으로 403 이슈가 발생할 수 있으니, uri 모듈을 통해 직접 302 Location Header 를 확인하고 이후에 get_url 모듈을 통해 바이너리를 다운로드받는 방식으로 2단계에 거쳐 처리하는 것이 가장 안전해보인다.

References