개요

Docker 이미지는 Base 이미지에 따라 특성이 달라집니다. 가장 널리 쓰이는 Base 이미지 중 Alpine과 Debian(ubuntu) 이미지가 있습니다. 회사 프로젝트에서 시간대 관련 오류의 원인을 파악하는 과정에서 이들의 cp 동작에 큰 차이가 있음을 발견하였습니다. 이에 따라 DevOps 파트에서 아래와 같은 기술 블로그 글을 작성하였습니다.

 

Alpine/Debian 기반 Docker 이미지 간 cp 명령 동작 차이

안녕하세요. 인프랩 DevOps Engineer 선비입니다! 오늘은 Docker를 다루며 발견하게 된 Alpine 기반 이미지의 cp 명령과 Debian 또는 Ubuntu 기반 이미지의 cp 명령의 동작 차이점을 소개해드리겠습니다. Docker

tech.inflab.com

 

문제 상황 시나리오

ubuntu 18.04

$ echo 1 > a
$ echo 2 > b

$ ln -s a c
$ cat a b c

1
2
1
$ cp b c
$ cat a b c

2
2
2

 

alpine 이미지 (node:gallium-alpine)

$ echo 1 > a
$ echo 2 > b

$ ln -s a c
$ cat a b c

1
2
1
$ cp b c
$ cat a b c 

1
2
2

 

원인 파악

심볼릭 링크가 걸린 c 파일을 cp 명령의 destination으로 잡고 b 를 복사한 결과 alpine 이미지와 ubuntu 가 다르게 동작하는 것이 파악되었습니다. 또한 ls 커맨드로 각각 조회한 결과 alpine 에서 파일 c 의 심볼릭 링크는 지워져 있고, ubuntu 에서 파일 c 의 심볼릭 링크는 살아 있습니다.

# ubuntu
$ ls -l

-rw-rw-r-- 1 ubuntu ubuntu 2 Jun  3 14:01 a
-rw-rw-r-- 1 ubuntu ubuntu 2 Jun  3 14:00 b
lrwxrwxrwx 1 ubuntu ubuntu 1 Jun  3 14:00 c -> a
# alpine
$ ls -l

-rw-r--r--    1 root     root             2 Jun  3 13:53 a
-rw-r--r--    1 root     root             2 Jun  3 13:53 b
-rw-r--r--    1 root     root             2 Jun  3 13:55 c

즉 두 리눅스의 cp 바이너리가 다름을 알 수 있습니다.

 

ubuntu cp

cp 의 man 페이지를 살펴보면 마지막의 SEE ALSO 섹션에 다음과 같이 나와 있습니다.

$ man cp
...
SEE ALSO
       Full documentation <https://www.gnu.org/software/coreutils/cp>
       or available locally via: info '(coreutils) cp invocation'

debian 계열 ubuntu cp 명령어는 GNU 재단 coreutils 임을 알 수 있습니다.

소스코드
https://github.com/coreutils/coreutils/blob/master/src/cp.c

 

GitHub - coreutils/coreutils: upstream mirror

upstream mirror. Contribute to coreutils/coreutils development by creating an account on GitHub.

github.com

소스코드를 분석하면 아래와 같습니다.

1. main() 에서 do_copy()를 호출

int main (int argc, char **argv) 
{
	exit_status |= do_copy (argc - optind, argv + optind, target_directory, &x);
}

 

2. do_copy()의 목적지 파일 설정부

지역변수 설정부

static int
do_copy (int n_files, char **file, const char *target_directory,
         struct cp_options *x)
{
  const char *dest; // destination의 디렉터리 경로
  struct stat sb; // 복사될 destination 파일의 파일 정보 구조체
  int new_dst = 0; 
  // 0이면 destination 경로가 바뀌지 않음
  // 1이면 destination 경로가 바뀜

  ...
  if (target_directory)
    dest = target_directory
  ...
}

istat 시스템 콜

https://linux.die.net/man/2/lstat

 

lstat(2): file status - Linux man page

lstat(2) - Linux man page Name stat, fstat, lstat - get file status Synopsis #include #include #include int stat(const char *path, struct stat *buf); int fstat(int fd, struct stat *buf); int lstat(const char *path, struct stat *buf); Feature Test Macro Req

linux.die.net

int lstat(const char *path, struct stat *buf)
  • lstat 시스템콜은 path의 인자로 들어온 파일의 정보를 얻어 파일 정보를 구조체 포인터 buf를 이용하여 저장한다.
  • 파일 정보 조회 성공시 0을 리턴하고 실패 시 -1을 리턴한다.
  • 기본적으로 stat 시스템 콜과 동일하게 동작하지만, 만약 path 가 심볼릭 링크라면 path로 지정된 파일의 정보를 얻어오는 것이 아닌 링크의 파일 정보를 저장한다.

symbolic link 여부 확인 조건문

{
  ...

  if (lstat (dest, &sb)) // 심볼릭 링크 확인
  {
    if (errno != ENOENT)
    {
      error (0, errno, _("accessing %s"), quote (dest));
      return 1;
    }
      new_dst = 1;
  }
  else // 심볼릭 링크가 아니라면.
  {
    struct stat sbx;
    /* If `dest' is not a symlink to a nonexistent file, use
      the results of stat instead of lstat, so we can copy files
      into symlinks to directories. */
    if (stat (dest, &sbx) == 0) // stat 시스템 콜로 파일 정보를 받아옴
      sb = sbx;

    dest_is_dir = S_ISDIR (sb.st_mode);
  }
  
  ...
}
  • if-else 조건문에서 lstat 시스템 콜을 호출하면 데비안 계열에서 심볼릭 링크였던 파일은 링크 파일의 정보를 받아 오게 되고 new_dst는 1로 세팅되면서 새로운 경로가 지정되었음을 flag로 표시
    • 만약 심볼릭 링크가 아니었다면 else 블럭이 실행되고, stat 시스템 콜을 통해 파일 정보를 받아오며, do_copy() 블록에 정의된 sb에 파일 정보를 저장함.

 

3. do_copy()의 destination file 타입 검사부

{
	...

	if(dest_is_dir)
	{
		...
	}
	else
	{
		char *new_dest; // 복사할 파일의 새 destination 경로
		char *source;
		struct stat source_stats;

		source = file[0]; //
		...

		if(... && !new_dst && ...) {
			/*
				lstat 를 호출하는 if문 블럭에서 
				new_dst 플래그가 1로 설정되어 있으므로
				이 부분은 실행되지 않음
			*/
		}
		else if (dest[strlen (dest) - 1] == '/'
          && lstat (source, &source_stats) == 0
          && !S_ISDIR (source_stats.st_mode))
		{
		
			char *source_base;
			ASSIGN_BASENAME_STRDUPA (source_base, source);
			new_dest = (char *) alloca (strlen (dest)
                                      + strlen (source_base) + 1);
			stpcpy (stpcpy (new_dest, dest), source_base);

		}
		else {
			new_dest = (char *) dest;
		}

		return copy (source, new_dest, new_dst, x, &unused, NULL);
	}
}
  • if 문은 실행되지 않으므로 else if 문 이 실행
    • destination 의 마지막 문자가 '/'로 끝나고, source 가 존재하고, 디렉터리가 아니어야 함. 
  • 이때 else if 문은 cp source dest/ 로 수행했던 사용자의 커맨드를 cp source dest/basename(source)으로 변경한다. 

 

alpine cp

$ which cp
/bin/cp
$ ls -l /bin/cp
lrwxrwxrwx    1 root     root            12 May 23 16:51 /bin/cp -> /bin/busybox

alpine의 커멘드 셋은 busybox 로 구성되어 있습니다.
busybox는 하나의 실행 파일 안에 구현된 리눅스 커널이 제공하는 인터페이스와 동일하게 동작하도록 설계되어 있습니다. 하지만 리눅스 커널과 동작이 일치를 보장하지 않습니다. 즉, alpine에서 수행되는 cp는 리눅스 커널 명령이 아니고 busybox에서 정의된 함수입니다.

https://github.com/mirror/busybox/blob/master/coreutils/cp.c

 

GitHub - mirror/busybox: BusyBox mirror

BusyBox mirror. Contribute to mirror/busybox development by creating an account on GitHub.

github.com

busybox cp 명령 코드

코드에서 GNU Coreutils의 cp처럼 destination이 심볼릭 링크 여부를 확인하는 로직은 보이지 않습니다.

 

결론

GNU Coreutils 기반의 cp 명령어는 destination(복사될 위치)가 심볼릭 링크 여부를 파악해서 심볼릭 원본에 복사하여 덮어 씌웁니다. 따라서 alpine과 debian의 cp 커맨드가 차이가 난 이유는 실행되는 바이너리 코드가 달랐고, debian에서는 파일 c를 덮어 씌운 것이 아니라 파일 a를 덮어씌웠기 때문입니다.